From e07415a2e212e9a12fb7bd6f6cbc0eb45c4c11b1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Oct 2025 14:41:03 -0700 Subject: [PATCH 001/702] build: framegen can use self-hosted This was a red herring when I was doing the 0.15 port. It works with self-hosted just fine. --- src/build/GhosttyFrameData.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/build/GhosttyFrameData.zig b/src/build/GhosttyFrameData.zig index def1dbdb3..7193162bd 100644 --- a/src/build/GhosttyFrameData.zig +++ b/src/build/GhosttyFrameData.zig @@ -43,7 +43,6 @@ pub fn distResources(b: *std.Build) struct { .root_module = b.createModule(.{ .target = b.graph.host, }), - .use_llvm = true, }); exe.addCSourceFile(.{ .file = b.path("src/build/framegen/main.c"), From 5360aeb8aab98cbf921107432e90ed8d4817be17 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Fri, 3 Oct 2025 22:20:40 -0700 Subject: [PATCH 002/702] Fix botched cherry-pick from #8990 --- src/font/face/coretext.zig | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index bd1716a61..9e7bc4d5d 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -363,7 +363,11 @@ pub const Face = struct { // We center all glyphs within the pixel-rounded and adjusted // cell width if it's larger than the face width, so that they // aren't weirdly off to the left. - if (metrics.face_width < cell_width) { + // + // 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)) { // We add half the difference to re-center. x += (cell_width - metrics.face_width) / 2; } @@ -378,18 +382,6 @@ pub const Face = struct { y = @round(y); } - // We center all glyphs within the pixel-rounded and adjusted - // cell width if it's larger than the face width, so that they - // aren't weirdly off to the left. - // - // 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)) { - // We add half the difference to re-center. - x += (cell_width - metrics.face_width) / 2; - } - // We make an assumption that font smoothing ("thicken") // adds no more than 1 extra pixel to any edge. We don't // add extra size if it's a sbix color font though, since From 6bc60b6c643cf3036b553ce15b202d895d4b9308 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Fri, 3 Oct 2025 21:51:25 -0700 Subject: [PATCH 003/702] Add comprehensive constraint tests --- src/font/face.zig | 193 +++++++++++++++++++++++++++++++++++++ src/font/face/freetype.zig | 37 ------- 2 files changed, 193 insertions(+), 37 deletions(-) diff --git a/src/font/face.zig b/src/font/face.zig index f660565fe..586de4ad6 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -507,3 +507,196 @@ test "Variation.Id: slnt should be 1936486004" { try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id))); try testing.expectEqualStrings("slnt", &(id.str())); } + +test "Constraints" { + const comparison = @import("../datastruct/comparison.zig"); + const getConstraint = @import("nerd_font_attributes.zig").getConstraint; + + // Hardcoded data matches metrics from CoreText at size 12 and DPI 96. + + // Define grid metrics (matches font-family = JetBrains Mono) + const metrics: Metrics = .{ + .cell_width = 10, + .cell_height = 22, + .cell_baseline = 5, + .underline_position = 19, + .underline_thickness = 1, + .strikethrough_position = 12, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = 22, + .icon_height = 44.48 / 3.0, + .face_width = 9.6, + .face_height = 21.12, + .face_y = 0.2, + }; + + // ASCII (no constraint). + { + const constraint: RenderOptions.Constraint = .none; + + // BBox of 'x' from JetBrains Mono. + const glyph_x: GlyphSize = .{ + .width = 6.784, + .height = 15.28, + .x = 1.408, + .y = 4.84, + }; + + // Any constraint width: do nothing. + inline for (.{ 1, 2 }) |constraint_width| { + try comparison.expectApproxEqual( + glyph_x, + constraint.constrain(glyph_x, metrics, constraint_width), + ); + } + } + + // Symbol (same constraint as hardcoded in Renderer.addGlyph). + { + const constraint: RenderOptions.Constraint = .{ .size = .fit }; + + // BBox of '■' (0x25A0 black square) from Iosevka. + // NOTE: This glyph is designed to span two cells. + const glyph_25A0: GlyphSize = .{ + .width = 10.272, + .height = 10.272, + .x = 2.864, + .y = 5.304, + }; + + // Constraint width 1: scale down and shift to fit a single cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = metrics.face_width, + .height = metrics.face_width, + .x = 0, + .y = 5.64, + }, + constraint.constrain(glyph_25A0, metrics, 1), + ); + + // Constraint width 2: do nothing. + try comparison.expectApproxEqual( + glyph_25A0, + constraint.constrain(glyph_25A0, metrics, 2), + ); + } + + // Emoji (same constraint as hardcoded in SharedGrid.renderGlyph). + { + const constraint: RenderOptions.Constraint = .{ + .size = .cover, + .align_horizontal = .center, + .align_vertical = .center, + .pad_left = 0.025, + .pad_right = 0.025, + }; + + // BBox of '🥸' (0x1F978) from Apple Color Emoji. + const glyph_1F978: GlyphSize = .{ + .width = 20, + .height = 20, + .x = 0.46, + .y = 1, + }; + + // Constraint width 2: scale to cover two cells with padding, center; + try comparison.expectApproxEqual( + GlyphSize{ + .width = 18.72, + .height = 18.72, + .x = 0.44, + .y = 1.4, + }, + constraint.constrain(glyph_1F978, metrics, 2), + ); + } + + // Nerd Font default. + { + const constraint = getConstraint(0xea61).?; + + // Verify that this is the constraint we expect. + try std.testing.expectEqual(.fit_cover1, constraint.size); + try std.testing.expectEqual(.icon, constraint.height); + try std.testing.expectEqual(.center1, constraint.align_horizontal); + try std.testing.expectEqual(.center1, constraint.align_vertical); + + // BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only. + // NOTE: This icon is part of a group, so the + // constraint applies to a larger bounding box. + const glyph_EA61: GlyphSize = .{ + .width = 9.015625, + .height = 13.015625, + .x = 3.015625, + .y = 3.76525, + }; + + // Constraint width 1: scale and shift group to fit a single cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = 7.2125, + .height = 10.4125, + .x = 0.8125, + .y = 5.950695224719102, + }, + constraint.constrain(glyph_EA61, metrics, 1), + ); + + // Constraint width 2: no scaling; left-align and vertically center group. + try comparison.expectApproxEqual( + GlyphSize{ + .width = glyph_EA61.width, + .height = glyph_EA61.height, + .x = 1.015625, + .y = 4.7483690308988775, + }, + constraint.constrain(glyph_EA61, metrics, 2), + ); + } + + // Nerd Font stretch. + { + const constraint = getConstraint(0xe0c0).?; + + // Verify that this is the constraint we expect. + try std.testing.expectEqual(.stretch, constraint.size); + try std.testing.expectEqual(.cell, constraint.height); + try std.testing.expectEqual(.start, constraint.align_horizontal); + try std.testing.expectEqual(.center1, constraint.align_vertical); + + // BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only. + const glyph_E0C0: GlyphSize = .{ + .width = 16.796875, + .height = 16.46875, + .x = -0.796875, + .y = 1.7109375, + }; + + // Constraint width 1: stretch and position to exactly cover one cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = @floatFromInt(metrics.cell_width), + .height = @floatFromInt(metrics.cell_height), + .x = 0, + .y = 0, + }, + constraint.constrain(glyph_E0C0, metrics, 1), + ); + + // Constraint width 1: stretch and position to exactly cover two cells. + try comparison.expectApproxEqual( + GlyphSize{ + .width = @floatFromInt(2 * metrics.cell_width), + .height = @floatFromInt(metrics.cell_height), + .x = 0, + .y = 0, + }, + constraint.constrain(glyph_E0C0, metrics, 2), + ); + } +} diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 259e91b8c..4958c48c8 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -1177,43 +1177,6 @@ test "color emoji" { const glyph_id = ft_font.glyphIndex('🥸').?; try testing.expect(ft_font.isColorGlyph(glyph_id)); } - - // resize - // TODO: Comprehensive tests for constraints, - // this is just an adapted legacy test. - { - const glyph = try ft_font.renderGlyph( - alloc, - &atlas, - ft_font.glyphIndex('🥸').?, - .{ - .grid_metrics = .{ - .cell_width = 13, - .cell_height = 24, - .cell_baseline = 0, - .underline_position = 0, - .underline_thickness = 0, - .strikethrough_position = 0, - .strikethrough_thickness = 0, - .overline_position = 0, - .overline_thickness = 0, - .box_thickness = 0, - .cursor_height = 0, - .icon_height = 0, - .face_width = 13, - .face_height = 24, - .face_y = 0, - }, - .constraint_width = 2, - .constraint = .{ - .size = .fit, - .align_horizontal = .center, - .align_vertical = .center, - }, - }, - ); - try testing.expectEqual(@as(u32, 24), glyph.height); - } } test "mono to bgra" { From 0b14026696e0bf54d5dba8940d8ae97201ddab27 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 4 Oct 2025 00:04:19 -0700 Subject: [PATCH 004/702] Expand `~` in `macos-custom-icon` --- macos/Sources/Ghostty/Ghostty.Config.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 05a3be2cd..0d75922cb 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -314,17 +314,14 @@ extension Ghostty { var macosCustomIcon: String { #if os(macOS) - let homeDirURL = FileManager.default.homeDirectoryForCurrentUser - let ghosttyConfigIconPath = homeDirURL.appendingPathComponent( - ".config/ghostty/Ghostty.icns", - conformingTo: .fileURL).path() - let defaultValue = ghosttyConfigIconPath + let defaultValue = NSString("~/.config/ghostty/Ghostty.icns").expandingTildeInPath 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 let ptr = v else { return defaultValue } - return String(cString: ptr) + guard let path = NSString(utf8String: ptr) else { return defaultValue } + return path.expandingTildeInPath #else return "" #endif From 3620132dfc449b95270703c386556278f26fa7f0 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 4 Oct 2025 00:06:41 -0700 Subject: [PATCH 005/702] Remove incorrect note from config docs --- src/config/Config.zig | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index caaf5feb8..bd2fe1cfb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2957,9 +2957,6 @@ keybind: Keybinds = .{}, /// Supported formats include PNG, JPEG, and ICNS. /// /// Defaults to `~/.config/ghostty/Ghostty.icns` -/// -/// Note: This configuration is required when `macos-icon` is set to -/// `custom` @"macos-custom-icon": ?[:0]const u8 = null, /// The material to use for the frame of the macOS app icon. From d4dcecb071a113f5a27ab2948c721384210114fc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Oct 2025 13:24:34 -0700 Subject: [PATCH 006/702] Move paste encoding to the input package, test, optimize away one alloc This moves our paste logic to `src/input` in preparation for exposing this as part of libghostty-vt. This yields an immediate benefit of unit tests for paste encoding. Additionally, we were able to remove one allocation on every unbracketed paste path unless the input specifically contains a newline. Unlikely to be noticable, but nice. NOTE: This also includes one change in behavior: we no longer encode `\r\n` and a single `\r`, but as a duplicate `\r\r`. This matches xterm behavior and I don't think will result in any issues since duplicate carriage returns should do nothing in well-behaved terminals. --- src/Surface.zig | 76 +++++++------------- src/input.zig | 1 + src/input/paste.zig | 146 ++++++++++++++++++++++++++++++++++++++ src/lib_vt.zig | 13 ++++ src/terminal/main.zig | 3 - src/terminal/modes.zig | 2 +- src/terminal/sanitize.zig | 14 ---- 7 files changed, 186 insertions(+), 69 deletions(-) create mode 100644 src/input/paste.zig delete mode 100644 src/terminal/sanitize.zig diff --git a/src/Surface.zig b/src/Surface.zig index 018c4206b..5aabb2b80 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5270,13 +5270,10 @@ fn completeClipboardPaste( ) !void { if (data.len == 0) return; - const critical: struct { - bracketed: bool, - } = critical: { + const encode_opts: input.paste.Options = encode_opts: { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - - const bracketed = self.io.terminal.modes.get(.bracketed_paste); + const opts: input.paste.Options = .fromTerminal(&self.io.terminal); // If we have paste protection enabled, we detect unsafe pastes and return // an error. The error approach allows apprt to attempt to complete the paste @@ -5292,7 +5289,7 @@ fn completeClipboardPaste( // This is set during confirmation usually. if (allow_unsafe) break :unsafe false; - if (bracketed) { + if (opts.bracketed) { // If we're bracketed and the paste contains and ending // bracket then something naughty might be going on and we // never trust it. @@ -5303,7 +5300,7 @@ fn completeClipboardPaste( if (self.config.clipboard_paste_bracketed_safe) break :unsafe false; } - break :unsafe !terminal.isSafePaste(data); + break :unsafe !input.paste.isSafe(data); }; if (unsafe) { @@ -5317,55 +5314,32 @@ fn completeClipboardPaste( log.warn("error scrolling to bottom err={}", .{err}); }; - break :critical .{ - .bracketed = bracketed, - }; + break :encode_opts opts; }; - if (critical.bracketed) { - // If we're bracketd we write the data as-is to the terminal with - // the bracketed paste escape codes around it. - self.io.queueMessage(.{ - .write_stable = "\x1B[200~", - }, .unlocked); + // Encode the data. In most cases this doesn't require any + // copies, so we optimize for that case. + var data_duped: ?[]u8 = null; + const vecs = input.paste.encode(data, encode_opts) catch |err| switch (err) { + error.MutableRequired => vecs: { + const buf: []u8 = try self.alloc.dupe(u8, data); + errdefer self.alloc.free(buf); + data_duped = buf; + break :vecs input.paste.encode(buf, encode_opts); + }, + }; + defer if (data_duped) |v| { + // This code path means the data did require a copy and mutation. + // We must free it. + self.alloc.free(v); + }; + + for (vecs) |vec| if (vec.len > 0) { self.io.queueMessage(try termio.Message.writeReq( self.alloc, - data, + vec, ), .unlocked); - self.io.queueMessage(.{ - .write_stable = "\x1B[201~", - }, .unlocked); - } else { - // If its not bracketed the input bytes are indistinguishable from - // keystrokes, so we must be careful. For example, we must replace - // any newlines with '\r'. - - // We just do a heap allocation here because its easy and I don't think - // worth the optimization of using small messages. - var buf = try self.alloc.alloc(u8, data.len); - defer self.alloc.free(buf); - - // This is super, super suboptimal. We can easily make use of SIMD - // here, but maybe LLVM in release mode is smart enough to figure - // out something clever. Either way, large non-bracketed pastes are - // increasingly rare for modern applications. - var len: usize = 0; - for (data, 0..) |ch, i| { - const dch = switch (ch) { - '\n' => '\r', - '\r' => if (i + 1 < data.len and data[i + 1] == '\n') continue else ch, - else => ch, - }; - - buf[len] = dch; - len += 1; - } - - self.io.queueMessage(try termio.Message.writeReq( - self.alloc, - buf[0..len], - ), .unlocked); - } + }; } fn completeClipboardReadOSC52( diff --git a/src/input.zig b/src/input.zig index caaf80509..06d7dc96a 100644 --- a/src/input.zig +++ b/src/input.zig @@ -9,6 +9,7 @@ pub const command = @import("input/command.zig"); pub const function_keys = @import("input/function_keys.zig"); pub const keycodes = @import("input/keycodes.zig"); pub const kitty = @import("input/kitty.zig"); +pub const paste = @import("input/paste.zig"); pub const ctrlOrSuper = key.ctrlOrSuper; pub const Action = key.Action; diff --git a/src/input/paste.zig b/src/input/paste.zig new file mode 100644 index 000000000..29787c385 --- /dev/null +++ b/src/input/paste.zig @@ -0,0 +1,146 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Terminal = @import("../terminal/Terminal.zig"); + +pub const Options = struct { + /// True if bracketed paste mode is on. + bracketed: bool, + + /// Return the encoding options based on the current terminal state. + pub fn fromTerminal(t: *const Terminal) Options { + return .{ + .bracketed = t.modes.get(.bracketed_paste), + }; + } +}; + +/// Encode the given data for pasting. The resulting value can be written +/// to the pty to perform a paste of the input data. +/// +/// The data can be either a `[]u8` or a `[]const u8`. If the data +/// type is const then `EncodeError` may be returned. If the data type +/// is mutable then this function can't return an error. +/// +/// This slightly complex calling style allows for initially const +/// data to be passed in without an allocation, since it is rare in normal +/// use cases that the data will need to be modified. In the unlikely case +/// data does need to be modified, the caller can make a mutable copy +/// after seeing the error. +/// +/// The data is returned as a set of slices to limit allocations. The caller +/// can combine the slices into a single buffer if desired. +/// +/// WARNING: The input data is not checked for safety. See the `isSafe` +/// function to check if the data is safe to paste. +pub fn encode( + data: anytype, + opts: Options, +) switch (@TypeOf(data)) { + []u8 => [3][]const u8, + []const u8 => Error![3][]const u8, + else => unreachable, +} { + const mutable = @TypeOf(data) == []u8; + + var result: [3][]const u8 = .{ "", data, "" }; + + // Bracketed paste mode (mode 2004) wraps pasted data in + // fenceposts so that the terminal can ignore things like newlines. + if (opts.bracketed) { + result[0] = "\x1b[200~"; + result[2] = "\x1b[201~"; + return result; + } + + // Non-bracketed. We have to replace newline with `\r`. This matches + // the behavior of xterm and other terminals. For `\r\n` this will + // result in `\r\r` which does match xterm. + if (comptime mutable) { + std.mem.replaceScalar(u8, data, '\n', '\r'); + } else if (std.mem.indexOfScalar(u8, data, '\n') != null) { + return Error.MutableRequired; + } + + return result; +} + +pub const Error = error{ + /// Returned if encoding requires a mutable copy of the data. This + /// can only be returned if the input data type is const. + MutableRequired, +}; + +/// Returns true if the data looks safe to paste. Data is considered +/// unsafe if it contains any of the following: +/// +/// - `\n`: Newlines can be used to inject commands. +/// - `\x1b[201~`: This is the end of a bracketed paste. This cane be used +/// to exit a bracketed paste and inject commands. +/// +/// We consider any scenario unsafe regardless of current terminal state. +/// For example, even if bracketed paste mode is not active, we still +/// consider `\x1b[201~` unsafe. The existence of these types of bytes +/// should raise suspicion that the producer of the paste data is +/// acting strangely. +pub fn isSafe(data: []const u8) bool { + return std.mem.indexOf(u8, data, "\n") == null and + std.mem.indexOf(u8, data, "\x1b[201~") == null; +} + +test isSafe { + const testing = std.testing; + try testing.expect(isSafe("hello")); + try testing.expect(!isSafe("hello\n")); + try testing.expect(!isSafe("hello\nworld")); + try testing.expect(!isSafe("he\x1b[201~llo")); +} + +test "encode bracketed" { + const testing = std.testing; + const result = try encode( + @as([]const u8, "hello"), + .{ .bracketed = true }, + ); + try testing.expectEqualStrings("\x1b[200~", result[0]); + try testing.expectEqualStrings("hello", result[1]); + try testing.expectEqualStrings("\x1b[201~", result[2]); +} + +test "encode unbracketed no newlines" { + const testing = std.testing; + const result = try encode( + @as([]const u8, "hello"), + .{ .bracketed = false }, + ); + try testing.expectEqualStrings("", result[0]); + try testing.expectEqualStrings("hello", result[1]); + try testing.expectEqualStrings("", result[2]); +} + +test "encode unbracketed newlines const" { + const testing = std.testing; + try testing.expectError(Error.MutableRequired, encode( + @as([]const u8, "hello\nworld"), + .{ .bracketed = false }, + )); +} + +test "encode unbracketed newlines" { + const testing = std.testing; + const data: []u8 = try testing.allocator.dupe(u8, "hello\nworld"); + defer testing.allocator.free(data); + const result = encode(data, .{ .bracketed = false }); + try testing.expectEqualStrings("", result[0]); + try testing.expectEqualStrings("hello\rworld", result[1]); + try testing.expectEqualStrings("", result[2]); +} + +test "encode unbracketed windows-stye newline" { + const testing = std.testing; + const data: []u8 = try testing.allocator.dupe(u8, "hello\r\nworld"); + defer testing.allocator.free(data); + const result = encode(data, .{ .bracketed = false }); + try testing.expectEqualStrings("", result[0]); + try testing.expectEqualStrings("hello\r\rworld", result[1]); + try testing.expectEqualStrings("", result[2]); +} diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 8c49b4900..4b064dc0d 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -66,6 +66,18 @@ pub const EraseLine = terminal.EraseLine; pub const TabClear = terminal.TabClear; pub const Attribute = terminal.Attribute; +/// Terminal-specific input encoding is also part of libghostty-vt. +pub const input = struct { + // We have to be careful to only import targeted files within + // the input package because the full package brings in too many + // other dependencies. + const paste = @import("input/paste.zig"); + pub const PasteError = paste.Error; + pub const PasteOptions = paste.Options; + pub const isSafePaste = paste.isSafe; + pub const encodePaste = paste.encode; +}; + comptime { // If we're building the C library (vs. the Zig module) then // we want to reference the C API so that it gets exported. @@ -84,6 +96,7 @@ comptime { test { _ = terminal; _ = @import("lib/main.zig"); + @import("std").testing.refAllDecls(input); if (comptime terminal.options.c_abi) { _ = terminal.c_api; } diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 6875ba89d..4498a5def 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -1,7 +1,6 @@ const builtin = @import("builtin"); const charsets = @import("charsets.zig"); -const sanitize = @import("sanitize.zig"); const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); @@ -59,8 +58,6 @@ pub const EraseLine = csi.EraseLine; pub const TabClear = csi.TabClear; pub const Attribute = sgr.Attribute; -pub const isSafePaste = sanitize.isSafePaste; - pub const Options = @import("build_options.zig").Options; pub const options = @import("terminal_options"); diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index 9a74db73c..13b7c1eac 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -43,7 +43,7 @@ pub const ModeState = struct { } /// Get the value of a mode. - pub fn get(self: *ModeState, mode: Mode) bool { + pub fn get(self: *const ModeState, mode: Mode) bool { switch (mode) { inline else => |mode_comptime| { const entry = comptime entryForMode(mode_comptime); diff --git a/src/terminal/sanitize.zig b/src/terminal/sanitize.zig deleted file mode 100644 index f96e8a00e..000000000 --- a/src/terminal/sanitize.zig +++ /dev/null @@ -1,14 +0,0 @@ -const std = @import("std"); - -/// Returns true if the data looks safe to paste. -pub fn isSafePaste(data: []const u8) bool { - return std.mem.indexOf(u8, data, "\n") == null and - std.mem.indexOf(u8, data, "\x1b[201~") == null; -} - -test isSafePaste { - const testing = std.testing; - try testing.expect(isSafePaste("hello")); - try testing.expect(!isSafePaste("hello\n")); - try testing.expect(!isSafePaste("hello\nworld")); -} From cef99250a796c2898a9a197ef263a5697632d613 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 5 Oct 2025 00:16:06 +0000 Subject: [PATCH 007/702] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index e76c5e354..4039c6bdd 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/iterm2_themes-release-20250922-150534-d28055b.tgz", - .hash = "N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz", + .hash = "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index ee374e695..ad9763f62 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv": { + "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/iterm2_themes-release-20250922-150534-d28055b.tgz", - "hash": "sha256-mdhUxAAqKxRRXwED2laabUo9ZZqZa/MZAsO0+Y9L7uQ=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz", + "hash": "sha256-GsEWVt4wMzp6+7N5I+QVuhCVJ70cFrdADwUds59AKnw=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index e9d2fc0bc..9d189cfdc 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv"; + name = "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/iterm2_themes-release-20250922-150534-d28055b.tgz"; - hash = "sha256-mdhUxAAqKxRRXwED2laabUo9ZZqZa/MZAsO0+Y9L7uQ="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz"; + hash = "sha256-GsEWVt4wMzp6+7N5I+QVuhCVJ70cFrdADwUds59AKnw="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 8ebecee9b..c3727f1e5 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -13,7 +13,6 @@ https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz -https://deps.files.ghostty.org/iterm2_themes-release-20250922-150534-d28055b.tgz https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz @@ -35,4 +34,5 @@ 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/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz https://github.com/rockorager/libvaxis/archive/1bf887aa7e3736bad69fd4e277a378946edb0f2a.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 58ef3c97a..1eba46fc2 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/iterm2_themes-release-20250922-150534-d28055b.tgz", - "dest": "vendor/p/N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv", - "sha256": "99d854c4002a2b14515f0103da569a6d4a3d659a996bf31902c3b4f98f4beee4" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y", + "sha256": "1ac11656de30333a7afbb37923e415ba109527bd1c16b7400f051db39f402a7c" }, { "type": "archive", From 44496df8994640975720938fb150a67e7d111663 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Oct 2025 15:04:52 -0700 Subject: [PATCH 008/702] input: use std.Io.Writer for key encoder, new API, expose via libghostty This modernizes `KeyEncoder` to a new `std.Io.Writer`-based API. Additionally, instead of a single struct, it is now an `encode` function that takes a series of more focused options. This is more idiomatic Zig while also making it easier to expose via libghostty-vt. libghostty-vt also gains access to key encoding APIs. --- src/Surface.zig | 99 +- src/config.zig | 1 - src/config/Config.zig | 10 +- src/input.zig | 4 +- src/input/config.zig | 8 + src/input/function_keys.zig | 5 + src/input/key.zig | 4 +- src/input/{KeyEncoder.zig => key_encode.zig} | 1611 +++++++++--------- src/input/keyboard.zig | 2 +- src/lib_vt.zig | 12 + src/terminal/kitty/key.zig | 15 +- 11 files changed, 880 insertions(+), 891 deletions(-) create mode 100644 src/input/config.zig rename src/input/{KeyEncoder.zig => key_encode.zig} (63%) diff --git a/src/Surface.zig b/src/Surface.zig index 5aabb2b80..b1553edff 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -271,7 +271,7 @@ const DerivedConfig = struct { mouse_scroll_multiplier: configpkg.MouseScrollMultiplier, mouse_shift_capture: configpkg.MouseShiftCapture, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, - macos_option_as_alt: ?configpkg.OptionAsAlt, + macos_option_as_alt: ?input.OptionAsAlt, selection_clear_on_copy: bool, selection_clear_on_typing: bool, vt_kam_allowed: bool, @@ -1130,7 +1130,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { // so that we can close the terminal. We close the terminal on // any key press that encodes a character. t.modes.set(.disable_keyboard, false); - t.screen.kitty_keyboard.set(.set, .{}); + t.screen.kitty_keyboard.set(.set, .disabled); } // Waiting after command we stop here. The terminal is updated, our @@ -2611,56 +2611,32 @@ fn encodeKey( event: input.KeyEvent, insp_ev: ?*inspectorpkg.key.Event, ) !?termio.Message.WriteReq { - // Build up our encoder. Under different modes and - // inputs there are many keybindings that result in no encoding - // whatsoever. - const enc: input.KeyEncoder = enc: { - const option_as_alt: configpkg.OptionAsAlt = self.config.macos_option_as_alt orelse detect: { - // Non-macOS doesn't use this value so ignore. - if (comptime builtin.os.tag != .macos) break :detect .false; - - // If we don't have alt pressed, it doesn't matter what this - // config is so we can just say "false" and break out and avoid - // more expensive checks below. - if (!event.mods.alt) break :detect .false; - - // Alt is pressed, we're on macOS. We break some encapsulation - // here and assume libghostty for ease... - break :detect self.rt_app.keyboardLayout().detectOptionAsAlt(); - }; - - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const t = &self.io.terminal; - break :enc .{ - .event = event, - .macos_option_as_alt = option_as_alt, - .alt_esc_prefix = t.modes.get(.alt_esc_prefix), - .cursor_key_application = t.modes.get(.cursor_keys), - .keypad_key_application = t.modes.get(.keypad_keys), - .ignore_keypad_with_numlock = t.modes.get(.ignore_keypad_with_numlock), - .modify_other_keys_state_2 = t.flags.modify_other_keys_2, - .kitty_flags = t.screen.kitty_keyboard.current(), - }; - }; - const write_req: termio.Message.WriteReq = req: { + // Build our encoding options, which requires the lock. + const encoding_opts = self.encodeKeyOpts(); + // Try to write the input into a small array. This fits almost // every scenario. Larger situations can happen due to long // pre-edits. var data: termio.Message.WriteReq.Small.Array = undefined; - if (enc.encode(&data)) |seq| { + var writer: std.Io.Writer = .fixed(&data); + if (input.key_encode.encode( + &writer, + event, + encoding_opts, + )) { + const written = writer.buffered(); + // Special-case: we did nothing. - if (seq.len == 0) return null; + if (written.len == 0) return null; break :req .{ .small = .{ .data = data, - .len = @intCast(seq.len), + .len = @intCast(written.len), } }; } else |err| switch (err) { // Means we need to allocate - error.OutOfMemory => {}, - else => return err, + error.WriteFailed => {}, } // We need to allocate. We allocate double the UTF-8 length @@ -2669,16 +2645,23 @@ fn encodeKey( // typing this where we don't have enough space is a long preedit, // and in that case the size we need is exactly the UTF-8 length, // so the double is being safe. - const buf = try self.alloc.alloc(u8, @max( - event.utf8.len * 2, - data.len * 2, - )); - defer self.alloc.free(buf); + var alloc_writer: std.Io.Writer.Allocating = try .initCapacity( + self.alloc, + @max(event.utf8.len * 2, data.len * 2), + ); + defer alloc_writer.deinit(); // This results in a double allocation but this is such an unlikely // path the performance impact is unimportant. - const seq = try enc.encode(buf); - break :req try termio.Message.WriteReq.init(self.alloc, seq); + try input.key_encode.encode( + &alloc_writer.writer, + event, + encoding_opts, + ); + break :req try termio.Message.WriteReq.init( + self.alloc, + alloc_writer.writer.buffered(), + ); }; // Copy the encoded data into the inspector event if we have one. @@ -2698,6 +2681,28 @@ fn encodeKey( return write_req; } +fn encodeKeyOpts(self: *const Surface) input.key_encode.Options { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t = &self.io.terminal; + + var opts: input.key_encode.Options = .fromTerminal(t); + if (comptime builtin.os.tag != .macos) return opts; + + opts.macos_option_as_alt = self.config.macos_option_as_alt orelse detect: { + // If we don't have alt pressed, it doesn't matter what this + // config is so we can just say "false" and break out and avoid + // more expensive checks below. + if (!self.mouse.mods.alt) break :detect .false; + + // Alt is pressed, we're on macOS. We break some encapsulation + // here and assume libghostty for ease... + break :detect self.rt_app.keyboardLayout().detectOptionAsAlt(); + }; + + return opts; +} + /// Sends text as-is to the terminal without triggering any keyboard /// protocol. This will treat the input text as if it was pasted /// from the clipboard so the same logic will be applied. Namely, diff --git a/src/config.zig b/src/config.zig index 569d4bec2..a596eb5e6 100644 --- a/src/config.zig +++ b/src/config.zig @@ -29,7 +29,6 @@ pub const Keybinds = Config.Keybinds; pub const MouseShiftCapture = Config.MouseShiftCapture; pub const MouseScrollMultiplier = Config.MouseScrollMultiplier; pub const NonNativeFullscreen = Config.NonNativeFullscreen; -pub const OptionAsAlt = Config.OptionAsAlt; pub const RepeatableCodepointMap = Config.RepeatableCodepointMap; pub const RepeatableFontVariation = Config.RepeatableFontVariation; pub const RepeatableString = Config.RepeatableString; diff --git a/src/config/Config.zig b/src/config/Config.zig index bd2fe1cfb..a203a32a1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2861,7 +2861,7 @@ keybind: Keybinds = .{}, /// /// The values `left` or `right` enable this for the left or right *Option* /// key, respectively. -@"macos-option-as-alt": ?OptionAsAlt = null, +@"macos-option-as-alt": ?inputpkg.OptionAsAlt = null, /// Whether to enable the macOS window shadow. The default value is true. /// With some window managers and window transparency settings, you may @@ -4821,14 +4821,6 @@ pub const NonNativeFullscreen = enum(c_int) { @"padded-notch", }; -/// Valid values for macos-option-as-alt. -pub const OptionAsAlt = enum { - false, - true, - left, - right, -}; - pub const WindowPaddingColor = enum { background, extend, diff --git a/src/input.zig b/src/input.zig index 06d7dc96a..be84a60d6 100644 --- a/src/input.zig +++ b/src/input.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); +const config = @import("input/config.zig"); const mouse = @import("input/mouse.zig"); const key = @import("input/key.zig"); const keyboard = @import("input/keyboard.zig"); @@ -8,6 +9,7 @@ const keyboard = @import("input/keyboard.zig"); pub const command = @import("input/command.zig"); pub const function_keys = @import("input/function_keys.zig"); pub const keycodes = @import("input/keycodes.zig"); +pub const key_encode = @import("input/key_encode.zig"); pub const kitty = @import("input/kitty.zig"); pub const paste = @import("input/paste.zig"); @@ -18,13 +20,13 @@ pub const Command = command.Command; pub const Link = @import("input/Link.zig"); pub const Key = key.Key; pub const KeyboardLayout = keyboard.Layout; -pub const KeyEncoder = @import("input/KeyEncoder.zig"); pub const KeyEvent = key.KeyEvent; pub const InspectorMode = Binding.Action.InspectorMode; pub const Mods = key.Mods; pub const MouseButton = mouse.Button; pub const MouseButtonState = mouse.ButtonState; pub const MousePressureStage = mouse.PressureStage; +pub const OptionAsAlt = config.OptionAsAlt; pub const ScrollMods = mouse.ScrollMods; pub const SplitFocusDirection = Binding.Action.SplitFocusDirection; pub const SplitResizeDirection = Binding.Action.SplitResizeDirection; diff --git a/src/input/config.zig b/src/input/config.zig new file mode 100644 index 000000000..fd839a20e --- /dev/null +++ b/src/input/config.zig @@ -0,0 +1,8 @@ +/// Determines the macOS option key behavior. See the config +/// `macos-option-as-alt` for a lot more details. +pub const OptionAsAlt = enum(c_int) { + false, + true, + left, + right, +}; diff --git a/src/input/function_keys.zig b/src/input/function_keys.zig index 8c89b39bd..efe86d9e3 100644 --- a/src/input/function_keys.zig +++ b/src/input/function_keys.zig @@ -293,6 +293,11 @@ fn pcStyle(comptime fmt: []const u8) []Entry { test "keys" { const testing = std.testing; + switch (@import("terminal_options").artifact) { + .ghostty => {}, + // Don't want to bring in termio into libghostty-vt + .lib => return error.SkipZigTest, + } // Force resolution for comptime evaluation. _ = keys; diff --git a/src/input/key.zig b/src/input/key.zig index a3814fb55..54c7491ae 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -2,7 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const cimgui = @import("cimgui"); -const config = @import("../config.zig"); +const OptionAsAlt = @import("config.zig").OptionAsAlt; /// A generic key input event. This is the information that is necessary /// regardless of apprt in order to generate the proper terminal @@ -146,7 +146,7 @@ pub const Mods = packed struct(Mods.Backing) { /// Return the mods to use for key translation. This handles settings /// like macos-option-as-alt. The translation mods should be used for /// translation but never sent back in for the key callback. - pub fn translation(self: Mods, option_as_alt: config.OptionAsAlt) Mods { + pub fn translation(self: Mods, option_as_alt: OptionAsAlt) Mods { var result = self; // macos-option-as-alt for darwin diff --git a/src/input/KeyEncoder.zig b/src/input/key_encode.zig similarity index 63% rename from src/input/KeyEncoder.zig rename to src/input/key_encode.zig index b5f18b5a2..c35cdebaa 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/key_encode.zig @@ -1,86 +1,128 @@ -/// KeyEncoder is responsible for processing keyboard input and generating -/// the proper VT sequence for any events. -/// -/// A new KeyEncoder should be created for each individual key press. -/// These encoders are not meant to be reused. -const KeyEncoder = @This(); - const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; - -const key = @import("key.zig"); -const config = @import("../config.zig"); +const KittyFlags = @import("../terminal/kitty/key.zig").Flags; +const OptionAsAlt = @import("config.zig").OptionAsAlt; +const Terminal = @import("../terminal/Terminal.zig"); const function_keys = @import("function_keys.zig"); -const terminal = @import("../terminal/main.zig"); +const key = @import("key.zig"); const KittyEntry = @import("kitty.zig").Entry; const kitty_entries = @import("kitty.zig").entries; -const KittyFlags = terminal.kitty.KeyFlags; -const log = std.log.scoped(.key_encoder); +/// Options that affect key encoding behavior. This is a mix of behavior +/// from terminal state as well as application configuration. +pub const Options = struct { + /// Terminal DEC mode 1 + cursor_key_application: bool = false, -event: key.KeyEvent, + /// Terminal DEC mode 66 + keypad_key_application: bool = false, -/// The state of various modes of a terminal that impact encoding. -macos_option_as_alt: config.OptionAsAlt = .false, -alt_esc_prefix: bool = false, -cursor_key_application: bool = false, -keypad_key_application: bool = false, -ignore_keypad_with_numlock: bool = false, -modify_other_keys_state_2: bool = false, -kitty_flags: KittyFlags = .{}, + /// Terminal DEC mode 1035 + ignore_keypad_with_numlock: bool = false, -/// Perform the proper encoding depending on the terminal state. + /// Terminal DEC mode 1036 + alt_esc_prefix: bool = false, + + /// xterm "modifyOtherKeys mode 2". Details here: + /// https://invisible-island.net/xterm/modified-keys.html + modify_other_keys_state_2: bool = false, + + /// Kitty keyboard protocol flags. + kitty_flags: KittyFlags = .disabled, + + /// Determines whether the "option" key on macOS is treated + /// as "alt" or not. See the Ghostty `macos_option-as-alt` config + /// docs for a more detailed description of why this is needed. + macos_option_as_alt: OptionAsAlt = .false, + + /// Initialize our options from the terminal state. + /// + /// Note that `macos_option_as_alt` cannot be determined from + /// terminal state so it must be set manually after this call. + pub fn fromTerminal(t: *const Terminal) Options { + return .{ + .alt_esc_prefix = t.modes.get(.alt_esc_prefix), + .cursor_key_application = t.modes.get(.cursor_keys), + .keypad_key_application = t.modes.get(.keypad_keys), + .ignore_keypad_with_numlock = t.modes.get(.ignore_keypad_with_numlock), + .modify_other_keys_state_2 = t.flags.modify_other_keys_2, + .kitty_flags = t.screen.kitty_keyboard.current(), + + // These can't be known from the terminal state. + .macos_option_as_alt = .false, + }; + } +}; + +/// Encode the key event to the writer in the proper format given +/// the options. For example, this will properly encode a key press +/// such as "ctrl+A" to Kitty format if Kitty encoding is enabled. +/// +/// Not all key events will result in output. It is up to the caller +/// to use a writer that can track whether any output was written if +/// they care about that. pub fn encode( - self: *const KeyEncoder, - buf: []u8, -) ![]const u8 { + writer: *std.Io.Writer, + event: key.KeyEvent, + opts: Options, +) std.Io.Writer.Error!void { // log.warn("KEYENCODER self={}", .{self.*}); - if (self.kitty_flags.int() != 0) return try self.kitty(buf); - return try self.legacy(buf); + return if (opts.kitty_flags.int() != 0) try kitty( + writer, + event, + opts, + ) else try legacy( + writer, + event, + opts, + ); } /// Perform Kitty keyboard protocol encoding of the key event. fn kitty( - self: *const KeyEncoder, - buf: []u8, -) ![]const u8 { + writer: *std.Io.Writer, + event: key.KeyEvent, + opts: Options, +) std.Io.Writer.Error!void { // This should never happen but we'll check anyway. - if (self.kitty_flags.int() == 0) return try self.legacy(buf); + if (opts.kitty_flags.int() == 0) return try legacy( + writer, + event, + opts, + ); // We only processed "press" events unless report events is active - if (self.event.action == .release) { - if (!self.kitty_flags.report_events) { - return ""; - } + if (event.action == .release) { + if (!opts.kitty_flags.report_events) return; // Enter, backspace, and tab do not report release events unless "report // all" is set - if (!self.kitty_flags.report_all) { - switch (self.event.key) { - .enter, .backspace, .tab => return "", + if (!opts.kitty_flags.report_all) { + switch (event.key) { + .enter, .backspace, .tab => return, else => {}, } } } - const all_mods = self.event.mods; - const effective_mods = self.event.effectiveMods(); + const all_mods = event.mods; + const effective_mods = event.effectiveMods(); const binding_mods = effective_mods.binding(); // Find the entry for this key in the kitty table. const entry_: ?KittyEntry = entry: { // Functional or predefined keys for (kitty_entries) |entry| { - if (entry.key == self.event.key) break :entry entry; + if (entry.key == event.key) break :entry entry; } // Otherwise, we use our unicode codepoint from UTF8. We // always use the unshifted value. - if (self.event.unshifted_codepoint > 0) { + if (event.unshifted_codepoint > 0) { break :entry .{ - .key = self.event.key, - .code = self.event.unshifted_codepoint, + .key = event.key, + .code = event.unshifted_codepoint, .final = 'u', .modifier = false, }; @@ -91,32 +133,32 @@ fn kitty( preprocessing: { // When composing, the only keys sent are plain modifiers. - if (self.event.composing) { + if (event.composing) { if (entry_) |entry| { if (entry.modifier) break :preprocessing; } - return ""; + return; } // IME confirmation still sends an enter key so if we have enter // and UTF8 text we just send it directly since we assume that is // whats happening. See legacy()'s similar logic for more details // on how to verify this. - if (self.event.utf8.len > 0) utf8: { - switch (self.event.key) { + if (event.utf8.len > 0) utf8: { + switch (event.key) { else => {}, inline .enter, .backspace => |tag| { // See legacy for why we handle this this way. - if (isControlUtf8(self.event.utf8)) break :utf8; - if (comptime tag == .backspace) return ""; - return try copyToBuf(buf, self.event.utf8); + if (isControlUtf8(event.utf8)) break :utf8; + if (comptime tag == .backspace) return; + return try writer.writeAll(event.utf8); }, } } // If we're reporting all then we always send CSI sequences. - if (!self.kitty_flags.report_all) { + if (!opts.kitty_flags.report_all) { // Quote: // The only exceptions are the Enter, Tab and Backspace keys which // still generate the same bytes as in legacy mode this is to allow the @@ -127,63 +169,73 @@ fn kitty( // Note that all keys are reported as escape codes, including Enter, // Tab, Backspace etc. if (effective_mods.empty()) { - switch (self.event.key) { - .enter => return try copyToBuf(buf, "\r"), - .tab => return try copyToBuf(buf, "\t"), - .backspace => return try copyToBuf(buf, "\x7F"), + switch (event.key) { + .enter => return try writer.writeByte('\r'), + .tab => return try writer.writeByte('\t'), + .backspace => return try writer.writeByte(0x7F), else => {}, } } // Send plain-text non-modified text directly to the terminal. // We don't send release events because those are specially encoded. - if (self.event.utf8.len > 0 and + if (event.utf8.len > 0 and binding_mods.empty() and - self.event.action != .release) + event.action != .release) plain_text: { // We only do this for printable characters. We should // inspect the real unicode codepoint properties here but // the real world issue is usually control characters. - const view = try std.unicode.Utf8View.init(self.event.utf8); + const view = std.unicode.Utf8View.init(event.utf8) catch { + // Invalid UTF-8 so let's fallback to encoding the + // key press as if it didn't produce UTF-8 text. I'm + // not sure what should happen here according to the spec, + // since it doesn't specify this behavior. Presumably + // this is a caller bug. + break :plain_text; + }; var it = view.iterator(); while (it.nextCodepoint()) |cp| { if (isControl(cp)) break :plain_text; } - return try copyToBuf(buf, self.event.utf8); + return try writer.writeAll(event.utf8); } } } - const entry = entry_ orelse return ""; + const entry = entry_ orelse return; // If this is just a modifier we require "report all" to send the sequence. - if (entry.modifier and !self.kitty_flags.report_all) return ""; + if (entry.modifier and !opts.kitty_flags.report_all) return; const seq: KittySequence = seq: { var seq: KittySequence = .{ .key = entry.code, .final = entry.final, .mods = .fromInput( - self.event.action, - self.event.key, + event.action, + event.key, all_mods, ), }; - if (self.kitty_flags.report_events) { - seq.event = switch (self.event.action) { + if (opts.kitty_flags.report_events) { + seq.event = switch (event.action) { .press => .press, .release => .release, .repeat => .repeat, }; } - if (self.kitty_flags.report_alternates) alternates: { + if (opts.kitty_flags.report_alternates) alternates: { // Break early if this is a control key if (isControl(seq.key)) break :alternates; - const view = try std.unicode.Utf8View.init(self.event.utf8); + const view = std.unicode.Utf8View.init(event.utf8) catch { + // Assume invalid UTF-8 means no UTF-8. + break :alternates; + }; var it = view.iterator(); // If we have a codepoint in our UTF-8 sequence, then we can @@ -198,7 +250,7 @@ fn kitty( // Set the base layout key. We only report this if this codepoint // differs from our pressed key. - if (self.event.key.codepoint()) |base| { + if (event.key.codepoint()) |base| { if (base != seq.key and (cp1 != base and !has_cp2)) { @@ -208,20 +260,20 @@ fn kitty( } else { // No UTF-8 so we can't report a shifted key but we can still // report a base layout key. - if (self.event.key.codepoint()) |base| { + if (event.key.codepoint()) |base| { if (base != seq.key) seq.alternates[1] = base; } } } - if (self.kitty_flags.report_associated and + if (opts.kitty_flags.report_associated and seq.event != .release) associated: { // Determine if the Alt modifier should be treated as an actual // modifier (in which case it prevents associated text) or as // the macOS Option key, which does not prevent associated text. const alt_prevents_text = if (comptime builtin.os.tag == .macos) - switch (self.macos_option_as_alt) { + switch (opts.macos_option_as_alt) { .left => all_mods.sides.alt == .left, .right => all_mods.sides.alt == .right, .true => true, @@ -232,13 +284,13 @@ fn kitty( if (seq.mods.preventsText(alt_prevents_text)) break :associated; - seq.text = self.event.utf8; + seq.text = event.utf8; } break :seq seq; }; - return try seq.encode(buf); + return try seq.encode(writer); } /// Perform legacy encoding of the key event. "Legacy" in this case @@ -248,28 +300,28 @@ fn kitty( /// meant to be extensions that do not change any existing behavior /// and therefore safe to combine. fn legacy( - self: *const KeyEncoder, - buf: []u8, -) ![]const u8 { - const all_mods = self.event.mods; - const effective_mods = self.event.effectiveMods(); + writer: *std.Io.Writer, + event: key.KeyEvent, + opts: Options, +) std.Io.Writer.Error!void { + const all_mods = event.mods; + const effective_mods = event.effectiveMods(); const binding_mods = effective_mods.binding(); // Legacy encoding only does press/repeat - if (self.event.action != .press and - self.event.action != .repeat) return ""; + if (event.action != .press and event.action != .repeat) return; // If we're in a dead key state then we never emit a sequence. - if (self.event.composing) return ""; + if (event.composing) return; // If we match a PC style function key then that is our result. if (pcStyleFunctionKey( - self.event.key, + event.key, all_mods, - self.cursor_key_application, - self.keypad_key_application, - self.ignore_keypad_with_numlock, - self.modify_other_keys_state_2, + opts.cursor_key_application, + opts.keypad_key_application, + opts.ignore_keypad_with_numlock, + opts.modify_other_keys_state_2, )) |sequence| pc_style: { // If we have UTF-8 text, then we never emit PC style function // keys. Many function keys (escape, enter, backspace) have @@ -280,65 +332,68 @@ fn legacy( // - Korean: escape commits the dead key state // - Korean: backspace should delete a single preedit char // - if (self.event.utf8.len > 0) utf8: { - switch (self.event.key) { + if (event.utf8.len > 0) utf8: { + switch (event.key) { else => {}, inline .backspace, .enter, .escape => |tag| { // We want to ignore control characters. This is because // some apprts (macOS) will send control characters as // UTF-8 encodings and we handle that manually. - if (isControlUtf8(self.event.utf8)) break :utf8; + if (isControlUtf8(event.utf8)) break :utf8; // Backspace encodes nothing because we modified IME. // Enter/escape don't encode the PC-style encoding // because we want to encode committed text. - if (comptime tag == .backspace) return ""; + if (comptime tag == .backspace) return; break :pc_style; }, } } - return copyToBuf(buf, sequence); + return try writer.writeAll(sequence); } // If we match a control sequence, we output that directly. For // ctrlSeq we have to use all mods because we want it to only // match ctrl+. if (ctrlSeq( - self.event.key, - self.event.utf8, - self.event.unshifted_codepoint, + event.key, + event.utf8, + event.unshifted_codepoint, all_mods, )) |char| { // C0 sequences support alt-as-esc prefixing. if (binding_mods.alt) { - if (buf.len < 2) return error.OutOfMemory; - buf[0] = 0x1B; - buf[1] = char; - return buf[0..2]; + try writer.writeByte(0x1B); + try writer.writeByte(char); + return; } - if (buf.len < 1) return error.OutOfMemory; - buf[0] = char; - return buf[0..1]; + try writer.writeByte(char); + return; } // If we have no UTF8 text then the only possibility is the // alt-prefix handling of unshifted codepoints... so we process that. - const utf8 = self.event.utf8; + const utf8 = event.utf8; if (utf8.len == 0) { - if (try self.legacyAltPrefix(binding_mods, all_mods)) |byte| { - return try std.fmt.bufPrint(buf, "\x1B{c}", .{byte}); - } - - return ""; + if (try legacyAltPrefix( + event, + binding_mods, + all_mods, + opts, + )) |byte| try writer.print("\x1B{c}", .{byte}); + return; } // In modify other keys state 2, we send the CSI 27 sequence // for any char with a modifier. Ctrl sequences like Ctrl+a // are already handled above. - if (self.modify_other_keys_state_2) modify_other: { - const view = try std.unicode.Utf8View.init(utf8); + if (opts.modify_other_keys_state_2) modify_other: { + const view = std.unicode.Utf8View.init(utf8) catch { + // Assume invalid UTF-8 means we no UTF-8. + break :modify_other; + }; var it = view.iterator(); const codepoint = it.nextCodepoint() orelse break :modify_other; @@ -371,8 +426,7 @@ fn legacy( if (should_modify) { for (function_keys.modifiers, 2..) |modset, code| { if (!binding_mods.equal(modset)) continue; - return try std.fmt.bufPrint( - buf, + return try writer.print( "\x1B[27;{};{}~", .{ code, codepoint }, ); @@ -383,17 +437,17 @@ fn legacy( // Let's see if we should apply fixterms to this codepoint. // At this stage of key processing, we only need to apply fixterms // to unicode codepoints if we have ctrl set. - if (self.event.mods.ctrl) csiu: { + if (event.mods.ctrl) csiu: { // Important: we want to use the original mods here, not the // effective mods. The fixterms spec states the shifted chars // should be sent uppercase but Kitty changes that behavior // so we'll send all the mods. const csi_u_mods, const char = mods: { - var mods = CsiUMods.fromInput(self.event.mods); + var mods = CsiUMods.fromInput(event.mods); // Get our codepoint. If we have more than one codepoint this // can't be valid CSIu. - const view = std.unicode.Utf8View.init(self.event.utf8) catch break :csiu; + const view = std.unicode.Utf8View.init(event.utf8) catch break :csiu; var it = view.iterator(); var char = it.nextCodepoint() orelse break :csiu; if (it.nextCodepoint() != null) break :csiu; @@ -414,25 +468,27 @@ fn legacy( // then we consider shift. Otherwise, we do not because the // shift key was used to obtain the character. This is specified // by fixterms. - if (self.event.unshifted_codepoint != char) { + if (event.unshifted_codepoint != char) { mods.shift = false; } break :mods .{ mods, char }; }; - const result = try std.fmt.bufPrint( - buf, + return try writer.print( "\x1B[{};{}u", .{ char, csi_u_mods.seqInt() }, ); - // std.log.warn("CSI_U: {s}", .{result}); - return result; } // If we have alt-pressed and alt-esc-prefix is enabled, then // we need to prefix the utf8 sequence with an esc. - if (try self.legacyAltPrefix(binding_mods, all_mods)) |byte| { - return try std.fmt.bufPrint(buf, "\x1B{c}", .{byte}); + if (try legacyAltPrefix( + event, + binding_mods, + all_mods, + opts, + )) |byte| { + return try writer.print("\x1B{c}", .{byte}); } // If we are on macOS, command+keys do not encode text. It isn't @@ -445,25 +501,26 @@ fn legacy( // For example on Gnome Console Super+b will encode a "b" character // with legacy encoding. if ((comptime builtin.os.tag == .macos) and all_mods.super) { - return ""; + return; } - return try copyToBuf(buf, utf8); + return try writer.writeAll(utf8); } fn legacyAltPrefix( - self: *const KeyEncoder, + event: key.KeyEvent, binding_mods: key.Mods, mods: key.Mods, + opts: Options, ) !?u8 { // This only takes effect with alt pressed - if (!binding_mods.alt or !self.alt_esc_prefix) return null; + if (!binding_mods.alt or !opts.alt_esc_prefix) return null; // On macOS, we only handle option like alt in certain // circumstances. Otherwise, macOS does a unicode translation // and we allow that to happen. if (comptime builtin.os.tag == .macos) { - switch (self.macos_option_as_alt) { + switch (opts.macos_option_as_alt) { .false => return null, .left => if (mods.sides.alt == .right) return null, .right => if (mods.sides.alt == .left) return null, @@ -472,7 +529,7 @@ fn legacyAltPrefix( } // Otherwise, we require utf8 to already have the byte represented. - const utf8 = self.event.utf8; + const utf8 = event.utf8; if (utf8.len == 1) { if (std.math.cast(u8, utf8[0])) |byte| { return byte; @@ -480,10 +537,10 @@ fn legacyAltPrefix( } // If UTF8 isn't set, we will allow unshifted codepoints through. - if (self.event.unshifted_codepoint > 0) { + if (event.unshifted_codepoint > 0) { if (std.math.cast( u8, - self.event.unshifted_codepoint, + event.unshifted_codepoint, )) |byte| { return byte; } @@ -897,19 +954,18 @@ const KittySequence = struct { release = 3, }; - pub fn encode(self: KittySequence, buf: []u8) ![]const u8 { - if (self.final == 'u' or self.final == '~') return try self.encodeFull(buf); - return try self.encodeSpecial(buf); + pub fn encode( + self: KittySequence, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + if (self.final == 'u' or self.final == '~') return try self.encodeFull(writer); + return try self.encodeSpecial(writer); } - fn encodeFull(self: KittySequence, buf: []u8) ![]const u8 { - // Boilerplate to basically create a string builder that writes - // over our buffer (but no more). - var fba = std.heap.FixedBufferAllocator.init(buf); - const alloc = fba.allocator(); - var builder = try std.ArrayListUnmanaged(u8).initCapacity(alloc, buf.len); - const writer = builder.writer(alloc); - + fn encodeFull( + self: KittySequence, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { // Key section try writer.print("\x1B[{d}", .{self.key}); // Write our alternates @@ -937,8 +993,11 @@ const KittySequence = struct { } // Text section - if (self.text.len > 0) { - const view = try std.unicode.Utf8View.init(self.text); + if (self.text.len > 0) text: { + const view = std.unicode.Utf8View.init(self.text) catch { + // Assume invalid UTF-8 means we have no text. + break :text; + }; var it = view.iterator(); var count: usize = 0; while (it.nextCodepoint()) |cp| { @@ -960,13 +1019,15 @@ const KittySequence = struct { } try writer.print("{c}", .{self.final}); - return builder.items; } - fn encodeSpecial(self: KittySequence, buf: []u8) ![]const u8 { + fn encodeSpecial( + self: KittySequence, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { const mods = self.mods.seqInt(); if (self.event != .none) { - return try std.fmt.bufPrint(buf, "\x1B[1;{d}:{d}{c}", .{ + return try writer.print("\x1B[1;{d}:{d}{c}", .{ mods, @intFromEnum(self.event), self.final, @@ -974,13 +1035,13 @@ const KittySequence = struct { } if (mods > 1) { - return try std.fmt.bufPrint(buf, "\x1B[1;{d}{c}", .{ + return try writer.print("\x1B[1;{d}{c}", .{ mods, self.final, }); } - return try std.fmt.bufPrint(buf, "\x1B[{c}", .{self.final}); + return try writer.print("\x1B[{c}", .{self.final}); } }; @@ -989,27 +1050,30 @@ test "KittySequence: backspace" { // Plain { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u' }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127u", writer.buffered()); } // Release event { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .event = .release }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127;1:3u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;1:3u", writer.buffered()); } // Shift { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .mods = .{ .shift = true }, }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127;2u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;2u", writer.buffered()); } } @@ -1018,221 +1082,214 @@ test "KittySequence: text" { // Plain { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .text = "A", }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127;;65u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;;65u", writer.buffered()); } // Release { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .event = .release, .text = "A", }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127;1:3;65u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;1:3;65u", writer.buffered()); } // Shift { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .mods = .{ .shift = true }, .text = "A", }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[127;2;65u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[127;2;65u", writer.buffered()); } } - +// test "KittySequence: text with control characters" { var buf: [128]u8 = undefined; // By itself { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .text = "\n", }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1b[127u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1b[127u", writer.buffered()); } // With other printables { + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 127, .final = 'u', .text = "A\n", }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1b[127;;65u", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1b[127;;65u", writer.buffered()); } } - +// test "KittySequence: special no mods" { var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 1, .final = 'A' }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[A", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[A", writer.buffered()); } test "KittySequence: special mods only" { var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 1, .final = 'A', .mods = .{ .shift = true } }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[1;2A", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[1;2A", writer.buffered()); } test "KittySequence: special mods and event" { var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); var seq: KittySequence = .{ .key = 1, .final = 'A', .event = .release, .mods = .{ .shift = true }, }; - const actual = try seq.encode(&buf); - try testing.expectEqualStrings("\x1B[1;2:3A", actual); + try seq.encode(&writer); + try testing.expectEqualStrings("\x1B[1;2:3A", writer.buffered()); } test "kitty: plain text" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_a, - .mods = .{}, - .utf8 = "abcd", - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{}, + .utf8 = "abcd", + }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("abcd", actual); + }); + try testing.expectEqualStrings("abcd", writer.buffered()); } test "kitty: repeat with just disambiguate" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_a, - .action = .repeat, - .mods = .{}, - .utf8 = "a", - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .action = .repeat, + .mods = .{}, + .utf8 = "a", + }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("a", actual); + }); + try testing.expectEqualStrings("a", writer.buffered()); } - +// test "kitty: enter, backspace, tab" { var buf: [128]u8 = undefined; { - var enc: KeyEncoder = .{ - .event = .{ .key = .enter, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .enter, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\r", actual); + }); + try testing.expectEqualStrings("\r", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .key = .backspace, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .backspace, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x7f", actual); + }); + try testing.expectEqualStrings("\x7f", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .key = .tab, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .tab, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\t", actual); + }); + try testing.expectEqualStrings("\t", writer.buffered()); } // No release events if "report_all" is not set { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .enter, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .enter, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .backspace, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .backspace, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .tab, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .tab, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } // Release events if "report_all" is set { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .enter, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .enter, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, .report_all = true, }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[13;1:3u", actual); + }); + try testing.expectEqualStrings("\x1b[13;1:3u", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .backspace, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .backspace, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, .report_all = true, }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[127;1:3u", actual); + }); + try testing.expectEqualStrings("\x1b[127;1:3u", writer.buffered()); } { - var enc: KeyEncoder = .{ - .event = .{ .action = .release, .key = .tab, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .action = .release, .key = .tab, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, .report_all = true, }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[9;1:3u", actual); + }); + try testing.expectEqualStrings("\x1b[9;1:3u", writer.buffered()); } } - +// test "kitty: enter with all flags" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ .key = .enter, .mods = .{}, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .enter, .mods = .{}, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1240,15 +1297,15 @@ test "kitty: enter with all flags" { .report_all = true, .report_associated = true, }, - }; - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[13u", actual[1..]); } - +// test "kitty: ctrl with all flags" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ .key = .control_left, .mods = .{ .ctrl = true }, .utf8 = "" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .control_left, .mods = .{ .ctrl = true }, .utf8 = "" }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1256,20 +1313,20 @@ test "kitty: ctrl with all flags" { .report_all = true, .report_associated = true, }, - }; - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[57442;5u", actual[1..]); } test "kitty: ctrl release with ctrl mod set" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .action = .release, - .key = .control_left, - .mods = .{ .ctrl = true }, - .utf8 = "", - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .control_left, + .mods = .{ .ctrl = true }, + .utf8 = "", + }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1277,210 +1334,191 @@ test "kitty: ctrl release with ctrl mod set" { .report_all = true, .report_associated = true, }, - }; - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[57442;5:3u", actual[1..]); } test "kitty: delete" { var buf: [128]u8 = undefined; { - var enc: KeyEncoder = .{ - .event = .{ .key = .delete, .mods = .{}, .utf8 = "\x7F" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ .key = .delete, .mods = .{}, .utf8 = "\x7F" }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[3~", actual); + }); + try testing.expectEqualStrings("\x1b[3~", writer.buffered()); } } test "kitty: composing with no modifier" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_a, - .mods = .{ .shift = true }, - .composing = true, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{ .shift = true }, + .composing = true, + }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } test "kitty: composing with modifier" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .shift_left, - .mods = .{ .shift = true }, - .composing = true, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .shift_left, + .mods = .{ .shift = true }, + .composing = true, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[57441;2u", actual); + }); + try testing.expectEqualStrings("\x1b[57441;2u", writer.buffered()); } test "kitty: shift+a on US keyboard" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_a, - .mods = .{ .shift = true }, - .utf8 = "A", - .unshifted_codepoint = 97, // lowercase A - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{ .shift = true }, + .utf8 = "A", + .unshifted_codepoint = 97, // lowercase A + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[97:65;2u", actual); + }); + try testing.expectEqualStrings("\x1b[97:65;2u", writer.buffered()); } test "kitty: matching unshifted codepoint" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_a, - .mods = .{ .shift = true }, - .utf8 = "A", - .unshifted_codepoint = 65, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_a, + .mods = .{ .shift = true }, + .utf8 = "A", + .unshifted_codepoint = 65, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true, }, - }; - + }); // WARNING: This is not a valid encoding. This is a hypothetical encoding // just to test that our logic is correct around matching unshifted // codepoints. We get an alternate here because the unshifted_codepoint does // not match the base key - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[65::97;2u", actual); + try testing.expectEqualStrings("\x1b[65::97;2u", writer.buffered()); } test "kitty: report alternates with caps" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_j, - .mods = .{ .caps_lock = true }, - .utf8 = "J", - .unshifted_codepoint = 106, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_j, + .mods = .{ .caps_lock = true }, + .utf8 = "J", + .unshifted_codepoint = 106, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[106;65;74u", actual); + }); + try testing.expectEqualStrings("\x1b[106;65;74u", writer.buffered()); } test "kitty: report alternates colon (shift+';')" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .semicolon, - .mods = .{ .shift = true }, - .utf8 = ":", - .unshifted_codepoint = ';', - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{ .shift = true }, + .utf8 = ":", + .unshifted_codepoint = ';', + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[59:58;2;58u", actual); + }); + try testing.expectEqualStrings("\x1b[59:58;2;58u", writer.buffered()); } test "kitty: report alternates with ru layout" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .semicolon, - .mods = .{}, - .utf8 = "ч", - .unshifted_codepoint = 1095, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{}, + .utf8 = "ч", + .unshifted_codepoint = 1095, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[1095::59;;1095u", actual); + }); + try testing.expectEqualStrings("\x1b[1095::59;;1095u", writer.buffered()); } test "kitty: report alternates with ru layout shifted" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .semicolon, - .mods = .{ .shift = true }, - .utf8 = "Ч", - .unshifted_codepoint = 1095, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{ .shift = true }, + .utf8 = "Ч", + .unshifted_codepoint = 1095, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[1095:1063:59;2;1063u", actual); + }); + try testing.expectEqualStrings("\x1b[1095:1063:59;2;1063u", writer.buffered()); } test "kitty: report alternates with ru layout caps lock" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .semicolon, - .mods = .{ .caps_lock = true }, - .utf8 = "Ч", - .unshifted_codepoint = 1095, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .semicolon, + .mods = .{ .caps_lock = true }, + .utf8 = "Ч", + .unshifted_codepoint = 1095, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[1095::59;65;1063u", actual); + }); + try testing.expectEqualStrings("\x1b[1095::59;65;1063u", writer.buffered()); } test "kitty: report alternates with hu layout release" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .action = .release, - .key = .bracket_left, - .mods = .{ .ctrl = true }, - .utf8 = "", - .unshifted_codepoint = 337, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .bracket_left, + .mods = .{ .ctrl = true }, + .utf8 = "", + .unshifted_codepoint = 337, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, @@ -1488,88 +1526,75 @@ test "kitty: report alternates with hu layout release" { .report_associated = true, .report_events = true, }, - }; - - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[337::91;5:3u", actual[1..]); } // macOS generates utf8 text for arrow keys. test "kitty: up arrow with utf8" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .arrow_up, - .mods = .{}, - .utf8 = &.{30}, - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .arrow_up, + .mods = .{}, + .utf8 = &.{30}, + }, .{ .kitty_flags = .{ .disambiguate = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[A", actual); + }); + try testing.expectEqualStrings("\x1b[A", writer.buffered()); } test "kitty: shift+tab" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .tab, - .mods = .{ .shift = true }, - .utf8 = "", // tab - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .tab, + .mods = .{ .shift = true }, + .utf8 = "", // tab + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[9;2u", actual); + }); + try testing.expectEqualStrings("\x1b[9;2u", writer.buffered()); } test "kitty: left shift" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .shift_left, - .mods = .{}, - .utf8 = "", - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .shift_left, + .mods = .{}, + .utf8 = "", + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } test "kitty: left shift with report all" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .shift_left, - .mods = .{}, - .utf8 = "", - }, - + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .shift_left, + .mods = .{}, + .utf8 = "", + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[57441u", actual); + }); + try testing.expectEqualStrings("\x1b[57441u", writer.buffered()); } test "kitty: report associated with alt text on macOS with option" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_w, - .mods = .{ .alt = true }, - .utf8 = "∑", - .unshifted_codepoint = 119, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_w, + .mods = .{ .alt = true }, + .utf8 = "∑", + .unshifted_codepoint = 119, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, @@ -1577,10 +1602,8 @@ test "kitty: report associated with alt text on macOS with option" { .report_associated = true, }, .macos_option_as_alt = .false, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[119;3;8721u", actual); + }); + try testing.expectEqualStrings("\x1b[119;3;8721u", writer.buffered()); } test "kitty: report associated with alt text on macOS with alt" { @@ -1589,13 +1612,13 @@ test "kitty: report associated with alt text on macOS with alt" { { // With Alt modifier var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_w, - .mods = .{ .alt = true }, - .utf8 = "∑", - .unshifted_codepoint = 119, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_w, + .mods = .{ .alt = true }, + .utf8 = "∑", + .unshifted_codepoint = 119, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, @@ -1603,22 +1626,20 @@ test "kitty: report associated with alt text on macOS with alt" { .report_associated = true, }, .macos_option_as_alt = .true, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[119;3u", actual); + }); + try testing.expectEqualStrings("\x1b[119;3u", writer.buffered()); } { // Without Alt modifier var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_w, - .mods = .{}, - .utf8 = "∑", - .unshifted_codepoint = 119, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_w, + .mods = .{}, + .utf8 = "∑", + .unshifted_codepoint = 119, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, @@ -1626,65 +1647,59 @@ test "kitty: report associated with alt text on macOS with alt" { .report_associated = true, }, .macos_option_as_alt = .true, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[119;;8721u", actual); + }); + try testing.expectEqualStrings("\x1b[119;;8721u", writer.buffered()); } } test "kitty: report associated with modifiers" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_j, - .mods = .{ .ctrl = true }, - .utf8 = "j", - .unshifted_codepoint = 106, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_j, + .mods = .{ .ctrl = true }, + .utf8 = "j", + .unshifted_codepoint = 106, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[106;5u", actual); + }); + try testing.expectEqualStrings("\x1b[106;5u", writer.buffered()); } test "kitty: report associated" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_j, - .mods = .{ .shift = true }, - .utf8 = "J", - .unshifted_codepoint = 106, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .key_j, + .mods = .{ .shift = true }, + .utf8 = "J", + .unshifted_codepoint = 106, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, .report_alternates = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[106:74;2;74u", actual); + }); + try testing.expectEqualStrings("\x1b[106:74;2;74u", writer.buffered()); } test "kitty: report associated on release" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .action = .release, - .key = .key_j, - .mods = .{ .shift = true }, - .utf8 = "J", - .unshifted_codepoint = 106, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .key_j, + .mods = .{ .shift = true }, + .utf8 = "J", + .unshifted_codepoint = 106, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_all = true, @@ -1692,54 +1707,53 @@ test "kitty: report associated on release" { .report_associated = true, .report_events = true, }, - }; - - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[106:74;2:3u", actual[1..]); } test "kitty: alternates omit control characters" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .delete, - .mods = .{}, - .utf8 = &.{0x7F}, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .delete, + .mods = .{}, + .utf8 = &.{0x7F}, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true, .report_all = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("\x1b[3~", actual); + }); + try testing.expectEqualStrings("\x1b[3~", writer.buffered()); } test "kitty: enter with utf8 (dead key state)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .enter, - .utf8 = "A", - .unshifted_codepoint = 0x0D, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .enter, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_alternates = true, .report_all = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("A", actual); + }); + try testing.expectEqualStrings("A", writer.buffered()); } test "kitty: keypad number" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ .key = .numpad_1, .mods = .{}, .utf8 = "1" }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .numpad_1, + .mods = .{}, + .utf8 = "1", + }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1747,19 +1761,19 @@ test "kitty: keypad number" { .report_all = true, .report_associated = true, }, - }; - const actual = try enc.kitty(&buf); + }); + const actual = writer.buffered(); try testing.expectEqualStrings("[57400;;49u", actual[1..]); } test "kitty: backspace with utf8 (dead key state)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .backspace, - .utf8 = "A", - .unshifted_codepoint = 0x0D, - }, + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{ .kitty_flags = .{ .disambiguate = true, .report_events = true, @@ -1767,261 +1781,223 @@ test "kitty: backspace with utf8 (dead key state)" { .report_all = true, .report_associated = true, }, - }; - - const actual = try enc.kitty(&buf); - try testing.expectEqualStrings("", actual); + }); + try testing.expectEqualStrings("", writer.buffered()); } test "legacy: backspace with utf8 (dead key state)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .backspace, - .utf8 = "A", - .unshifted_codepoint = 0x0D, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .backspace, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{}); + try testing.expectEqualStrings("", writer.buffered()); } test "legacy: enter with utf8 (dead key state)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .enter, - .utf8 = "A", - .unshifted_codepoint = 0x0D, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("A", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .enter, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{}); + try testing.expectEqualStrings("A", writer.buffered()); } test "legacy: esc with utf8 (dead key state)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .escape, - .utf8 = "A", - .unshifted_codepoint = 0x0D, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("A", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .escape, + .utf8 = "A", + .unshifted_codepoint = 0x0D, + }, .{}); + try testing.expectEqualStrings("A", writer.buffered()); } test "legacy: ctrl+shift+minus (underscore on US)" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .minus, - .mods = .{ .ctrl = true, .shift = true }, - .utf8 = "_", - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1F", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .minus, + .mods = .{ .ctrl = true, .shift = true }, + .utf8 = "_", + }, .{}); + try testing.expectEqualStrings("\x1F", writer.buffered()); } test "legacy: ctrl+alt+c" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_c, - .mods = .{ .ctrl = true, .alt = true }, - .utf8 = "c", - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b\x03", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .mods = .{ .ctrl = true, .alt = true }, + .utf8 = "c", + }, .{}); + try testing.expectEqualStrings("\x1b\x03", writer.buffered()); } test "legacy: alt+c" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_c, - .utf8 = "c", - .mods = .{ .alt = true }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .utf8 = "c", + .mods = .{ .alt = true }, + }, .{ .alt_esc_prefix = true, .macos_option_as_alt = .true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1Bc", actual); + }); + try testing.expectEqualStrings("\x1Bc", writer.buffered()); } test "legacy: alt+e only unshifted" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_e, - .unshifted_codepoint = 'e', - .mods = .{ .alt = true }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_e, + .unshifted_codepoint = 'e', + .mods = .{ .alt = true }, + }, .{ .alt_esc_prefix = true, .macos_option_as_alt = .true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1Be", actual); + }); + try testing.expectEqualStrings("\x1Be", writer.buffered()); } test "legacy: alt+x macos" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_c, - .utf8 = "≈", - .unshifted_codepoint = 'c', - .mods = .{ .alt = true }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .utf8 = "≈", + .unshifted_codepoint = 'c', + .mods = .{ .alt = true }, + }, .{ .alt_esc_prefix = true, .macos_option_as_alt = .true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1Bc", actual); + }); + try testing.expectEqualStrings("\x1Bc", writer.buffered()); } test "legacy: shift+alt+. macos" { if (comptime !builtin.target.os.tag.isDarwin()) return error.SkipZigTest; var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .period, - .utf8 = ">", - .unshifted_codepoint = '.', - .mods = .{ .alt = true, .shift = true }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .period, + .utf8 = ">", + .unshifted_codepoint = '.', + .mods = .{ .alt = true, .shift = true }, + }, .{ .alt_esc_prefix = true, .macos_option_as_alt = .true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1B>", actual); + }); + try testing.expectEqualStrings("\x1B>", writer.buffered()); } test "legacy: alt+ф" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_f, - .utf8 = "ф", - .mods = .{ .alt = true }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_f, + .utf8 = "ф", + .mods = .{ .alt = true }, + }, .{ .alt_esc_prefix = true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("ф", actual); + }); + try testing.expectEqualStrings("ф", writer.buffered()); } test "legacy: ctrl+c" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_c, - .mods = .{ .ctrl = true }, - .utf8 = "c", - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x03", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_c, + .mods = .{ .ctrl = true }, + .utf8 = "c", + }, .{}); + try testing.expectEqualStrings("\x03", writer.buffered()); } test "legacy: ctrl+space" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .space, - .mods = .{ .ctrl = true }, - .utf8 = " ", - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x00", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .space, + .mods = .{ .ctrl = true }, + .utf8 = " ", + }, .{}); + try testing.expectEqualStrings("\x00", writer.buffered()); } test "legacy: ctrl+shift+backspace" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .backspace, - .mods = .{ .ctrl = true, .shift = true }, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x08", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .backspace, + .mods = .{ .ctrl = true, .shift = true }, + }, .{}); + try testing.expectEqualStrings("\x08", writer.buffered()); } test "legacy: ctrl+shift+char with modify other state 2" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_h, - .mods = .{ .ctrl = true, .shift = true }, - .utf8 = "H", - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_h, + .mods = .{ .ctrl = true, .shift = true }, + .utf8 = "H", + }, .{ .modify_other_keys_state_2 = true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[27;6;72~", actual); + }); + try testing.expectEqualStrings("\x1b[27;6;72~", writer.buffered()); } test "legacy: fixterm awkward letters" { var buf: [128]u8 = undefined; { - var enc: KeyEncoder = .{ .event = .{ + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = .key_i, .mods = .{ .ctrl = true }, .utf8 = "i", - } }; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[105;5u", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[105;5u", writer.buffered()); } { - var enc: KeyEncoder = .{ .event = .{ + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = .key_m, .mods = .{ .ctrl = true }, .utf8 = "m", - } }; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[109;5u", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[109;5u", writer.buffered()); } { - var enc: KeyEncoder = .{ .event = .{ + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = .bracket_left, .mods = .{ .ctrl = true }, .utf8 = "[", - } }; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[91;5u", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[91;5u", writer.buffered()); } { - var enc: KeyEncoder = .{ .event = .{ + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = .digit_2, .mods = .{ .ctrl = true, .shift = true }, .utf8 = "@", .unshifted_codepoint = '2', - } }; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[64;5u", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[64;5u", writer.buffered()); } } @@ -2030,199 +2006,189 @@ test "legacy: fixterm awkward letters" { test "legacy: ctrl+shift+letter ascii" { var buf: [128]u8 = undefined; { - var enc: KeyEncoder = .{ .event = .{ + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ .key = .key_m, .mods = .{ .ctrl = true, .shift = true }, .utf8 = "M", .unshifted_codepoint = 'm', - } }; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[109;6u", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[109;6u", writer.buffered()); } } test "legacy: shift+function key should use all mods" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .arrow_up, - .mods = .{ .shift = true }, - .consumed_mods = .{ .shift = true }, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[1;2A", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .arrow_up, + .mods = .{ .shift = true }, + .consumed_mods = .{ .shift = true }, + }, .{}); + try testing.expectEqualStrings("\x1b[1;2A", writer.buffered()); } test "legacy: keypad enter" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .numpad_enter, - .mods = .{}, - .consumed_mods = .{}, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\r", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_enter, + .mods = .{}, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\r", writer.buffered()); } test "legacy: keypad 1" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .numpad_1, - .mods = .{}, - .consumed_mods = .{}, - .utf8 = "1", - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("1", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{}, + .consumed_mods = .{}, + .utf8 = "1", + }, .{}); + try testing.expectEqualStrings("1", writer.buffered()); } test "legacy: keypad 1 with application keypad" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .numpad_1, - .mods = .{}, - .consumed_mods = .{}, - .utf8 = "1", - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{}, + .consumed_mods = .{}, + .utf8 = "1", + }, .{ .keypad_key_application = true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1bOq", actual); + }); + try testing.expectEqualStrings("\x1bOq", writer.buffered()); } test "legacy: keypad 1 with application keypad and numlock" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .numpad_1, - .mods = .{ .num_lock = true }, - .consumed_mods = .{}, - .utf8 = "1", - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{ .num_lock = true }, + .consumed_mods = .{}, + .utf8 = "1", + }, .{ .keypad_key_application = true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1bOq", actual); + }); + try testing.expectEqualStrings("\x1bOq", writer.buffered()); } test "legacy: keypad 1 with application keypad and numlock ignore" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .numpad_1, - .mods = .{ .num_lock = false }, - .consumed_mods = .{}, - .utf8 = "1", - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .numpad_1, + .mods = .{ .num_lock = false }, + .consumed_mods = .{}, + .utf8 = "1", + }, .{ .keypad_key_application = true, .ignore_keypad_with_numlock = true, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("1", actual); + }); + try testing.expectEqualStrings("1", writer.buffered()); } test "legacy: f1" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .f1, - .mods = .{ .ctrl = true }, - .consumed_mods = .{}, - }, - }; // F1 { - enc.event.key = .f1; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[1;5P", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f1, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[1;5P", writer.buffered()); } // F2 { - enc.event.key = .f2; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[1;5Q", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f2, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[1;5Q", writer.buffered()); } // F3 { - enc.event.key = .f3; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[13;5~", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f3, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[13;5~", writer.buffered()); } // F4 { - enc.event.key = .f4; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[1;5S", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f4, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[1;5S", writer.buffered()); } // F5 uses new encoding { - enc.event.key = .f5; - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[15;5~", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .f5, + .mods = .{ .ctrl = true }, + .consumed_mods = .{}, + }, .{}); + try testing.expectEqualStrings("\x1b[15;5~", writer.buffered()); } } test "legacy: left_shift+tab" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .tab, - .mods = .{ - .shift = true, - .sides = .{ .shift = .left }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .tab, + .mods = .{ + .shift = true, + .sides = .{ .shift = .left }, }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[Z", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[Z", writer.buffered()); } test "legacy: right_shift+tab" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .tab, - .mods = .{ - .shift = true, - .sides = .{ .shift = .right }, - }, + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .tab, + .mods = .{ + .shift = true, + .sides = .{ .shift = .right }, }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x1b[Z", actual); + }, .{}); + try testing.expectEqualStrings("\x1b[Z", writer.buffered()); } test "legacy: hu layout ctrl+ő sends proper codepoint" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .bracket_left, - .mods = .{ .ctrl = true }, - .utf8 = "ő", - .unshifted_codepoint = 337, - }, - }; - - const actual = try enc.legacy(&buf); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .bracket_left, + .mods = .{ .ctrl = true }, + .utf8 = "ő", + .unshifted_codepoint = 337, + }, .{}); + const actual = writer.buffered(); try testing.expectEqualStrings("[337;5u", actual[1..]); } @@ -2230,46 +2196,37 @@ test "legacy: super-only on macOS with text" { if (comptime builtin.os.tag != .macos) return error.SkipZigTest; var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_b, - .utf8 = "b", - .mods = .{ .super = true }, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_b, + .utf8 = "b", + .mods = .{ .super = true }, + }, .{}); + try testing.expectEqualStrings("", writer.buffered()); } test "legacy: super and other mods on macOS with text" { if (comptime builtin.os.tag != .macos) return error.SkipZigTest; var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .key_b, - .utf8 = "B", - .mods = .{ .super = true, .shift = true }, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_b, + .utf8 = "B", + .mods = .{ .super = true, .shift = true }, + }, .{}); + try testing.expectEqualStrings("", writer.buffered()); } test "legacy: backspace with DEL utf8" { var buf: [128]u8 = undefined; - var enc: KeyEncoder = .{ - .event = .{ - .key = .backspace, - .utf8 = &.{0x7F}, - .unshifted_codepoint = 0x08, - }, - }; - - const actual = try enc.legacy(&buf); - try testing.expectEqualStrings("\x7F", actual); + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .backspace, + .utf8 = &.{0x7F}, + .unshifted_codepoint = 0x08, + }, .{}); + try testing.expectEqualStrings("\x7F", writer.buffered()); } test "ctrlseq: normal ctrl c" { diff --git a/src/input/keyboard.zig b/src/input/keyboard.zig index 73674df2c..d2882a23a 100644 --- a/src/input/keyboard.zig +++ b/src/input/keyboard.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const OptionAsAlt = @import("../config.zig").OptionAsAlt; +const OptionAsAlt = @import("config.zig").OptionAsAlt; /// Keyboard layouts. /// diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 4b064dc0d..4d51d1062 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -72,10 +72,22 @@ pub const input = struct { // the input package because the full package brings in too many // other dependencies. const paste = @import("input/paste.zig"); + const key = @import("input/key.zig"); + const key_encode = @import("input/key_encode.zig"); + + // Paste-related APIs pub const PasteError = paste.Error; pub const PasteOptions = paste.Options; pub const isSafePaste = paste.isSafe; pub const encodePaste = paste.encode; + + // Key encoding + pub const Key = key.Key; + pub const KeyAction = key.Action; + pub const KeyEvent = key.KeyEvent; + pub const KeyMods = key.Mods; + pub const KeyEncodeOptions = key_encode.Options; + pub const encodeKey = key_encode.encode; }; comptime { diff --git a/src/terminal/kitty/key.zig b/src/terminal/kitty/key.zig index 0883c90f2..8594c4c39 100644 --- a/src/terminal/kitty/key.zig +++ b/src/terminal/kitty/key.zig @@ -8,7 +8,7 @@ const std = @import("std"); pub const FlagStack = struct { const len = 8; - flags: [len]Flags = @splat(.{}), + flags: [len]Flags = @splat(.disabled), idx: u3 = 0, /// Return the current stack value @@ -51,12 +51,12 @@ pub const FlagStack = struct { // could send a huge number of pop commands to waste cpu. if (n >= self.flags.len) { self.idx = 0; - self.flags = @splat(.{}); + self.flags = @splat(.disabled); return; } for (0..n) |_| { - self.flags[self.idx] = .{}; + self.flags[self.idx] = .disabled; self.idx -%= 1; } } @@ -83,6 +83,15 @@ pub const Flags = packed struct(u5) { report_all: bool = false, report_associated: bool = false, + /// Kitty keyboard protocol disabled (all flags off). + pub const disabled: Flags = .{ + .disambiguate = false, + .report_events = false, + .report_alternates = false, + .report_all = false, + .report_associated = false, + }; + /// Sets all modes on. pub const @"true": Flags = .{ .disambiguate = true, From 1c0282d658f494674b93fe9aa8ded3160fe6290d Mon Sep 17 00:00:00 2001 From: NikoMalik Date: Sun, 5 Oct 2025 12:30:51 +0300 Subject: [PATCH 009/702] fix:use builtin memmove --- src/fastmem.zig | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/fastmem.zig b/src/fastmem.zig index bdea44155..47009a0a7 100644 --- a/src/fastmem.zig +++ b/src/fastmem.zig @@ -5,18 +5,7 @@ const assert = std.debug.assert; /// Same as std.mem.copyForwards/Backwards but prefers libc memmove if it is /// available because it is generally much faster. pub inline fn move(comptime T: type, dest: []T, source: []const T) void { - if (builtin.link_libc) { - _ = memmove(dest.ptr, source.ptr, source.len * @sizeOf(T)); - } else { - // Depending on the ordering of the copy, we need to use the - // proper call here. Unfortunately this function call is - // too generic to know this at comptime. - if (@intFromPtr(dest.ptr) <= @intFromPtr(source.ptr)) { - std.mem.copyForwards(T, dest, source); - } else { - std.mem.copyBackwards(T, dest, source); - } - } + @memmove(dest, source); } /// Same as @memcpy but prefers libc memcpy if it is available From ff3a6d06503f22cd38b4f741c9c63327805df6ca Mon Sep 17 00:00:00 2001 From: NikoMalik Date: Sun, 5 Oct 2025 17:35:58 +0300 Subject: [PATCH 010/702] fix: do not remove libc memmove until performance comparisons have been conducted --- src/fastmem.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/fastmem.zig b/src/fastmem.zig index 47009a0a7..d4a0a7750 100644 --- a/src/fastmem.zig +++ b/src/fastmem.zig @@ -2,10 +2,14 @@ const std = @import("std"); const builtin = @import("builtin"); const assert = std.debug.assert; -/// Same as std.mem.copyForwards/Backwards but prefers libc memmove if it is -/// available because it is generally much faster. +/// Same as @memmove but prefers libc memmove if it is +/// available because it is generally much faster?. pub inline fn move(comptime T: type, dest: []T, source: []const T) void { - @memmove(dest, source); + if (builtin.link_libc) { + _ = memmove(dest.ptr, source.ptr, source.len * @sizeOf(T)); + } else { + @memmove(dest, source); + } } /// Same as @memcpy but prefers libc memcpy if it is available From 61fe78c1d304b31400609d8191e427466ebcec25 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Oct 2025 20:32:42 -0700 Subject: [PATCH 011/702] lib-vt: expose key encoding as a C API --- AGENTS.md | 9 +- example/c-vt-key-encode/README.md | 22 + example/c-vt-key-encode/build.zig | 42 ++ example/c-vt-key-encode/build.zig.zon | 24 + example/c-vt-key-encode/src/main.c | 59 +++ include/ghostty/vt.h | 639 +++++++++++++++++++++++++- src/input/key_encode.zig | 10 + src/lib_vt.zig | 20 + src/terminal/c/key_encode.zig | 269 +++++++++++ src/terminal/c/key_event.zig | 253 ++++++++++ src/terminal/c/main.zig | 26 ++ 11 files changed, 1371 insertions(+), 2 deletions(-) create mode 100644 example/c-vt-key-encode/README.md create mode 100644 example/c-vt-key-encode/build.zig create mode 100644 example/c-vt-key-encode/build.zig.zon create mode 100644 example/c-vt-key-encode/src/main.c create mode 100644 src/terminal/c/key_encode.zig create mode 100644 src/terminal/c/key_event.zig diff --git a/AGENTS.md b/AGENTS.md index 2e90fd94e..14fff7b3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,10 +13,17 @@ A file for [guiding coding agents](https://agents.md/). ## Directory Structure - Shared Zig core: `src/` -- C API: `include/ghostty.h` +- C API: `include` - macOS app: `macos/` - GTK (Linux and FreeBSD) app: `src/apprt/gtk` +## libghostty-vt + +- Build: `zig build lib-vt` +- Test: `zig build test-lib-vt` +- Test filter: `zig build test-lib-vt -Dtest-filter=` +- When working on libghostty-vt, do not build the full app. + ## macOS App - Do not use `xcodebuild` diff --git a/example/c-vt-key-encode/README.md b/example/c-vt-key-encode/README.md new file mode 100644 index 000000000..05ee3fc31 --- /dev/null +++ b/example/c-vt-key-encode/README.md @@ -0,0 +1,22 @@ +# Example: `ghostty-vt` C Key Encoding + +This example demonstrates how to use the `ghostty-vt` C library to encode key +events into terminal escape sequences. + +This example specifically shows how to: + +1. Create a key encoder with the C API +2. Configure Kitty keyboard protocol flags (this example uses KKP) +3. Create and configure a key event +4. Encode the key event into a terminal escape sequence + +The example encodes a Ctrl key release event with the Ctrl modifier set, +producing the escape sequence `\x1b[57442;5:3u`. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-key-encode/build.zig b/example/c-vt-key-encode/build.zig new file mode 100644 index 000000000..b4b759744 --- /dev/null +++ b/example/c-vt-key-encode/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_key_encode", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-key-encode/build.zig.zon b/example/c-vt-key-encode/build.zig.zon new file mode 100644 index 000000000..5da1a9168 --- /dev/null +++ b/example/c-vt-key-encode/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt, + .version = "0.0.0", + .fingerprint = 0x413a8529b1255f9a, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-key-encode/src/main.c b/example/c-vt-key-encode/src/main.c new file mode 100644 index 000000000..82444f99d --- /dev/null +++ b/example/c-vt-key-encode/src/main.c @@ -0,0 +1,59 @@ +#include +#include +#include +#include +#include + +int main() { + GhosttyKeyEncoder encoder; + GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); + assert(result == GHOSTTY_SUCCESS); + + // Set kitty flags with all features enabled + ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); + + // Create key event + GhosttyKeyEvent event; + result = ghostty_key_event_new(NULL, &event); + assert(result == GHOSTTY_SUCCESS); + ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_RELEASE); + ghostty_key_event_set_key(event, GHOSTTY_KEY_CONTROL_LEFT); + ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); + printf("Encoding event: left ctrl release with all Kitty flags enabled\n"); + + // Optionally, encode with null buffer to get required size. You can + // skip this step and provide a sufficiently large buffer directly. + // If there isn't enoug hspace, the function will return an out of memory + // error. + size_t required = 0; + result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); + assert(result == GHOSTTY_OUT_OF_MEMORY); + printf("Required buffer size: %zu bytes\n", required); + + // Encode the key event. We don't use our required size above because + // that was just an example; we know 128 bytes is enough. + char buf[128]; + size_t written = 0; + result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + assert(result == GHOSTTY_SUCCESS); + printf("Encoded %zu bytes\n", written); + + // Print the encoded sequence (hex and string) + printf("Hex: "); + for (size_t i = 0; i < written; i++) printf("%02x ", (unsigned char)buf[i]); + printf("\n"); + + printf("String: "); + for (size_t i = 0; i < written; i++) { + if (buf[i] == 0x1b) { + printf("\\x1b"); + } else { + printf("%c", buf[i]); + } + } + printf("\n"); + + ghostty_key_event_free(event); + ghostty_key_encoder_free(encoder); + return 0; +} diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 4b930a96f..7815ebb81 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -27,6 +27,7 @@ * @section groups_sec API Reference * * The API is organized into the following groups: + * - @ref key "Key Encoding" - Encode key events into terminal sequences * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref allocator "Memory Management" - Memory management and custom allocators * @@ -319,7 +320,643 @@ typedef struct { /** @} */ // end of allocator group //------------------------------------------------------------------- -// Functions +// Key Encoding + +/** @defgroup key Key Encoding + * + * Utilities for encoding key events into terminal escape sequences, + * supporting both legacy encoding as well as Kitty Keyboard Protocol. + * + * @{ + */ + +/** + * Opaque handle to a key event. + * + * This handle represents a keyboard input event containing information about + * the physical key pressed, modifiers, and generated text. The event can be + * configured using the `ghostty_key_event_set_*` functions. + * + * @ingroup key + */ +typedef struct GhosttyKeyEvent *GhosttyKeyEvent; + +/** + * Keyboard input event types. + * + * @ingroup key + */ +typedef enum { + /** Key was released */ + GHOSTTY_KEY_ACTION_RELEASE = 0, + /** Key was pressed */ + GHOSTTY_KEY_ACTION_PRESS = 1, + /** Key is being repeated (held down) */ + GHOSTTY_KEY_ACTION_REPEAT = 2, +} GhosttyKeyAction; + +/** + * Keyboard modifier keys bitmask. + * + * A bitmask representing all keyboard modifiers. This tracks which modifier keys + * are pressed and, where supported by the platform, which side (left or right) + * of each modifier is active. + * + * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. + * + * Modifier side bits are only meaningful when the corresponding modifier bit is set. + * Not all platforms support distinguishing between left and right modifier + * keys and Ghostty is built to expect that some platforms may not provide this + * information. + * + * @ingroup key + */ +typedef uint16_t GhosttyMods; + +/** Shift key is pressed */ +#define GHOSTTY_MODS_SHIFT (1 << 0) +/** Control key is pressed */ +#define GHOSTTY_MODS_CTRL (1 << 1) +/** Alt/Option key is pressed */ +#define GHOSTTY_MODS_ALT (1 << 2) +/** Super/Command/Windows key is pressed */ +#define GHOSTTY_MODS_SUPER (1 << 3) +/** Caps Lock is active */ +#define GHOSTTY_MODS_CAPS_LOCK (1 << 4) +/** Num Lock is active */ +#define GHOSTTY_MODS_NUM_LOCK (1 << 5) + +/** + * Right shift is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_SHIFT is set. + */ +#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) +/** + * Right ctrl is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_CTRL is set. + */ +#define GHOSTTY_MODS_CTRL_SIDE (1 << 7) +/** + * Right alt is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_ALT is set. + */ +#define GHOSTTY_MODS_ALT_SIDE (1 << 8) +/** + * Right super is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_SUPER is set. + */ +#define GHOSTTY_MODS_SUPER_SIDE (1 << 9) + +/** + * Physical key codes. + * + * The set of key codes that Ghostty is aware of. These represent physical keys + * on the keyboard and are layout-independent. For example, the "a" key on a US + * keyboard is the same as the "ф" key on a Russian keyboard, but both will + * report the same key_a value. + * + * Layout-dependent strings are provided separately as UTF-8 text and are produced + * by the platform. These values are based on the W3C UI Events KeyboardEvent code + * standard. See: https://www.w3.org/TR/uievents-code + * + * @ingroup key + */ +typedef enum { + GHOSTTY_KEY_UNIDENTIFIED = 0, + + // Writing System Keys (W3C § 3.1.1) + GHOSTTY_KEY_BACKQUOTE, + GHOSTTY_KEY_BACKSLASH, + GHOSTTY_KEY_BRACKET_LEFT, + GHOSTTY_KEY_BRACKET_RIGHT, + GHOSTTY_KEY_COMMA, + GHOSTTY_KEY_DIGIT_0, + GHOSTTY_KEY_DIGIT_1, + GHOSTTY_KEY_DIGIT_2, + GHOSTTY_KEY_DIGIT_3, + GHOSTTY_KEY_DIGIT_4, + GHOSTTY_KEY_DIGIT_5, + GHOSTTY_KEY_DIGIT_6, + GHOSTTY_KEY_DIGIT_7, + GHOSTTY_KEY_DIGIT_8, + GHOSTTY_KEY_DIGIT_9, + GHOSTTY_KEY_EQUAL, + GHOSTTY_KEY_INTL_BACKSLASH, + GHOSTTY_KEY_INTL_RO, + GHOSTTY_KEY_INTL_YEN, + GHOSTTY_KEY_A, + GHOSTTY_KEY_B, + GHOSTTY_KEY_C, + GHOSTTY_KEY_D, + GHOSTTY_KEY_E, + GHOSTTY_KEY_F, + GHOSTTY_KEY_G, + GHOSTTY_KEY_H, + GHOSTTY_KEY_I, + GHOSTTY_KEY_J, + GHOSTTY_KEY_K, + GHOSTTY_KEY_L, + GHOSTTY_KEY_M, + GHOSTTY_KEY_N, + GHOSTTY_KEY_O, + GHOSTTY_KEY_P, + GHOSTTY_KEY_Q, + GHOSTTY_KEY_R, + GHOSTTY_KEY_S, + GHOSTTY_KEY_T, + GHOSTTY_KEY_U, + GHOSTTY_KEY_V, + GHOSTTY_KEY_W, + GHOSTTY_KEY_X, + GHOSTTY_KEY_Y, + GHOSTTY_KEY_Z, + GHOSTTY_KEY_MINUS, + GHOSTTY_KEY_PERIOD, + GHOSTTY_KEY_QUOTE, + GHOSTTY_KEY_SEMICOLON, + GHOSTTY_KEY_SLASH, + + // Functional Keys (W3C § 3.1.2) + GHOSTTY_KEY_ALT_LEFT, + GHOSTTY_KEY_ALT_RIGHT, + GHOSTTY_KEY_BACKSPACE, + GHOSTTY_KEY_CAPS_LOCK, + GHOSTTY_KEY_CONTEXT_MENU, + GHOSTTY_KEY_CONTROL_LEFT, + GHOSTTY_KEY_CONTROL_RIGHT, + GHOSTTY_KEY_ENTER, + GHOSTTY_KEY_META_LEFT, + GHOSTTY_KEY_META_RIGHT, + GHOSTTY_KEY_SHIFT_LEFT, + GHOSTTY_KEY_SHIFT_RIGHT, + GHOSTTY_KEY_SPACE, + GHOSTTY_KEY_TAB, + GHOSTTY_KEY_CONVERT, + GHOSTTY_KEY_KANA_MODE, + GHOSTTY_KEY_NON_CONVERT, + + // Control Pad Section (W3C § 3.2) + GHOSTTY_KEY_DELETE, + GHOSTTY_KEY_END, + GHOSTTY_KEY_HELP, + GHOSTTY_KEY_HOME, + GHOSTTY_KEY_INSERT, + GHOSTTY_KEY_PAGE_DOWN, + GHOSTTY_KEY_PAGE_UP, + + // Arrow Pad Section (W3C § 3.3) + GHOSTTY_KEY_ARROW_DOWN, + GHOSTTY_KEY_ARROW_LEFT, + GHOSTTY_KEY_ARROW_RIGHT, + GHOSTTY_KEY_ARROW_UP, + + // Numpad Section (W3C § 3.4) + GHOSTTY_KEY_NUM_LOCK, + GHOSTTY_KEY_NUMPAD_0, + GHOSTTY_KEY_NUMPAD_1, + GHOSTTY_KEY_NUMPAD_2, + GHOSTTY_KEY_NUMPAD_3, + GHOSTTY_KEY_NUMPAD_4, + GHOSTTY_KEY_NUMPAD_5, + GHOSTTY_KEY_NUMPAD_6, + GHOSTTY_KEY_NUMPAD_7, + GHOSTTY_KEY_NUMPAD_8, + GHOSTTY_KEY_NUMPAD_9, + GHOSTTY_KEY_NUMPAD_ADD, + GHOSTTY_KEY_NUMPAD_BACKSPACE, + GHOSTTY_KEY_NUMPAD_CLEAR, + GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, + GHOSTTY_KEY_NUMPAD_COMMA, + GHOSTTY_KEY_NUMPAD_DECIMAL, + GHOSTTY_KEY_NUMPAD_DIVIDE, + GHOSTTY_KEY_NUMPAD_ENTER, + GHOSTTY_KEY_NUMPAD_EQUAL, + GHOSTTY_KEY_NUMPAD_MEMORY_ADD, + GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, + GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, + GHOSTTY_KEY_NUMPAD_MEMORY_STORE, + GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, + GHOSTTY_KEY_NUMPAD_MULTIPLY, + GHOSTTY_KEY_NUMPAD_PAREN_LEFT, + GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, + GHOSTTY_KEY_NUMPAD_SUBTRACT, + GHOSTTY_KEY_NUMPAD_SEPARATOR, + GHOSTTY_KEY_NUMPAD_UP, + GHOSTTY_KEY_NUMPAD_DOWN, + GHOSTTY_KEY_NUMPAD_RIGHT, + GHOSTTY_KEY_NUMPAD_LEFT, + GHOSTTY_KEY_NUMPAD_BEGIN, + GHOSTTY_KEY_NUMPAD_HOME, + GHOSTTY_KEY_NUMPAD_END, + GHOSTTY_KEY_NUMPAD_INSERT, + GHOSTTY_KEY_NUMPAD_DELETE, + GHOSTTY_KEY_NUMPAD_PAGE_UP, + GHOSTTY_KEY_NUMPAD_PAGE_DOWN, + + // Function Section (W3C § 3.5) + GHOSTTY_KEY_ESCAPE, + GHOSTTY_KEY_F1, + GHOSTTY_KEY_F2, + GHOSTTY_KEY_F3, + GHOSTTY_KEY_F4, + GHOSTTY_KEY_F5, + GHOSTTY_KEY_F6, + GHOSTTY_KEY_F7, + GHOSTTY_KEY_F8, + GHOSTTY_KEY_F9, + GHOSTTY_KEY_F10, + GHOSTTY_KEY_F11, + GHOSTTY_KEY_F12, + GHOSTTY_KEY_F13, + GHOSTTY_KEY_F14, + GHOSTTY_KEY_F15, + GHOSTTY_KEY_F16, + GHOSTTY_KEY_F17, + GHOSTTY_KEY_F18, + GHOSTTY_KEY_F19, + GHOSTTY_KEY_F20, + GHOSTTY_KEY_F21, + GHOSTTY_KEY_F22, + GHOSTTY_KEY_F23, + GHOSTTY_KEY_F24, + GHOSTTY_KEY_F25, + GHOSTTY_KEY_FN, + GHOSTTY_KEY_FN_LOCK, + GHOSTTY_KEY_PRINT_SCREEN, + GHOSTTY_KEY_SCROLL_LOCK, + GHOSTTY_KEY_PAUSE, + + // Media Keys (W3C § 3.6) + GHOSTTY_KEY_BROWSER_BACK, + GHOSTTY_KEY_BROWSER_FAVORITES, + GHOSTTY_KEY_BROWSER_FORWARD, + GHOSTTY_KEY_BROWSER_HOME, + GHOSTTY_KEY_BROWSER_REFRESH, + GHOSTTY_KEY_BROWSER_SEARCH, + GHOSTTY_KEY_BROWSER_STOP, + GHOSTTY_KEY_EJECT, + GHOSTTY_KEY_LAUNCH_APP_1, + GHOSTTY_KEY_LAUNCH_APP_2, + GHOSTTY_KEY_LAUNCH_MAIL, + GHOSTTY_KEY_MEDIA_PLAY_PAUSE, + GHOSTTY_KEY_MEDIA_SELECT, + GHOSTTY_KEY_MEDIA_STOP, + GHOSTTY_KEY_MEDIA_TRACK_NEXT, + GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, + GHOSTTY_KEY_POWER, + GHOSTTY_KEY_SLEEP, + GHOSTTY_KEY_AUDIO_VOLUME_DOWN, + GHOSTTY_KEY_AUDIO_VOLUME_MUTE, + GHOSTTY_KEY_AUDIO_VOLUME_UP, + GHOSTTY_KEY_WAKE_UP, + + // Legacy, Non-standard, and Special Keys (W3C § 3.7) + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, +} GhosttyKey; + +/** + * Kitty keyboard protocol flags. + * + * Bitflags representing the various modes of the Kitty keyboard protocol. + * These can be combined using bitwise OR operations. Valid values all + * start with `GHOSTTY_KITTY_KEY_`. + * + * @ingroup key + */ +typedef uint8_t GhosttyKittyKeyFlags; + +/** Kitty keyboard protocol disabled (all flags off) */ +#define GHOSTTY_KITTY_KEY_DISABLED 0 + +/** Disambiguate escape codes */ +#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) + +/** Report key press and release events */ +#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) + +/** Report alternate key codes */ +#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) + +/** Report all key events including those normally handled by the terminal */ +#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) + +/** Report associated text with key events */ +#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) + +/** All Kitty keyboard protocol flags enabled */ +#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) + +/** + * macOS option key behavior. + * + * Determines whether the "option" key on macOS is treated as "alt" or not. + * See the Ghostty `macos-option-as-alt` configuration option for more details. + * + * @ingroup key + */ +typedef enum { + /** Option key is not treated as alt */ + GHOSTTY_OPTION_AS_ALT_FALSE = 0, + /** Option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_TRUE = 1, + /** Only left option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_LEFT = 2, + /** Only right option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_RIGHT = 3, +} GhosttyOptionAsAlt; + +/** + * Create a new key event instance. + * + * Creates a new key event with default values. The event must be freed using + * ghostty_key_event_free() when no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param event Pointer to store the created key event handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup key + */ +GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); + +/** + * Free a key event instance. + * + * Releases all resources associated with the key event. After this call, + * the event handle becomes invalid and must not be used. + * + * @param event The key event handle to free (may be NULL) + * + * @ingroup key + */ +void ghostty_key_event_free(GhosttyKeyEvent event); + +/** + * Set the key action (press, release, repeat). + * + * @param event The key event handle, must not be NULL + * @param action The action to set + * + * @ingroup key + */ +void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); + +/** + * Get the key action (press, release, repeat). + * + * @param event The key event handle, must not be NULL + * @return The key action + * + * @ingroup key + */ +GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); + +/** + * Set the physical key code. + * + * @param event The key event handle, must not be NULL + * @param key The physical key code to set + * + * @ingroup key + */ +void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); + +/** + * Get the physical key code. + * + * @param event The key event handle, must not be NULL + * @return The physical key code + * + * @ingroup key + */ +GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); + +/** + * Set the modifier keys bitmask. + * + * @param event The key event handle, must not be NULL + * @param mods The modifier keys bitmask to set + * + * @ingroup key + */ +void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); + +/** + * Get the modifier keys bitmask. + * + * @param event The key event handle, must not be NULL + * @return The modifier keys bitmask + * + * @ingroup key + */ +GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); + +/** + * Set the consumed modifiers bitmask. + * + * @param event The key event handle, must not be NULL + * @param consumed_mods The consumed modifiers bitmask to set + * + * @ingroup key + */ +void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); + +/** + * Get the consumed modifiers bitmask. + * + * @param event The key event handle, must not be NULL + * @return The consumed modifiers bitmask + * + * @ingroup key + */ +GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); + +/** + * Set whether the key event is part of a composition sequence. + * + * @param event The key event handle, must not be NULL + * @param composing Whether the key event is part of a composition sequence + * + * @ingroup key + */ +void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); + +/** + * Get whether the key event is part of a composition sequence. + * + * @param event The key event handle, must not be NULL + * @return Whether the key event is part of a composition sequence + * + * @ingroup key + */ +bool ghostty_key_event_get_composing(GhosttyKeyEvent event); + +/** + * Set the UTF-8 text generated by the key event. + * + * The key event does NOT take ownership of the text pointer. The caller + * must ensure the string remains valid for the lifetime needed by the event. + * + * @param event The key event handle, must not be NULL + * @param utf8 The UTF-8 text to set (or NULL for empty) + * @param len Length of the UTF-8 text in bytes + * + * @ingroup key + */ +void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); + +/** + * Get the UTF-8 text generated by the key event. + * + * The returned pointer is valid until the event is freed or the UTF-8 text is modified. + * + * @param event The key event handle, must not be NULL + * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) + * @return The UTF-8 text (or NULL for empty) + * + * @ingroup key + */ +const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); + +/** + * Set the unshifted Unicode codepoint. + * + * @param event The key event handle, must not be NULL + * @param codepoint The unshifted Unicode codepoint to set + * + * @ingroup key + */ +void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); + +/** + * Get the unshifted Unicode codepoint. + * + * @param event The key event handle, must not be NULL + * @return The unshifted Unicode codepoint + * + * @ingroup key + */ +uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); + +/** + * Opaque handle to a key encoder instance. + * + * This handle represents a key encoder that converts key events into terminal + * escape sequences. The encoder supports both legacy encoding and the Kitty + * Keyboard Protocol, depending on the options set. + * + * @ingroup key + */ +typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; + +/** + * Key encoder option identifiers. + * + * These values are used with ghostty_key_encoder_setopt() to configure + * the behavior of the key encoder. + * + * @ingroup key + */ +typedef enum { + /** Terminal DEC mode 1: cursor key application mode (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, + + /** Terminal DEC mode 66: keypad key application mode (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, + + /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, + + /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, + + /** xterm modifyOtherKeys mode 2 (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, + + /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ + GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, + + /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ + GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, +} GhosttyKeyEncoderOption; + +/** + * Create a new key encoder instance. + * + * Creates a new key encoder with default options. The encoder can be configured + * using ghostty_key_encoder_setopt() and must be freed using + * ghostty_key_encoder_free() when no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param encoder Pointer to store the created encoder handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup key + */ +GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); + +/** + * Free a key encoder instance. + * + * Releases all resources associated with the key encoder. After this call, + * the encoder handle becomes invalid and must not be used. + * + * @param encoder The encoder handle to free (may be NULL) + * + * @ingroup key + */ +void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); + +/** + * Set an option on the key encoder. + * + * Configures the behavior of the key encoder. Options control various aspects + * of encoding such as terminal modes (cursor key application mode, keypad mode), + * protocol selection (Kitty keyboard protocol flags), and platform-specific + * behaviors (macOS option-as-alt). + * + * A null pointer value does nothing. It does not reset the value to the + * default. The setopt call will do nothing. + * + * @param encoder The encoder handle, must not be NULL + * @param option The option to set + * @param value Pointer to the value to set (type depends on the option) + * + * @ingroup key + */ +void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); + +/** + * Encode a key event into a terminal escape sequence. + * + * Converts a key event into the appropriate terminal escape sequence based on + * the encoder's current options. The sequence is written to the provided buffer. + * + * Not all key events produce output. For example, unmodified modifier keys + * typically don't generate escape sequences. Check the out_len parameter to + * determine if any data was written. + * + * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY + * and out_len will contain the required buffer size. The caller can then + * allocate a larger buffer and call the function again. + * + * @param encoder The encoder handle, must not be NULL + * @param event The key event to encode, must not be NULL + * @param out_buf Buffer to write the encoded sequence to + * @param out_buf_size Size of the output buffer in bytes + * @param out_len Pointer to store the number of bytes written (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code + * + * @ingroup key + */ +GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); + +/** @} */ // end of key group + +//------------------------------------------------------------------- +// OSC Parser /** @defgroup osc OSC Parser * diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index c35cdebaa..fa641c1aa 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -36,6 +36,16 @@ pub const Options = struct { /// docs for a more detailed description of why this is needed. macos_option_as_alt: OptionAsAlt = .false, + pub const default: Options = .{ + .cursor_key_application = false, + .keypad_key_application = false, + .ignore_keypad_with_numlock = false, + .alt_esc_prefix = false, + .modify_other_keys_state_2 = false, + .kitty_flags = .disabled, + .macos_option_as_alt = .false, + }; + /// Initialize our options from the terminal state. /// /// Note that `macos_option_as_alt` cannot be determined from diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 4d51d1062..73a030333 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -102,6 +102,26 @@ comptime { @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); + @export(&c.key_event_new, .{ .name = "ghostty_key_event_new" }); + @export(&c.key_event_free, .{ .name = "ghostty_key_event_free" }); + @export(&c.key_event_set_action, .{ .name = "ghostty_key_event_set_action" }); + @export(&c.key_event_get_action, .{ .name = "ghostty_key_event_get_action" }); + @export(&c.key_event_set_key, .{ .name = "ghostty_key_event_set_key" }); + @export(&c.key_event_get_key, .{ .name = "ghostty_key_event_get_key" }); + @export(&c.key_event_set_mods, .{ .name = "ghostty_key_event_set_mods" }); + @export(&c.key_event_get_mods, .{ .name = "ghostty_key_event_get_mods" }); + @export(&c.key_event_set_consumed_mods, .{ .name = "ghostty_key_event_set_consumed_mods" }); + @export(&c.key_event_get_consumed_mods, .{ .name = "ghostty_key_event_get_consumed_mods" }); + @export(&c.key_event_set_composing, .{ .name = "ghostty_key_event_set_composing" }); + @export(&c.key_event_get_composing, .{ .name = "ghostty_key_event_get_composing" }); + @export(&c.key_event_set_utf8, .{ .name = "ghostty_key_event_set_utf8" }); + @export(&c.key_event_get_utf8, .{ .name = "ghostty_key_event_get_utf8" }); + @export(&c.key_event_set_unshifted_codepoint, .{ .name = "ghostty_key_event_set_unshifted_codepoint" }); + @export(&c.key_event_get_unshifted_codepoint, .{ .name = "ghostty_key_event_get_unshifted_codepoint" }); + @export(&c.key_encoder_new, .{ .name = "ghostty_key_encoder_new" }); + @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); + @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); + @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); } } diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig new file mode 100644 index 000000000..96754d884 --- /dev/null +++ b/src/terminal/c/key_encode.zig @@ -0,0 +1,269 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const key_encode = @import("../../input/key_encode.zig"); +const key_event = @import("key_event.zig"); +const KittyFlags = @import("../../terminal/kitty/key.zig").Flags; +const OptionAsAlt = @import("../../input/config.zig").OptionAsAlt; +const Result = @import("result.zig").Result; +const KeyEvent = @import("key_event.zig").Event; + +/// Wrapper around key encoding options that tracks the allocator for C API usage. +const KeyEncoderWrapper = struct { + opts: key_encode.Options, + alloc: Allocator, +}; + +/// C: GhosttyKeyEncoder +pub const Encoder = ?*KeyEncoderWrapper; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Encoder, +) callconv(.c) Result { + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(KeyEncoderWrapper) catch + return .out_of_memory; + ptr.* = .{ + .opts = .{}, + .alloc = alloc, + }; + result.* = ptr; + return .success; +} + +pub fn free(encoder_: Encoder) callconv(.c) void { + const wrapper = encoder_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +/// C: GhosttyKeyEncoderOption +pub const Option = enum(c_int) { + cursor_key_application = 0, + keypad_key_application = 1, + ignore_keypad_with_numlock = 2, + alt_esc_prefix = 3, + modify_other_keys_state_2 = 4, + kitty_flags = 5, + macos_option_as_alt = 6, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .cursor_key_application, + .keypad_key_application, + .ignore_keypad_with_numlock, + .alt_esc_prefix, + .modify_other_keys_state_2, + => bool, + .kitty_flags => u8, + .macos_option_as_alt => OptionAsAlt, + }; + } +}; + +pub fn setopt( + encoder_: Encoder, + option: Option, + value: ?*const anyopaque, +) callconv(.c) void { + return switch (option) { + inline else => |comptime_option| setoptTyped( + encoder_, + comptime_option, + @ptrCast(@alignCast(value orelse return)), + ), + }; +} + +fn setoptTyped( + encoder_: Encoder, + comptime option: Option, + value: *const option.InType(), +) void { + const opts = &encoder_.?.opts; + switch (option) { + .cursor_key_application => opts.cursor_key_application = value.*, + .keypad_key_application => opts.keypad_key_application = value.*, + .ignore_keypad_with_numlock => opts.ignore_keypad_with_numlock = value.*, + .alt_esc_prefix => opts.alt_esc_prefix = value.*, + .modify_other_keys_state_2 => opts.modify_other_keys_state_2 = value.*, + .kitty_flags => opts.kitty_flags = flags: { + const bits: u5 = @truncate(value.*); + break :flags @bitCast(bits); + }, + .macos_option_as_alt => opts.macos_option_as_alt = value.*, + } +} + +pub fn encode( + encoder_: Encoder, + event_: KeyEvent, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(.c) Result { + // Attempt to write to this buffer + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + key_encode.encode( + &writer, + event_.?.event, + encoder_.?.opts, + ) catch |err| switch (err) { + error.WriteFailed => { + // If we don't have space, use a discarding writer to count + // how much space we would have needed. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + key_encode.encode( + &discarding.writer, + event_.?.event, + encoder_.?.opts, + ) catch unreachable; + + out_written.* = discarding.count; + return .out_of_memory; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "alloc" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + free(e); +} + +test "setopt bool" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Test setting bool options + const val_true: bool = true; + setopt(e, .cursor_key_application, &val_true); + try testing.expect(e.?.opts.cursor_key_application); + + const val_false: bool = false; + setopt(e, .cursor_key_application, &val_false); + try testing.expect(!e.?.opts.cursor_key_application); + + setopt(e, .keypad_key_application, &val_true); + try testing.expect(e.?.opts.keypad_key_application); +} + +test "setopt kitty flags" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Test setting kitty flags + const flags: KittyFlags = .{ + .disambiguate = true, + .report_events = true, + }; + const flags_int: u8 = @intCast(flags.int()); + setopt(e, .kitty_flags, &flags_int); + try testing.expect(e.?.opts.kitty_flags.disambiguate); + try testing.expect(e.?.opts.kitty_flags.report_events); + try testing.expect(!e.?.opts.kitty_flags.report_alternates); +} + +test "setopt macos option as alt" { + const testing = std.testing; + var e: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Test setting option as alt + const opt_left: OptionAsAlt = .left; + setopt(e, .macos_option_as_alt, &opt_left); + try testing.expectEqual(OptionAsAlt.left, e.?.opts.macos_option_as_alt); + + const opt_true: OptionAsAlt = .true; + setopt(e, .macos_option_as_alt, &opt_true); + try testing.expectEqual(OptionAsAlt.true, e.?.opts.macos_option_as_alt); +} + +test "encode: kitty ctrl release with ctrl mod set" { + const testing = std.testing; + + // Create encoder + var encoder: Encoder = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &encoder, + )); + defer free(encoder); + + // Set kitty flags with all features enabled + { + const flags: KittyFlags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }; + const flags_int: u8 = @intCast(flags.int()); + setopt(encoder, .kitty_flags, &flags_int); + } + + // Create key event + var event: key_event.Event = undefined; + try testing.expectEqual(Result.success, key_event.new( + &lib_alloc.test_allocator, + &event, + )); + defer key_event.free(event); + + // Set event properties: release action, ctrl key, ctrl modifier + key_event.set_action(event, .release); + key_event.set_key(event, .control_left); + key_event.set_mods(event, .{ .ctrl = true }); + + // Encode null should give us the length required + var required: usize = 0; + try testing.expectEqual(Result.out_of_memory, encode( + encoder, + event, + null, + 0, + &required, + )); + + // Encode the key event + var buf: [128]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, encode( + encoder, + event, + &buf, + buf.len, + &written, + )); + try testing.expectEqual(required, written); + + // Expected: ESC[57442;5:3u (ctrl key code with mods and release event) + const actual = buf[0..written]; + try testing.expectEqualStrings("\x1b[57442;5:3u", actual); +} diff --git a/src/terminal/c/key_event.zig b/src/terminal/c/key_event.zig new file mode 100644 index 000000000..5befe4384 --- /dev/null +++ b/src/terminal/c/key_event.zig @@ -0,0 +1,253 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const key = @import("../../input/key.zig"); +const Result = @import("result.zig").Result; + +/// Wrapper around KeyEvent that tracks the allocator for C API usage. +/// The UTF-8 text is not owned by this wrapper - the caller is responsible +/// for ensuring the lifetime of any UTF-8 text set via set_utf8. +const KeyEventWrapper = struct { + event: key.KeyEvent = .{}, + alloc: Allocator, +}; + +/// C: GhosttyKeyEvent +pub const Event = ?*KeyEventWrapper; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Event, +) callconv(.c) Result { + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(KeyEventWrapper) catch + return .out_of_memory; + ptr.* = .{ .alloc = alloc }; + result.* = ptr; + return .success; +} + +pub fn free(event_: Event) callconv(.c) void { + const wrapper = event_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn set_action(event_: Event, action: key.Action) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.action = action; +} + +pub fn get_action(event_: Event) callconv(.c) key.Action { + const event: *key.KeyEvent = &event_.?.event; + return event.action; +} + +pub fn set_key(event_: Event, k: key.Key) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.key = k; +} + +pub fn get_key(event_: Event) callconv(.c) key.Key { + const event: *key.KeyEvent = &event_.?.event; + return event.key; +} + +pub fn set_mods(event_: Event, mods: key.Mods) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.mods = mods; +} + +pub fn get_mods(event_: Event) callconv(.c) key.Mods { + const event: *key.KeyEvent = &event_.?.event; + return event.mods; +} + +pub fn set_consumed_mods(event_: Event, consumed_mods: key.Mods) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.consumed_mods = consumed_mods; +} + +pub fn get_consumed_mods(event_: Event) callconv(.c) key.Mods { + const event: *key.KeyEvent = &event_.?.event; + return event.consumed_mods; +} + +pub fn set_composing(event_: Event, composing: bool) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.composing = composing; +} + +pub fn get_composing(event_: Event) callconv(.c) bool { + const event: *key.KeyEvent = &event_.?.event; + return event.composing; +} + +pub fn set_utf8(event_: Event, utf8: ?[*]const u8, len: usize) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.utf8 = if (utf8) |ptr| ptr[0..len] else ""; +} + +pub fn get_utf8(event_: Event, len: ?*usize) callconv(.c) ?[*]const u8 { + const event: *key.KeyEvent = &event_.?.event; + if (len) |l| l.* = event.utf8.len; + return if (event.utf8.len == 0) null else event.utf8.ptr; +} + +pub fn set_unshifted_codepoint(event_: Event, codepoint: u32) callconv(.c) void { + const event: *key.KeyEvent = &event_.?.event; + event.unshifted_codepoint = @truncate(codepoint); +} + +pub fn get_unshifted_codepoint(event_: Event) callconv(.c) u32 { + const event: *key.KeyEvent = &event_.?.event; + return event.unshifted_codepoint; +} + +test "alloc" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + free(e); +} + +test "set" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Test action + set_action(e, .press); + try testing.expectEqual(key.Action.press, e.?.event.action); + + // Test key + set_key(e, .key_a); + try testing.expectEqual(key.Key.key_a, e.?.event.key); + + // Test mods + const mods: key.Mods = .{ .shift = true, .ctrl = true }; + set_mods(e, mods); + try testing.expect(e.?.event.mods.shift); + try testing.expect(e.?.event.mods.ctrl); + + // Test consumed mods + const consumed: key.Mods = .{ .shift = true }; + set_consumed_mods(e, consumed); + try testing.expect(e.?.event.consumed_mods.shift); + try testing.expect(!e.?.event.consumed_mods.ctrl); + + // Test composing + set_composing(e, true); + try testing.expect(e.?.event.composing); + + // Test UTF-8 + const text = "hello"; + set_utf8(e, text.ptr, text.len); + try testing.expectEqualStrings(text, e.?.event.utf8); + + // Test UTF-8 null + set_utf8(e, null, 0); + try testing.expectEqualStrings("", e.?.event.utf8); + + // Test unshifted codepoint + set_unshifted_codepoint(e, 'a'); + try testing.expectEqual(@as(u21, 'a'), e.?.event.unshifted_codepoint); +} + +test "get" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Set some values + set_action(e, .repeat); + set_key(e, .key_z); + + const mods: key.Mods = .{ .alt = true, .super = true }; + set_mods(e, mods); + + const consumed: key.Mods = .{ .alt = true }; + set_consumed_mods(e, consumed); + + set_composing(e, true); + + const text = "test"; + set_utf8(e, text.ptr, text.len); + + set_unshifted_codepoint(e, 'z'); + + // Get them back + try testing.expectEqual(key.Action.repeat, get_action(e)); + try testing.expectEqual(key.Key.key_z, get_key(e)); + + const got_mods = get_mods(e); + try testing.expect(got_mods.alt); + try testing.expect(got_mods.super); + + const got_consumed = get_consumed_mods(e); + try testing.expect(got_consumed.alt); + try testing.expect(!got_consumed.super); + + try testing.expect(get_composing(e)); + + var utf8_len: usize = undefined; + const got_utf8 = get_utf8(e, &utf8_len); + try testing.expect(got_utf8 != null); + try testing.expectEqual(@as(usize, 4), utf8_len); + try testing.expectEqualStrings("test", got_utf8.?[0..utf8_len]); + + try testing.expectEqual(@as(u32, 'z'), get_unshifted_codepoint(e)); +} + +test "complete key event" { + const testing = std.testing; + var e: Event = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &e, + )); + defer free(e); + + // Build a complete key event for shift+a + set_action(e, .press); + set_key(e, .key_a); + + const mods: key.Mods = .{ .shift = true }; + set_mods(e, mods); + + const consumed: key.Mods = .{ .shift = true }; + set_consumed_mods(e, consumed); + + const text = "A"; + set_utf8(e, text.ptr, text.len); + + set_unshifted_codepoint(e, 'a'); + + // Verify all fields + try testing.expectEqual(key.Action.press, e.?.event.action); + try testing.expectEqual(key.Key.key_a, e.?.event.key); + try testing.expect(e.?.event.mods.shift); + try testing.expect(e.?.event.consumed_mods.shift); + try testing.expectEqualStrings("A", e.?.event.utf8); + try testing.expectEqual(@as(u21, 'a'), e.?.event.unshifted_codepoint); + + // Also test the getter + var utf8_len: usize = undefined; + const got_utf8 = get_utf8(e, &utf8_len); + try testing.expect(got_utf8 != null); + try testing.expectEqual(@as(usize, 1), utf8_len); + try testing.expectEqualStrings("A", got_utf8.?[0..utf8_len]); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 68fd77edd..500dbf56c 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -1,4 +1,6 @@ pub const osc = @import("osc.zig"); +pub const key_event = @import("key_event.zig"); +pub const key_encode = @import("key_encode.zig"); // The full C API, unexported. pub const osc_new = osc.new; @@ -9,8 +11,32 @@ pub const osc_end = osc.end; pub const osc_command_type = osc.commandType; pub const osc_command_data = osc.commandData; +pub const key_event_new = key_event.new; +pub const key_event_free = key_event.free; +pub const key_event_set_action = key_event.set_action; +pub const key_event_get_action = key_event.get_action; +pub const key_event_set_key = key_event.set_key; +pub const key_event_get_key = key_event.get_key; +pub const key_event_set_mods = key_event.set_mods; +pub const key_event_get_mods = key_event.get_mods; +pub const key_event_set_consumed_mods = key_event.set_consumed_mods; +pub const key_event_get_consumed_mods = key_event.get_consumed_mods; +pub const key_event_set_composing = key_event.set_composing; +pub const key_event_get_composing = key_event.get_composing; +pub const key_event_set_utf8 = key_event.set_utf8; +pub const key_event_get_utf8 = key_event.get_utf8; +pub const key_event_set_unshifted_codepoint = key_event.set_unshifted_codepoint; +pub const key_event_get_unshifted_codepoint = key_event.get_unshifted_codepoint; + +pub const key_encoder_new = key_encode.new; +pub const key_encoder_free = key_encode.free; +pub const key_encoder_setopt = key_encode.setopt; +pub const key_encoder_encode = key_encode.encode; + test { _ = osc; + _ = key_event; + _ = key_encode; // We want to make sure we run the tests for the C allocator interface. _ = @import("../../lib/allocator.zig"); From fd0851bae7c7a05086bc0761b63b3dbe33a82c1c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Oct 2025 14:48:40 -0700 Subject: [PATCH 012/702] lib-vt: update documentation with more examples --- DoxygenLayout.xml | 269 +++++++++++++++++++++++++++++++++++++++++++ include/ghostty/vt.h | 99 ++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 DoxygenLayout.xml diff --git a/DoxygenLayout.xml b/DoxygenLayout.xml new file mode 100644 index 000000000..67497e83f --- /dev/null +++ b/DoxygenLayout.xml @@ -0,0 +1,269 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 7815ebb81..ebb41e300 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -327,6 +327,63 @@ typedef struct { * Utilities for encoding key events into terminal escape sequences, * supporting both legacy encoding as well as Kitty Keyboard Protocol. * + * ## Basic Usage + * + * 1. Create an encoder instance with ghostty_key_encoder_new() + * 2. Configure encoder options with ghostty_key_encoder_setopt(). + * 3. For each key event: + * - Create a key event with ghostty_key_event_new() + * - Set event properties (action, key, modifiers, etc.) + * - Encode with ghostty_key_encoder_encode() + * - Free the event with ghostty_key_event_free() + * - Note: You can also reuse the same key event multiple times by + * changing its properties. + * 4. Free the encoder with ghostty_key_encoder_free() when done + * + * ## Example + * + * @code{.c} + * #include + * #include + * #include + * + * int main() { + * // Create encoder + * GhosttyKeyEncoder encoder; + * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); + * assert(result == GHOSTTY_SUCCESS); + * + * // Enable Kitty keyboard protocol with all features + * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, + * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); + * + * // Create and configure key event for Ctrl+C press + * GhosttyKeyEvent event; + * result = ghostty_key_event_new(NULL, &event); + * assert(result == GHOSTTY_SUCCESS); + * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); + * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); + * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); + * + * // Encode the key event + * char buf[128]; + * size_t written = 0; + * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * assert(result == GHOSTTY_SUCCESS); + * + * // Use the encoded sequence (e.g., write to terminal) + * fwrite(buf, 1, written, stdout); + * + * // Cleanup + * ghostty_key_event_free(event); + * ghostty_key_encoder_free(encoder); + * return 0; + * } + * @endcode + * + * For a complete working example, see example/c-vt-key-encode in the + * repository. + * * @{ */ @@ -949,6 +1006,48 @@ void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOpti * @param out_len Pointer to store the number of bytes written (may be NULL) * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code * + * ## Example: Calculate required buffer size + * + * @code{.c} + * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) + * size_t required = 0; + * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); + * assert(result == GHOSTTY_OUT_OF_MEMORY); + * + * // Allocate buffer of required size + * char *buf = malloc(required); + * + * // Encode with properly sized buffer + * size_t written = 0; + * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); + * assert(result == GHOSTTY_SUCCESS); + * + * // Use the encoded sequence... + * + * free(buf); + * @endcode + * + * ## Example: Direct encoding with static buffer + * + * @code{.c} + * // Most escape sequences are short, so a static buffer often suffices + * char buf[128]; + * size_t written = 0; + * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * if (result == GHOSTTY_SUCCESS) { + * // Write the encoded sequence to the terminal + * write(pty_fd, buf, written); + * } else if (result == GHOSTTY_OUT_OF_MEMORY) { + * // Buffer too small, written contains required size + * char *dynamic_buf = malloc(written); + * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); + * assert(result == GHOSTTY_SUCCESS); + * write(pty_fd, dynamic_buf, written); + * free(dynamic_buf); + * } + * @endcode + * * @ingroup key */ GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); From aeb6647aa6e151873e0f5cd312148306315b0c82 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Oct 2025 15:00:59 -0700 Subject: [PATCH 013/702] libghostty docs: use latest Doxygen --- src/build/docker/lib-c-docs/Dockerfile | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index f30dfba90..76f29ad21 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -1,3 +1,26 @@ +#-------------------------------------------------------------------- +# Build Doxygen from source +#-------------------------------------------------------------------- +FROM ubuntu:24.04 AS doxygen-builder + +ARG DOXYGEN_VERSION=1.14.0 +RUN apt-get update && apt-get install -y \ + wget \ + cmake \ + g++ \ + flex \ + bison \ + python3 \ + && rm -rf /var/lib/apt/lists/* \ + && wget -q https://www.doxygen.nl/files/doxygen-${DOXYGEN_VERSION}.src.tar.gz \ + && tar -xzf doxygen-${DOXYGEN_VERSION}.src.tar.gz \ + && cd doxygen-${DOXYGEN_VERSION} \ + && cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release \ + && make -j$(nproc) \ + && make install \ + && cd .. \ + && rm -rf doxygen-${DOXYGEN_VERSION}* + #-------------------------------------------------------------------- # Generate documentation with Doxygen #-------------------------------------------------------------------- @@ -6,9 +29,9 @@ FROM ubuntu:24.04 AS builder # Build argument for noindex header ARG ADD_NOINDEX_HEADER=false RUN apt-get update && apt-get install -y \ - doxygen \ graphviz \ && rm -rf /var/lib/apt/lists/* +COPY --from=doxygen-builder /usr/local/bin/doxygen /usr/local/bin/doxygen WORKDIR /ghostty COPY include/ ./include/ COPY Doxyfile ./ From c5ea4a807907306c82017ec73a9e41805c2f0714 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Oct 2025 15:19:16 -0700 Subject: [PATCH 014/702] libghostty: use Arch for docs container to get later Doxygen --- src/build/docker/lib-c-docs/Dockerfile | 33 ++++---------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index 76f29ad21..5667c6607 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -1,40 +1,17 @@ -#-------------------------------------------------------------------- -# Build Doxygen from source -#-------------------------------------------------------------------- -FROM ubuntu:24.04 AS doxygen-builder - -ARG DOXYGEN_VERSION=1.14.0 -RUN apt-get update && apt-get install -y \ - wget \ - cmake \ - g++ \ - flex \ - bison \ - python3 \ - && rm -rf /var/lib/apt/lists/* \ - && wget -q https://www.doxygen.nl/files/doxygen-${DOXYGEN_VERSION}.src.tar.gz \ - && tar -xzf doxygen-${DOXYGEN_VERSION}.src.tar.gz \ - && cd doxygen-${DOXYGEN_VERSION} \ - && cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release \ - && make -j$(nproc) \ - && make install \ - && cd .. \ - && rm -rf doxygen-${DOXYGEN_VERSION}* - #-------------------------------------------------------------------- # Generate documentation with Doxygen #-------------------------------------------------------------------- -FROM ubuntu:24.04 AS builder +FROM alpine:latest AS builder # Build argument for noindex header ARG ADD_NOINDEX_HEADER=false -RUN apt-get update && apt-get install -y \ - graphviz \ - && rm -rf /var/lib/apt/lists/* -COPY --from=doxygen-builder /usr/local/bin/doxygen /usr/local/bin/doxygen +RUN apk add --no-cache \ + doxygen \ + graphviz WORKDIR /ghostty COPY include/ ./include/ COPY Doxyfile ./ +COPY DoxygenLayout.xml ./ RUN mkdir -p zig-out/share/ghostty/doc/libghostty RUN doxygen From d6ef048cd7d9327cdae25d76da5820947d79646c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Oct 2025 14:20:05 -0500 Subject: [PATCH 015/702] freebsd: fix CI for Zig 0.15 and enable FreeBSD 15.0 --- .github/workflows/test.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9893106dd..a0750bd90 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -563,6 +563,8 @@ jobs: test: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-md + outputs: + zig_version: ${{ steps.zig.outputs.version }} env: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache @@ -570,6 +572,11 @@ jobs: - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Get required Zig version + id: zig + run: | + echo "version=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig)" >> $GITHUB_OUTPUT + - name: Setup Cache uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: @@ -1137,12 +1144,11 @@ jobs: name: Build on FreeBSD needs: test runs-on: namespace-profile-mitchellh-sm-systemd - if: false # FIXME: FreeBSD does not yet ship with Zig 0.15 strategy: matrix: release: - "14.3" - # - "15.0" # disable until fixed: https://github.com/vmactions/freebsd-vm/issues/108 + - "15.0" steps: - name: Checkout Ghostty uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -1163,14 +1169,19 @@ jobs: devel/gettext \ devel/git \ devel/pkgconf \ + ftp/curl \ graphics/wayland \ - lang/zig \ security/ca_root_nss \ textproc/hs-pandoc \ x11-fonts/jetbrains-mono \ x11-toolkits/libadwaita \ x11-toolkits/gtk40 \ x11-toolkits/gtk4-layer-shell + curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/${{ needs.test.outputs.zig_version }}/zig-x86_64-freebsd-${{ needs.test.outputs.zig_version }}.tar.xz" && \ + mkdir /opt && \ + tar -xf /tmp/zig.tar.xz -C /opt && \ + rm /tmp/zig.tar.xz && \ + ln -s "/opt/zig-x86_64-freebsd-${{ needs.test.outputs.zig_version }}/zig" /usr/local/bin/zig run: | zig env From 67357c663db1f44f329f729b36c1b7d04d363e1d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Oct 2025 19:08:20 -0500 Subject: [PATCH 016/702] freebsd: add a timeout to the freebsd job --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a0750bd90..59556f58e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1149,6 +1149,7 @@ jobs: release: - "14.3" - "15.0" + timeout-minutes: 10 steps: - name: Checkout Ghostty uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 From 34cb77c9f207d73077dbe87e7484ba3f56c884a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:10:09 +0000 Subject: [PATCH 017/702] build(deps): bump softprops/action-gh-release from 2.3.3 to 2.3.4 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.3.3 to 2.3.4. - [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/6cbd405e2c4e67a21c47fa9e383d020e4e28b836...62c96d0c4e8a889135c1f3a25910db8dbe0e85f7) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 2.3.4 dependency-type: direct:production update-type: version-update:semver-patch ... 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 7f7b85e2f..eca678244 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -188,7 +188,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@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 + uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -359,7 +359,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 + uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -590,7 +590,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 + uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -775,7 +775,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 + uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 with: name: 'Ghostty Tip ("Nightly")' prerelease: true From f03344b1c61b1867afae4df859f099460c2319a0 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Oct 2025 19:46:04 -0500 Subject: [PATCH 018/702] osc: reorder osc tests and name them consistently --- src/terminal/osc.zig | 1706 ++++++++++++++++++------------------ src/terminal/osc/color.zig | 22 +- 2 files changed, 888 insertions(+), 840 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 897a5ef0f..1d41d95f2 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -1691,7 +1691,7 @@ test { _ = osc_color; } -test "OSC: change_window_title" { +test "OSC 0: change_window_title" { const testing = std.testing; var p: Parser = .init(); @@ -1704,7 +1704,62 @@ test "OSC: change_window_title" { try testing.expectEqualStrings("ab", cmd.change_window_title); } -test "OSC: change_window_title with 2" { +test "OSC 0: longer than buffer" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "0;" ++ "a" ** (Parser.MAX_BUF + 2); + for (input) |ch| p.next(ch); + + try testing.expect(p.end(null) == null); + try testing.expect(p.complete == false); +} + +test "OSC 0: one shorter than buffer length" { + const testing = std.testing; + + var p: Parser = .init(); + + const prefix = "0;"; + const title = "a" ** (Parser.MAX_BUF - prefix.len - 1); + const input = prefix ++ title; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings(title, cmd.change_window_title); +} + +test "OSC 0: exactly at buffer length" { + const testing = std.testing; + + var p: Parser = .init(); + + const prefix = "0;"; + const title = "a" ** (Parser.MAX_BUF - prefix.len); + const input = prefix ++ title; + for (input) |ch| p.next(ch); + + // This should be null because we always reserve space for a null terminator. + try testing.expect(p.end(null) == null); + try testing.expect(p.complete == false); +} + +test "OSC 1: change_window_icon" { + const testing = std.testing; + + var p: Parser = .init(); + p.next('1'); + p.next(';'); + p.next('a'); + p.next('b'); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_icon); + try testing.expectEqualStrings("ab", cmd.change_window_icon); +} + +test "OSC 2: change_window_title with 2" { const testing = std.testing; var p: Parser = .init(); @@ -1717,7 +1772,7 @@ test "OSC: change_window_title with 2" { try testing.expectEqualStrings("ab", cmd.change_window_title); } -test "OSC: change_window_title with utf8" { +test "OSC 2: change_window_title with utf8" { const testing = std.testing; var p: Parser = .init(); @@ -1739,7 +1794,7 @@ test "OSC: change_window_title with utf8" { try testing.expectEqualStrings("— ‐", cmd.change_window_title); } -test "OSC: change_window_title empty" { +test "OSC 2: change_window_title empty" { const testing = std.testing; var p: Parser = .init(); @@ -1750,207 +1805,23 @@ test "OSC: change_window_title empty" { try testing.expectEqualStrings("", cmd.change_window_title); } -test "OSC: change_window_icon" { - const testing = std.testing; - - var p: Parser = .init(); - p.next('1'); - p.next(';'); - p.next('a'); - p.next('b'); - const cmd = p.end(null).?.*; - try testing.expect(cmd == .change_window_icon); - try testing.expectEqualStrings("ab", cmd.change_window_icon); -} - -test "OSC: prompt_start" { +test "OSC 4: empty param" { const testing = std.testing; var p: Parser = .init(); - const input = "133;A"; + const input = "4;;"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.aid == null); - try testing.expect(cmd.prompt_start.redraw); + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); } -test "OSC: prompt_start with single option" { - const testing = std.testing; +// See src/terminal/osc/color.zig for more OSC 4 tests. - var p: Parser = .init(); +// See src/terminal/osc/color.zig for OSC 5 tests. - const input = "133;A;aid=14"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); -} - -test "OSC: prompt_start with redraw disabled" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;A;redraw=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(!cmd.prompt_start.redraw); -} - -test "OSC: prompt_start with redraw invalid value" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;A;redraw=42"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.redraw); - try testing.expect(cmd.prompt_start.kind == .primary); -} - -test "OSC: prompt_start with continuation" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;A;k=c"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.kind == .continuation); -} - -test "OSC: prompt_start with secondary" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;A;k=s"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_start); - try testing.expect(cmd.prompt_start.kind == .secondary); -} - -test "OSC: end_of_command no exit code" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;D"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_command); -} - -test "OSC: end_of_command with exit code" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;D;25"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_command); - try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); -} - -test "OSC: prompt_end" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;B"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .prompt_end); -} - -test "OSC: end_of_input" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "133;C"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .end_of_input); -} - -test "OSC: get/set clipboard" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "52;s;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 's'); - try testing.expectEqualStrings("?", cmd.clipboard_contents.data); -} - -test "OSC: get/set clipboard (optional parameter)" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "52;;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 'c'); - try testing.expectEqualStrings("?", cmd.clipboard_contents.data); -} - -test "OSC: get/set clipboard with allocator" { - const testing = std.testing; - - var p: Parser = .initAlloc(testing.allocator); - defer p.deinit(); - - const input = "52;s;?"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 's'); - try testing.expectEqualStrings("?", cmd.clipboard_contents.data); -} - -test "OSC: clear clipboard" { - const testing = std.testing; - - var p: Parser = .init(); - defer p.deinit(); - - const input = "52;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .clipboard_contents); - try testing.expect(cmd.clipboard_contents.kind == 'c'); - try testing.expectEqualStrings("", cmd.clipboard_contents.data); -} - -test "OSC: report pwd" { +test "OSC 7: report pwd" { const testing = std.testing; var p: Parser = .init(); @@ -1963,7 +1834,7 @@ test "OSC: report pwd" { try testing.expectEqualStrings("file:///tmp/example", cmd.report_pwd.value); } -test "OSC: report pwd empty" { +test "OSC 7: report pwd empty" { const testing = std.testing; var p: Parser = .init(); @@ -1975,610 +1846,7 @@ test "OSC: report pwd empty" { try testing.expectEqualStrings("", cmd.report_pwd.value); } -test "OSC: pointer cursor" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "22;pointer"; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .mouse_shape); - try testing.expectEqualStrings("pointer", cmd.mouse_shape.value); -} - -test "OSC: longer than buffer" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "0;" ++ "a" ** (Parser.MAX_BUF + 2); - for (input) |ch| p.next(ch); - - try testing.expect(p.end(null) == null); - try testing.expect(p.complete == false); -} - -test "OSC: one shorter than buffer length" { - const testing = std.testing; - - var p: Parser = .init(); - - const prefix = "0;"; - const title = "a" ** (Parser.MAX_BUF - prefix.len - 1); - const input = prefix ++ title; - for (input) |ch| p.next(ch); - - const cmd = p.end(null).?.*; - try testing.expect(cmd == .change_window_title); - try testing.expectEqualStrings(title, cmd.change_window_title); -} - -test "OSC: exactly at buffer length" { - const testing = std.testing; - - var p: Parser = .init(); - - const prefix = "0;"; - const title = "a" ** (Parser.MAX_BUF - prefix.len); - const input = prefix ++ title; - for (input) |ch| p.next(ch); - - // This should be null because we always reserve space for a null terminator. - try testing.expect(p.end(null) == null); - try testing.expect(p.complete == false); -} - -test "OSC: OSC 9;1 ConEmu sleep" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1;420"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(420, cmd.conemu_sleep.duration_ms); -} - -test "OSC: OSC 9;1 ConEmu sleep with no value default to 100ms" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); -} - -test "OSC: OSC 9;1 conemu sleep cannot exceed 10000ms" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1;12345"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(10000, cmd.conemu_sleep.duration_ms); -} - -test "OSC: OSC 9;1 conemu sleep invalid input" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1;foo"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_sleep); - try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); -} - -test "OSC: OSC 9;1 conemu sleep -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("1", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;1 conemu sleep -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;1a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("1a", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9 show desktop notification" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;Hello world"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("", cmd.show_desktop_notification.title); - try testing.expectEqualStrings("Hello world", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9 show single character desktop notification" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;H"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("", cmd.show_desktop_notification.title); - try testing.expectEqualStrings("H", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 777 show desktop notification with title" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "777;notify;Title;Body"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); - try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); -} - -test "OSC: OSC 9;2 ConEmu message box" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2;hello world"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_show_message_box); - try testing.expectEqualStrings("hello world", cmd.conemu_show_message_box); -} - -test "OSC: 9;2 ConEmu message box invalid input" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); -} - -test "OSC: 9;2 ConEmu message box empty message" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_show_message_box); - try testing.expectEqualStrings("", cmd.conemu_show_message_box); -} - -test "OSC: 9;2 ConEmu message box spaces only message" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2; "; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_show_message_box); - try testing.expectEqualStrings(" ", cmd.conemu_show_message_box); -} - -test "OSC: OSC 9;2 message box -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;2 message box -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;2a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("2a", cmd.show_desktop_notification.body); -} - -test "OSC: 9;3 ConEmu change tab title" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;3;foo bar"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_change_tab_title); - try testing.expectEqualStrings("foo bar", cmd.conemu_change_tab_title.value); -} - -test "OSC: 9;3 ConEmu change tab title reset" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;3;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - const expected_command: Command = .{ .conemu_change_tab_title = .reset }; - try testing.expectEqual(expected_command, cmd); -} - -test "OSC: 9;3 ConEmu change tab title spaces only" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;3; "; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .conemu_change_tab_title); - try testing.expectEqualStrings(" ", cmd.conemu_change_tab_title.value); -} - -test "OSC: OSC 9;3 change tab title -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;3"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("3", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;3 message box -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;3a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("3a", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;4 ConEmu progress set" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;1;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expect(cmd.conemu_progress_report.progress == 100); -} - -test "OSC: OSC 9;4 ConEmu progress set overflow" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;1;900"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expectEqual(100, cmd.conemu_progress_report.progress); -} - -test "OSC: OSC 9;4 ConEmu progress set single digit" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;1;9"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expect(cmd.conemu_progress_report.progress == 9); -} - -test "OSC: OSC 9;4 ConEmu progress set double digit" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;1;94"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expectEqual(94, cmd.conemu_progress_report.progress); -} - -test "OSC: OSC 9;4 ConEmu progress set extra semicolon ignored" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;1;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .set); - try testing.expectEqual(100, cmd.conemu_progress_report.progress); -} - -test "OSC: OSC 9;4 ConEmu progress remove with no progress" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;0;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC: OSC 9;4 ConEmu progress remove with double semicolon" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;0;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC: OSC 9;4 ConEmu progress remove ignores progress" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;0;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC: OSC 9;4 ConEmu progress remove extra semicolon" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;0;100;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .remove); -} - -test "OSC: OSC 9;4 ConEmu progress error" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;2"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .@"error"); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC: OSC 9;4 ConEmu progress error with progress" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;2;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .@"error"); - try testing.expect(cmd.conemu_progress_report.progress == 100); -} - -test "OSC: OSC 9;4 progress pause" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;4"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .pause); - try testing.expect(cmd.conemu_progress_report.progress == null); -} - -test "OSC: OSC 9;4 ConEmu progress pause with progress" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;4;100"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_progress_report); - try testing.expect(cmd.conemu_progress_report.state == .pause); - try testing.expect(cmd.conemu_progress_report.progress == 100); -} - -test "OSC: OSC 9;4 progress -> desktop notification 1" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;4 progress -> desktop notification 2" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4;", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;4 progress -> desktop notification 3" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;5"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4;5", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;4 progress -> desktop notification 4" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;4;5a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("4;5a", cmd.show_desktop_notification.body); -} - -test "OSC: OSC 9;5 ConEmu wait input" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;5"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_wait_input); -} - -test "OSC: OSC 9;5 ConEmu wait ignores trailing characters" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "9;5;foo"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_wait_input); -} - -test "OSC: empty param" { - const testing = std.testing; - - var p: Parser = .init(); - - const input = "4;;"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b'); - try testing.expect(cmd == null); -} - -test "OSC: hyperlink" { +test "OSC 8: hyperlink" { const testing = std.testing; var p: Parser = .init(); @@ -2591,7 +1859,7 @@ test "OSC: hyperlink" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with id set" { +test "OSC 8: hyperlink with id set" { const testing = std.testing; var p: Parser = .init(); @@ -2605,7 +1873,7 @@ test "OSC: hyperlink with id set" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with empty id" { +test "OSC 8: hyperlink with empty id" { const testing = std.testing; var p: Parser = .init(); @@ -2619,7 +1887,7 @@ test "OSC: hyperlink with empty id" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with incomplete key" { +test "OSC 8: hyperlink with incomplete key" { const testing = std.testing; var p: Parser = .init(); @@ -2633,7 +1901,7 @@ test "OSC: hyperlink with incomplete key" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with empty key" { +test "OSC 8: hyperlink with empty key" { const testing = std.testing; var p: Parser = .init(); @@ -2647,7 +1915,7 @@ test "OSC: hyperlink with empty key" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with empty key and id" { +test "OSC 8: hyperlink with empty key and id" { const testing = std.testing; var p: Parser = .init(); @@ -2661,7 +1929,7 @@ test "OSC: hyperlink with empty key and id" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } -test "OSC: hyperlink with empty uri" { +test "OSC 8: hyperlink with empty uri" { const testing = std.testing; var p: Parser = .init(); @@ -2673,7 +1941,7 @@ test "OSC: hyperlink with empty uri" { try testing.expect(cmd == null); } -test "OSC: hyperlink end" { +test "OSC 8: hyperlink end" { const testing = std.testing; var p: Parser = .init(); @@ -2685,7 +1953,591 @@ test "OSC: hyperlink end" { try testing.expect(cmd == .hyperlink_end); } -test "OSC: kitty color protocol" { +test "OSC 9: show desktop notification" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;Hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("", cmd.show_desktop_notification.title); + try testing.expectEqualStrings("Hello world", cmd.show_desktop_notification.body); +} + +test "OSC 9: show single character desktop notification" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;H"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("", cmd.show_desktop_notification.title); + try testing.expectEqualStrings("H", cmd.show_desktop_notification.body); +} + +test "OSC 9;1: ConEmu sleep" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;1;420"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(420, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: ConEmu sleep with no value default to 100ms" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;1;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: conemu sleep cannot exceed 10000ms" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;1;12345"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(10000, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: conemu sleep invalid input" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;1;foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_sleep); + try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); +} + +test "OSC 9;1: conemu sleep -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;1"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("1", cmd.show_desktop_notification.body); +} + +test "OSC 9;1: conemu sleep -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;1a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("1a", cmd.show_desktop_notification.body); +} + +test "OSC 9;2: ConEmu message box" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;2;hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_show_message_box); + try testing.expectEqualStrings("hello world", cmd.conemu_show_message_box); +} + +test "OSC 9;2: ConEmu message box invalid input" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); +} + +test "OSC 9;2: ConEmu message box empty message" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;2;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_show_message_box); + try testing.expectEqualStrings("", cmd.conemu_show_message_box); +} + +test "OSC 9;2: ConEmu message box spaces only message" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;2; "; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_show_message_box); + try testing.expectEqualStrings(" ", cmd.conemu_show_message_box); +} + +test "OSC 9;2: message box -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); +} + +test "OSC 9;2: message box -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;2a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("2a", cmd.show_desktop_notification.body); +} + +test "OSC 9;3: ConEmu change tab title" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;3;foo bar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_change_tab_title); + try testing.expectEqualStrings("foo bar", cmd.conemu_change_tab_title.value); +} + +test "OSC 9;3: ConEmu change tab title reset" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;3;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + const expected_command: Command = .{ .conemu_change_tab_title = .reset }; + try testing.expectEqual(expected_command, cmd); +} + +test "OSC 9;3: ConEmu change tab title spaces only" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;3; "; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .conemu_change_tab_title); + try testing.expectEqualStrings(" ", cmd.conemu_change_tab_title.value); +} + +test "OSC 9;3: change tab title -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;3"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("3", cmd.show_desktop_notification.body); +} + +test "OSC 9;3: message box -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;3a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("3a", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: ConEmu progress set" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;1;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expect(cmd.conemu_progress_report.progress == 100); +} + +test "OSC 9;4: ConEmu progress set overflow" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;1;900"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expectEqual(100, cmd.conemu_progress_report.progress); +} + +test "OSC 9;4: ConEmu progress set single digit" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;1;9"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expect(cmd.conemu_progress_report.progress == 9); +} + +test "OSC 9;4: ConEmu progress set double digit" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;1;94"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expectEqual(94, cmd.conemu_progress_report.progress); +} + +test "OSC 9;4: ConEmu progress set extra semicolon ignored" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;1;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .set); + try testing.expectEqual(100, cmd.conemu_progress_report.progress); +} + +test "OSC 9;4: ConEmu progress remove with no progress" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;0;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress remove with double semicolon" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;0;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress remove ignores progress" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;0;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress remove extra semicolon" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;0;100;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .remove); +} + +test "OSC 9;4: ConEmu progress error" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .@"error"); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress error with progress" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;2;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .@"error"); + try testing.expect(cmd.conemu_progress_report.progress == 100); +} + +test "OSC 9;4: progress pause" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;4"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .pause); + try testing.expect(cmd.conemu_progress_report.progress == null); +} + +test "OSC 9;4: ConEmu progress pause with progress" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;4;100"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_progress_report); + try testing.expect(cmd.conemu_progress_report.state == .pause); + try testing.expect(cmd.conemu_progress_report.progress == 100); +} + +test "OSC 9;4: progress -> desktop notification 1" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: progress -> desktop notification 2" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4;", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: progress -> desktop notification 3" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;5"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4;5", cmd.show_desktop_notification.body); +} + +test "OSC 9;4: progress -> desktop notification 4" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;4;5a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("4;5a", cmd.show_desktop_notification.body); +} + +test "OSC 9;5: ConEmu wait input" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;5"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_wait_input); +} + +test "OSC 9;5: ConEmu wait ignores trailing characters" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "9;5;foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_wait_input); +} + +test "OSC 9;6: ConEmu guimacro 1" { + const testing = std.testing; + + var p: Parser = .initAlloc(testing.allocator); + defer p.deinit(); + + const input = "9;6;a"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_guimacro); + try testing.expectEqualStrings("a", cmd.conemu_guimacro); +} + +test "OSC: 9;6: ConEmu guimacro 2" { + const testing = std.testing; + + var p: Parser = .initAlloc(testing.allocator); + defer p.deinit(); + + const input = "9;6;ab"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .conemu_guimacro); + try testing.expectEqualStrings("ab", cmd.conemu_guimacro); +} + +test "OSC: 9;6: ConEmu guimacro 3 incomplete -> desktop notification" { + const testing = std.testing; + + var p: Parser = .initAlloc(testing.allocator); + defer p.deinit(); + + const input = "9;6"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings("6", cmd.show_desktop_notification.body); +} + +// See src/terminal/osc/color.zig for OSC 10 tests. + +// See src/terminal/osc/color.zig for OSC 11 tests. + +// See src/terminal/osc/color.zig for OSC 12 tests. + +// See src/terminal/osc/color.zig for OSC 13 tests. + +// See src/terminal/osc/color.zig for OSC 14 tests. + +// See src/terminal/osc/color.zig for OSC 15 tests. + +// See src/terminal/osc/color.zig for OSC 16 tests. + +// See src/terminal/osc/color.zig for OSC 17 tests. + +// See src/terminal/osc/color.zig for OSC 18 tests. + +// See src/terminal/osc/color.zig for OSC 19 tests. + +test "OSC 21: kitty color protocol" { const testing = std.testing; const Kind = kitty_color.Kind; @@ -2757,7 +2609,7 @@ test "OSC: kitty color protocol" { } } -test "OSC: kitty color protocol without allocator" { +test "OSC 21: kitty color protocol without allocator" { const testing = std.testing; var p: Parser = .init(); @@ -2768,7 +2620,7 @@ test "OSC: kitty color protocol without allocator" { try testing.expect(p.end('\x1b') == null); } -test "OSC: kitty color protocol double reset" { +test "OSC 21: kitty color protocol double reset" { const testing = std.testing; var p: Parser = .initAlloc(testing.allocator); @@ -2784,7 +2636,7 @@ test "OSC: kitty color protocol double reset" { p.reset(); } -test "OSC: kitty color protocol reset after invalid" { +test "OSC 21: kitty color protocol reset after invalid" { const testing = std.testing; var p: Parser = .initAlloc(testing.allocator); @@ -2805,7 +2657,7 @@ test "OSC: kitty color protocol reset after invalid" { p.reset(); } -test "OSC: kitty color protocol no key" { +test "OSC 21: kitty color protocol no key" { const testing = std.testing; var p: Parser = .initAlloc(testing.allocator); @@ -2819,44 +2671,240 @@ test "OSC: kitty color protocol no key" { try testing.expectEqual(0, cmd.kitty_color_protocol.list.items.len); } -test "OSC: 9;6: ConEmu guimacro 1" { +test "OSC 22: pointer cursor" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); - defer p.deinit(); + var p: Parser = .init(); - const input = "9;6;a"; + const input = "22;pointer"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_guimacro); - try testing.expectEqualStrings("a", cmd.conemu_guimacro); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .mouse_shape); + try testing.expectEqualStrings("pointer", cmd.mouse_shape.value); } -test "OSC: 9;6: ConEmu guimacro 2" { +test "OSC 52: get/set clipboard" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); - defer p.deinit(); + var p: Parser = .init(); - const input = "9;6;ab"; + const input = "52;s;?"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .conemu_guimacro); - try testing.expectEqualStrings("ab", cmd.conemu_guimacro); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 's'); + try testing.expectEqualStrings("?", cmd.clipboard_contents.data); } -test "OSC: 9;6: ConEmu guimacro 3 incomplete -> desktop notification" { +test "OSC 52: get/set clipboard (optional parameter)" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "52;;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 'c'); + try testing.expectEqualStrings("?", cmd.clipboard_contents.data); +} + +test "OSC 52: get/set clipboard with allocator" { const testing = std.testing; var p: Parser = .initAlloc(testing.allocator); defer p.deinit(); - const input = "9;6"; + const input = "52;s;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 's'); + try testing.expectEqualStrings("?", cmd.clipboard_contents.data); +} + +test "OSC 52: clear clipboard" { + const testing = std.testing; + + var p: Parser = .init(); + defer p.deinit(); + + const input = "52;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expect(cmd.clipboard_contents.kind == 'c'); + try testing.expectEqualStrings("", cmd.clipboard_contents.data); +} + +// See src/terminal/osc/color.zig for OSC 104 tests. + +// See src/terminal/osc/color.zig for OSC 105 tests. + +// See src/terminal/osc/color.zig for OSC 110 tests. + +// See src/terminal/osc/color.zig for OSC 111 tests. + +// See src/terminal/osc/color.zig for OSC 112 tests. + +// See src/terminal/osc/color.zig for OSC 113 tests. + +// See src/terminal/osc/color.zig for OSC 114 tests. + +// See src/terminal/osc/color.zig for OSC 115 tests. + +// See src/terminal/osc/color.zig for OSC 116 tests. + +// See src/terminal/osc/color.zig for OSC 117 tests. + +// See src/terminal/osc/color.zig for OSC 118 tests. + +// See src/terminal/osc/color.zig for OSC 119 tests. + +test "OSC 133: prompt_start" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.aid == null); + try testing.expect(cmd.prompt_start.redraw); +} + +test "OSC 133: prompt_start with single option" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;aid=14"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); +} + +test "OSC 133: prompt_start with redraw disabled" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;redraw=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(!cmd.prompt_start.redraw); +} + +test "OSC 133: prompt_start with redraw invalid value" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;redraw=42"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.redraw); + try testing.expect(cmd.prompt_start.kind == .primary); +} + +test "OSC 133: prompt_start with continuation" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;k=c"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.kind == .continuation); +} + +test "OSC 133: prompt_start with secondary" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;k=s"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.kind == .secondary); +} + +test "OSC 133: end_of_command no exit code" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;D"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_command); +} + +test "OSC 133: end_of_command with exit code" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;D;25"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_command); + try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); +} + +test "OSC 133: prompt_end" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;B"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_end); +} + +test "OSC 133: end_of_input" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); +} + +test "OSC: OSC 777 show desktop notification with title" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "777;notify;Title;Body"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings("6", cmd.show_desktop_notification.body); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); } diff --git a/src/terminal/osc/color.zig b/src/terminal/osc/color.zig index 8a8e8b942..9fd81ed63 100644 --- a/src/terminal/osc/color.zig +++ b/src/terminal/osc/color.zig @@ -279,7 +279,7 @@ pub const ColoredTarget = struct { color: RGB, }; -test "osc4" { +test "OSC 4:" { const testing = std.testing; const alloc = testing.allocator; @@ -401,7 +401,7 @@ test "osc4" { } } -test "osc5" { +test "OSC 5:" { const testing = std.testing; const alloc = testing.allocator; @@ -433,7 +433,7 @@ test "osc5" { } } -test "osc4: multiple requests" { +test "OSC 4: multiple requests" { const testing = std.testing; const alloc = testing.allocator; @@ -489,7 +489,7 @@ test "osc4: multiple requests" { } } -test "osc104" { +test "OSC 104:" { const testing = std.testing; const alloc = testing.allocator; @@ -540,7 +540,7 @@ test "osc104" { } } -test "osc104 empty index" { +test "OSC 104: empty index" { const testing = std.testing; const alloc = testing.allocator; @@ -557,7 +557,7 @@ test "osc104 empty index" { ); } -test "osc104 invalid index" { +test "OSC 104: invalid index" { const testing = std.testing; const alloc = testing.allocator; @@ -570,7 +570,7 @@ test "osc104 invalid index" { ); } -test "osc104 reset all" { +test "OSC 104: reset all" { const testing = std.testing; const alloc = testing.allocator; @@ -583,7 +583,7 @@ test "osc104 reset all" { ); } -test "osc105 reset all" { +test "OSC 105: reset all" { const testing = std.testing; const alloc = testing.allocator; @@ -597,7 +597,7 @@ test "osc105 reset all" { } // OSC 10-19: Get/Set Dynamic Colors -test "dynamic" { +test "OSC 10: OSC 11: OSC 12: OSC: 13: OSC 14: OSC 15: OSC: 16: OSC 17: OSC 18: OSC 19: dynamic" { const testing = std.testing; const alloc = testing.allocator; @@ -625,7 +625,7 @@ test "dynamic" { } } -test "dynamic multiple" { +test "OSC 10: OSC 11: OSC 12: OSC: 13: OSC 14: OSC 15: OSC: 16: OSC 17: OSC 18: OSC 19: dynamic multiple" { const testing = std.testing; const alloc = testing.allocator; @@ -657,7 +657,7 @@ test "dynamic multiple" { } // OSC 110-119: Reset Dynamic Colors -test "reset dynamic" { +test "OSC 110: OSC 111: OSC 112: OSC: 113: OSC 114: OSC 115: OSC: 116: OSC 117: OSC 118: OSC 119: reset dynamic" { const testing = std.testing; const alloc = testing.allocator; From a249b3da3a1c84de94d26dd83aa0b190bdd29739 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Oct 2025 20:44:52 -0500 Subject: [PATCH 019/702] linux cgroup: fix initialization --- src/os/cgroup.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig index 97c796f8b..761386a2f 100644 --- a/src/os/cgroup.zig +++ b/src/os/cgroup.zig @@ -19,9 +19,7 @@ pub fn current(alloc: Allocator, pid: std.os.linux.pid_t) !?[]const u8 { defer file.close(); // Read it all into memory -- we don't expect this file to ever be that large. - var reader_buf: [4096]u8 = undefined; - var reader = file.reader(&reader_buf); - const contents = try reader.interface.readAlloc( + const contents = try file.readToEndAlloc( alloc, 1 * 1024 * 1024, // 1MB ); From d9de5909d9b3f95c3a91ed008d2246eb1aebcd99 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Oct 2025 20:55:12 -0500 Subject: [PATCH 020/702] linux cgroup: also fix controllers() This fix was found by Claude Code, but I manually reviewed this change and removed extraneous changes made by the AI tool. Co-authored-by: moderation --- src/os/cgroup.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig index 761386a2f..4b5ccc4d3 100644 --- a/src/os/cgroup.zig +++ b/src/os/cgroup.zig @@ -185,9 +185,7 @@ pub fn controllers(alloc: Allocator, cgroup: []const u8) ![]const u8 { // Read it all into memory -- we don't expect this file to ever // be that large. - var reader_buf: [4096]u8 = undefined; - var reader = file.reader(&reader_buf); - const contents = try reader.interface.readAlloc( + const contents = try file.readToEndAlloc( alloc, 1 * 1024 * 1024, // 1MB ); From a73a67d252678fafc7cfbb8cb0333434b01af01c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Oct 2025 19:59:58 -0700 Subject: [PATCH 021/702] doxygen improvements --- Doxyfile | 45 ++++++++++++++++++ DoxygenLayout.xml | 28 ++--------- dist/doxygen/favicon.png | Bin 0 -> 1562 bytes dist/doxygen/ghostty.css | 99 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 dist/doxygen/favicon.png create mode 100644 dist/doxygen/ghostty.css diff --git a/Doxyfile b/Doxyfile index fccd4a493..d0c0414dc 100644 --- a/Doxyfile +++ b/Doxyfile @@ -2,9 +2,38 @@ DOXYFILE_ENCODING = UTF-8 PROJECT_NAME = "libghostty" +PROJECT_LOGO = images/gnome/64.png INPUT = include/ghostty/vt.h INPUT_ENCODING = UTF-8 RECURSIVE = NO +FULL_PATH_NAMES = NO +STRIP_FROM_INC_PATH = include +SOURCE_BROWSER = YES +INLINE_SOURCES = NO +REFERENCES_RELATION = YES +REFERENCED_BY_RELATION = YES + +#--------------------------------------------------------------------------- +# C API Optimization +#--------------------------------------------------------------------------- + +# Optimize output for C API documentation +OPTIMIZE_OUTPUT_FOR_C = YES +TYPEDEF_HIDES_STRUCT = YES +HIDE_SCOPE_NAMES = YES + +# Clean path names +FULL_PATH_NAMES = NO +STRIP_FROM_PATH = . +STRIP_FROM_INC_PATH = include + +# Hide undocumented and internal APIs +HIDE_UNDOC_MEMBERS = YES +HIDE_UNDOC_CLASSES = YES +EXTRACT_ALL = NO +INTERNAL_DOCS = NO +EXTRACT_PRIVATE = NO +EXTRACT_LOCAL_CLASSES = NO #--------------------------------------------------------------------------- # HTML Output @@ -12,6 +41,21 @@ RECURSIVE = NO GENERATE_HTML = YES HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty +HTML_EXTRA_STYLESHEET = dist/doxygen/ghostty.css +HTML_EXTRA_FILES = dist/doxygen/favicon.png +HTML_COLORSTYLE = DARK +LAYOUT_FILE = DoxygenLayout.xml +GENERATE_TREEVIEW = YES +HTML_DYNAMIC_SECTIONS = YES +SEARCHENGINE = YES +ALPHABETICAL_INDEX = YES +HTML_TIMESTAMP = NO + +#--------------------------------------------------------------------------- +# Graphs and Diagrams +#--------------------------------------------------------------------------- + +HAVE_DOT = NO #--------------------------------------------------------------------------- # Man Output @@ -20,6 +64,7 @@ HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty GENERATE_MAN = YES MAN_OUTPUT = zig-out/share/man MAN_EXTENSION = .3 +MAN_LINKS = YES #--------------------------------------------------------------------------- # Other Output diff --git a/DoxygenLayout.xml b/DoxygenLayout.xml index 67497e83f..ae9c52684 100644 --- a/DoxygenLayout.xml +++ b/DoxygenLayout.xml @@ -6,37 +6,15 @@ - + - - - - - - - - - - - - - - - - - - + - - - - - - + diff --git a/dist/doxygen/favicon.png b/dist/doxygen/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..b647bcf358546e6f9b54c9c423579db9205f2bcc GIT binary patch literal 1562 zcmV+#2IcvQP)A#`s<*=)YsQ{)B5%6hXw`)-q+sVUKWwiT8oGXAiiystqZVz zkj~G~S5BQeb>`5aLnCL;o_+he0e0-z@x9^U;co%K#fy`~am`N)jlLF(AT(2j3EnB|!-R}&u_a{#k@Rws_V{cqFfy0Ln|LX2ltG;m4vSoBG zy_>;RU*_Dz4E?t+=ib$~5~zST#{S7`e>}zFMMc`82xDn10vbk$3V?{Q(F6SF+#eVp zKf~zgi9_qwt=n+b1p52?2Y>tOaR7Sz?%?!WZ}PeOKf%s#f0A}jTcw{T_LbTD^JnQ< z7(wnSz9w=SH!c**EL!@02k^UBU*>~%-Zj{C3(_<#)r$^ba%PfyKDv_6tX;{!XDvDq zk_2w7B)Uqib_sx~sf(B_D>Vg(<5=lL128{7 z$Lcj7V|M-$9~s!dBYS>`GATcP={MZ_+57nT+BF<~;V9L34iH}x`6+aL0Yrt=lFNWl zpSxPCsipwcYE{LtFSepkqsUxb<>uu}n72igjR?w%nNHfs!hnti5%X67u?kV*mrdRR z1fh_mDF9mQx`%BAfKC!XNV9~Q=^3ia`}oGbXNVP~8g5&CJ5dO~Kl(RBidYID3Q!>a zUXc7;pwow0VGL+P7@#?H34&%FVnuHm$xr~Xiy3&m!PW$3hS zj3x*pL*E(fu^<*uC}J{{+k%z=wK(uI^g?D3EKZob=ids+;F7nw@| z2@y$r0*R}cRsc3l-N%TO5sAR2SQn$p0i2Zh;aZyQFvkTmyZZen1=Y3??-rIlH@wJKqo!{2M}PzuM}}c4!{b4AG_@HG1vxk zKq3UeUqp2PX_bKc$N{JVA`0NV8e`B}r%eGWmCEG8&fZ(#1lFcV(1XpU0cmcIZ2+=(<+UI| zHzZTQ1f5+Ua2`zoPM$n@?9=ys>cfE?A^z8X)Ut0P;f-LkhCNk{9u4mWe0n*8D6EO8 zoqrld#;v!l76a2)O#qla@bptd4?g(SV-G*Pnb-gHB2!Z*Fqy?Ro_FwLT0jXwAoTRy zz+J08PgLw+-%t0hb>__z@Kj+qI5af0ZN-WeYr-%nTWj(D{!d5V36Q@N5zkQXE7B~R z9UmV*J~A@$XqIJvX@cBT? Date: Sun, 5 Oct 2025 20:18:47 -0700 Subject: [PATCH 022/702] lib-vt: fix dockerfile to include assets for web --- src/build/docker/lib-c-docs/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index 5667c6607..6522ca4f6 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -10,6 +10,8 @@ RUN apk add --no-cache \ graphviz WORKDIR /ghostty COPY include/ ./include/ +COPY images/ ./images/ +COPY dist/doxygen/ ./dist/doxygen/ COPY Doxyfile ./ COPY DoxygenLayout.xml ./ RUN mkdir -p zig-out/share/ghostty/doc/libghostty From 6ef0be758043d18f6ca9374ea0c4158da62c4dbd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Oct 2025 20:26:17 -0700 Subject: [PATCH 023/702] libghostty website: update to use arch for doxygen for latest --- src/build/docker/lib-c-docs/Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index 6522ca4f6..8b4f2f6df 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -1,13 +1,15 @@ #-------------------------------------------------------------------- # Generate documentation with Doxygen #-------------------------------------------------------------------- -FROM alpine:latest AS builder +FROM --platform=linux/amd64 archlinux:latest AS builder # Build argument for noindex header ARG ADD_NOINDEX_HEADER=false -RUN apk add --no-cache \ +RUN pacman -Syu --noconfirm && \ + pacman -S --noconfirm \ doxygen \ - graphviz + graphviz && \ + pacman -Scc --noconfirm WORKDIR /ghostty COPY include/ ./include/ COPY images/ ./images/ From 21d545c3b46de4242250929b58bac4b06b587525 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Oct 2025 20:28:33 -0700 Subject: [PATCH 024/702] doxygen prettier --- dist/doxygen/ghostty.css | 83 ++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/dist/doxygen/ghostty.css b/dist/doxygen/ghostty.css index cac21e689..f9f1bd6d7 100644 --- a/dist/doxygen/ghostty.css +++ b/dist/doxygen/ghostty.css @@ -4,96 +4,115 @@ */ /* Ghostty brand color for links and accents - high contrast for dark bg */ -a, a:link { - color: #99B3FF; +a, +a:link { + color: #99b3ff; } a:visited { - color: #99B3FF; + color: #99b3ff; } a:hover { - color: #C2D4FF; + color: #c2d4ff; } /* High contrast text colors */ -body, div.contents, div.header, .title, .summary, td, th, p, li { - color: #E8E8E8 !important; +body, +div.contents, +div.header, +.title, +.summary, +td, +th, +p, +li { + color: #e8e8e8 !important; } -h1, h2, h3, h4, h5, h6, .groupheader { - color: #FFFFFF !important; +h1, +h2, +h3, +h4, +h5, +h6, +.groupheader { + color: #ffffff !important; } -.memtitle, .memname { - color: #FFFFFF !important; +.memtitle, +.memname { + color: #ffffff !important; } .memdoc { - color: #E8E8E8 !important; + color: #e8e8e8 !important; } /* Selection color */ ::selection { - background: rgba(53, 81, 243, 0.6); + background: rgba(53, 81, 243, 0.6); } /* Tree view selected item */ #nav-tree .selected { - background-color: #3551F3 !important; + background-color: #3551f3 !important; } /* Custom syntax highlighting optimized for dark backgrounds with high contrast */ -.fragment, div.line { - color: #F0F0F0 !important; +.fragment, +div.line { + color: #f0f0f0 !important; } /* Keywords (int, void, const, static, etc.) */ -.keyword, .keywordtype { - color: #FF8BE6 !important; - font-weight: 500; +.keyword, +.keywordtype { + color: #ff8be6 !important; + font-weight: 500; } /* Control flow (if, else, return, for, while, etc.) */ .keywordflow { - color: #FF8BE6 !important; - font-weight: 500; + color: #ff8be6 !important; + font-weight: 500; } /* Comments */ .comment { - color: #8BC34A !important; - font-style: italic; + color: #8bc34a !important; + font-style: italic; } /* Preprocessor directives (#include, #define, etc.) */ .preprocessor { - color: #FFCC66 !important; + color: #ffcc66 !important; } /* String and character literals */ -.stringliteral, .charliteral { - color: #B8E986 !important; +.stringliteral, +.charliteral { + color: #b8e986 !important; } /* Numbers */ span.charliteral { - color: #D4A5FF !important; + color: #d4a5ff !important; } /* Function names */ .functionname { - color: #6FE87C !important; - font-weight: 500; + color: #6fe87c !important; + font-weight: 500; } /* Line numbers */ span.lineno { - color: #8A8A8A !important; - background-color: transparent !important; + color: #8a8a8a !important; + background-color: transparent !important; } span.lineno a { - color: #8A8A8A !important; - background-color: transparent !important; + color: #8a8a8a !important; + background-color: transparent !important; } From 86421c9e091df5972da3f374df2276e51022e7b9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Oct 2025 20:36:20 -0700 Subject: [PATCH 025/702] lib-vt: trying to fix up hosted docs --- Doxyfile | 1 + src/build/docker/lib-c-docs/entrypoint.sh | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/Doxyfile b/Doxyfile index d0c0414dc..7c9a9c689 100644 --- a/Doxyfile +++ b/Doxyfile @@ -44,6 +44,7 @@ HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty HTML_EXTRA_STYLESHEET = dist/doxygen/ghostty.css HTML_EXTRA_FILES = dist/doxygen/favicon.png HTML_COLORSTYLE = DARK +HTML_CODE_FOLDING = NO LAYOUT_FILE = DoxygenLayout.xml GENERATE_TREEVIEW = YES HTML_DYNAMIC_SECTIONS = YES diff --git a/src/build/docker/lib-c-docs/entrypoint.sh b/src/build/docker/lib-c-docs/entrypoint.sh index 928d6e163..67cc44b03 100755 --- a/src/build/docker/lib-c-docs/entrypoint.sh +++ b/src/build/docker/lib-c-docs/entrypoint.sh @@ -7,10 +7,22 @@ server { root /usr/share/nginx/html; index index.html; add_header X-Robots-Tag "noindex, nofollow" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always; } } EOF # Remove default server config rm -f /etc/nginx/conf.d/default.conf +else + cat > /etc/nginx/conf.d/default.conf << 'EOF' +server { + listen 80; + location / { + root /usr/share/nginx/html; + index index.html; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always; + } +} +EOF fi exec nginx -g "daemon off;" From d2ee80bc49dd0aaabcffed2cf4d9c3ef8cd90c02 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Oct 2025 09:39:50 -0500 Subject: [PATCH 026/702] gtk: use std.Io.Writer to generate runtime CSS --- src/apprt/gtk/class/application.zig | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index af56130d3..07663fec9 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -806,19 +806,19 @@ pub const Application = extern struct { } } - fn loadRuntimeCss(self: *Self) Allocator.Error!void { + fn loadRuntimeCss(self: *Self) (Allocator.Error || std.Io.Writer.Error)!void { const alloc = self.allocator(); const config = self.private().config.get(); - var buf: std.ArrayListUnmanaged(u8) = try .initCapacity(alloc, 2048); - defer buf.deinit(alloc); + var buf: std.Io.Writer.Allocating = try .initCapacity(alloc, 2048); + defer buf.deinit(); - const writer = buf.writer(alloc); + const writer = &buf.writer; // Load standard css first as it can override some of the user configured styling. - try loadRuntimeCss414(config, &writer); - try loadRuntimeCss416(config, &writer); + try loadRuntimeCss414(config, writer); + try loadRuntimeCss416(config, writer); const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background; @@ -861,7 +861,8 @@ pub const Application = extern struct { // ensure that we have a sentinel try writer.writeByte(0); - const data = buf.items[0 .. buf.items.len - 1 :0]; + const data_ = buf.written(); + const data = data_[0 .. data_.len - 1 :0]; log.debug("runtime CSS is {d} bytes", .{data.len + 1}); @@ -875,8 +876,8 @@ pub const Application = extern struct { /// Load runtime CSS for older than GTK 4.16 fn loadRuntimeCss414( config: *const CoreConfig, - writer: *const std.ArrayListUnmanaged(u8).Writer, - ) Allocator.Error!void { + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { if (gtk_version.runtimeAtLeast(4, 16, 0)) return; const window_theme = config.@"window-theme"; @@ -911,8 +912,8 @@ pub const Application = extern struct { /// Load runtime for GTK 4.16 and newer fn loadRuntimeCss416( config: *const CoreConfig, - writer: *const std.ArrayListUnmanaged(u8).Writer, - ) Allocator.Error!void { + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { if (gtk_version.runtimeUntil(4, 16, 0)) return; const window_theme = config.@"window-theme"; @@ -1044,9 +1045,7 @@ pub const Application = extern struct { defer file.close(); log.info("loading gtk-custom-css path={s}", .{path}); - var buf: [4096]u8 = undefined; - var reader = file.reader(&buf); - const contents = try reader.interface.readAlloc( + const contents = try file.readToEndAlloc( alloc, 5 * 1024 * 1024, // 5MB, ); @@ -1164,7 +1163,7 @@ pub const Application = extern struct { // just stuck with the old CSS but we don't want to fail the entire // config change operation. self.loadRuntimeCss() catch |err| switch (err) { - error.OutOfMemory => log.warn( + error.WriteFailed, error.OutOfMemory => log.warn( "out of memory loading runtime CSS, no runtime CSS applied", .{}, ), From 76d9d731f0b1cc6bb900583c1d6844cd7cbeccc1 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Oct 2025 09:53:10 -0500 Subject: [PATCH 027/702] update to the latest zf --- build.zig.zon | 4 ++-- build.zig.zon.json | 26 ++++++++------------------ build.zig.zon.nix | 38 +++++++++++--------------------------- build.zig.zon.txt | 6 ++---- flatpak/zig-packages.json | 30 +++++++++--------------------- 5 files changed, 32 insertions(+), 72 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 4039c6bdd..71e7cd1b4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -50,8 +50,8 @@ }, .zf = .{ // natecraddock/zf - .url = "https://deps.files.ghostty.org/zf-52ad2e5528ab754f77437edf08a07b5ec843661c.tar.gz", - .hash = "zf-0.10.3-OIRy8QGJAACJcu3tCGtfbJnnd3Y4QL7OW_X8PJ8u_ASR", + .url = "https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.tar.gz", + .hash = "zf-0.10.3-OIRy8euIAACn1wj5gnZbqxbAdAKI5JGMeex8zrdPqORg", .lazy = true, }, .gobject = .{ diff --git a/build.zig.zon.json b/build.zig.zon.json index ad9763f62..b587ee5ef 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -114,16 +114,16 @@ "url": "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz", "hash": "sha256-VomSYOF8fRJwb/8GtVG/QqR6c95zSkQt4649C/4KXAc=" }, + "vaxis-0.5.1-BWNV_BMgCQDXdZzABeY4F_xwgE7nHFtYEP07KgEwJWo8": { + "name": "vaxis", + "url": "git+https://github.com/rockorager/libvaxis#6eb16bb4190dc074dafaf4f0ce7dadd50e81192a", + "hash": "sha256-5c+TjmiH4071lKI+U8SIJ0M+4ezzcAtLI2ZSfZYdXSA=" + }, "vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ": { "name": "vaxis", "url": "https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz", "hash": "sha256-7H5a0J7uUsrzlO7JNAf/Ussi9WxvmsbyJSmhqvl+rqI=" }, - "vaxis-0.5.1-BWNV_H0PCQAeMusmtLzh9P9xO2IW242GZ2IRe9iKYhcA": { - "name": "vaxis", - "url": "https://github.com/rockorager/libvaxis/archive/1bf887aa7e3736bad69fd4e277a378946edb0f2a.tar.gz", - "hash": "sha256-eq5YC26OY0i2cdQJ0ZXMZ+o2vHQLEFNNGzQt5Zuz4BM=" - }, "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t": { "name": "wayland", "url": "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", @@ -144,15 +144,10 @@ "url": "https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz", "hash": "sha256-5/qRZAIh1U42v7jql9W0jr2zzQZtu39DxJPLVrSybJg=" }, - "zf-0.10.3-OIRy8QGJAACJcu3tCGtfbJnnd3Y4QL7OW_X8PJ8u_ASR": { + "zf-0.10.3-OIRy8euIAACn1wj5gnZbqxbAdAKI5JGMeex8zrdPqORg": { "name": "zf", - "url": "https://deps.files.ghostty.org/zf-52ad2e5528ab754f77437edf08a07b5ec843661c.tar.gz", - "hash": "sha256-8BinbanSfZeBA8SBAopVxwJObN36/BTpxVHABKicsMQ=" - }, - "zg-0.14.1-oGqU3J4_tAKBfyes3AWleKDjo-IcYvnEwaB8qxOqFMwM": { - "name": "zg", - "url": "git+https://codeberg.org/ivanstepanovftw/zg#4fe689e56ce2ed5a8f59308b471bccd7da89fac9", - "hash": "sha256-P0ieLuOQ05wKVaMmeNKJIxCWMIdyeKkmhsj8Ps80BGU=" + "url": "https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.tar.gz", + "hash": "sha256-Kaduui4Llb9Fq1lCKLOeG8cWTknjhos8cSTeJ50KP/I=" }, "zg-0.15.1-oGqU3M0-tALZCy7boQS86znlBloyKx6--JriGlY0Paa9": { "name": "zg", @@ -174,11 +169,6 @@ "url": "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz", "hash": "sha256-TxRrc17Q1Sf1IOO/cdPpP3LD0PpYOujt06SFH3B5Ek4=" }, - "zigimg-0.1.0-8_eo2mWmEgBoqdr0sH9O5GTqDHthkoEPM5_tipcBRreL": { - "name": "zigimg", - "url": "git+https://github.com/ivanstepanovftw/zigimg#aa4c31db872612c39edbb79f753b3cd9a79fe726", - "hash": "sha256-Ko5RuxxTAvpUHCnWEdHqNl7b+PVUAxg1/OPmzGGjdt0=" - }, "zigimg-0.1.0-8_eo2vHnEwCIVW34Q14Ec-xUlzIoVg86-7FU2ypPtxms": { "name": "zigimg", "url": "https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 9d189cfdc..dd59e709a 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -266,6 +266,14 @@ in hash = "sha256-VomSYOF8fRJwb/8GtVG/QqR6c95zSkQt4649C/4KXAc="; }; } + { + name = "vaxis-0.5.1-BWNV_BMgCQDXdZzABeY4F_xwgE7nHFtYEP07KgEwJWo8"; + path = fetchZigArtifact { + name = "vaxis"; + url = "git+https://github.com/rockorager/libvaxis#6eb16bb4190dc074dafaf4f0ce7dadd50e81192a"; + hash = "sha256-5c+TjmiH4071lKI+U8SIJ0M+4ezzcAtLI2ZSfZYdXSA="; + }; + } { name = "vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ"; path = fetchZigArtifact { @@ -274,14 +282,6 @@ in hash = "sha256-7H5a0J7uUsrzlO7JNAf/Ussi9WxvmsbyJSmhqvl+rqI="; }; } - { - name = "vaxis-0.5.1-BWNV_H0PCQAeMusmtLzh9P9xO2IW242GZ2IRe9iKYhcA"; - path = fetchZigArtifact { - name = "vaxis"; - url = "https://github.com/rockorager/libvaxis/archive/1bf887aa7e3736bad69fd4e277a378946edb0f2a.tar.gz"; - hash = "sha256-eq5YC26OY0i2cdQJ0ZXMZ+o2vHQLEFNNGzQt5Zuz4BM="; - }; - } { name = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t"; path = fetchZigArtifact { @@ -315,19 +315,11 @@ in }; } { - name = "zf-0.10.3-OIRy8QGJAACJcu3tCGtfbJnnd3Y4QL7OW_X8PJ8u_ASR"; + name = "zf-0.10.3-OIRy8euIAACn1wj5gnZbqxbAdAKI5JGMeex8zrdPqORg"; path = fetchZigArtifact { name = "zf"; - url = "https://deps.files.ghostty.org/zf-52ad2e5528ab754f77437edf08a07b5ec843661c.tar.gz"; - hash = "sha256-8BinbanSfZeBA8SBAopVxwJObN36/BTpxVHABKicsMQ="; - }; - } - { - name = "zg-0.14.1-oGqU3J4_tAKBfyes3AWleKDjo-IcYvnEwaB8qxOqFMwM"; - path = fetchZigArtifact { - name = "zg"; - url = "git+https://codeberg.org/ivanstepanovftw/zg#4fe689e56ce2ed5a8f59308b471bccd7da89fac9"; - hash = "sha256-P0ieLuOQ05wKVaMmeNKJIxCWMIdyeKkmhsj8Ps80BGU="; + url = "https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.tar.gz"; + hash = "sha256-Kaduui4Llb9Fq1lCKLOeG8cWTknjhos8cSTeJ50KP/I="; }; } { @@ -362,14 +354,6 @@ in hash = "sha256-TxRrc17Q1Sf1IOO/cdPpP3LD0PpYOujt06SFH3B5Ek4="; }; } - { - name = "zigimg-0.1.0-8_eo2mWmEgBoqdr0sH9O5GTqDHthkoEPM5_tipcBRreL"; - path = fetchZigArtifact { - name = "zigimg"; - url = "git+https://github.com/ivanstepanovftw/zigimg#aa4c31db872612c39edbb79f753b3cd9a79fe726"; - hash = "sha256-Ko5RuxxTAvpUHCnWEdHqNl7b+PVUAxg1/OPmzGGjdt0="; - }; - } { name = "zigimg-0.1.0-8_eo2vHnEwCIVW34Q14Ec-xUlzIoVg86-7FU2ypPtxms"; path = fetchZigArtifact { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index c3727f1e5..9db796546 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -1,5 +1,4 @@ -git+https://codeberg.org/ivanstepanovftw/zg#4fe689e56ce2ed5a8f59308b471bccd7da89fac9 -git+https://github.com/ivanstepanovftw/zigimg#aa4c31db872612c39edbb79f753b3cd9a79fe726 +git+https://github.com/rockorager/libvaxis#6eb16bb4190dc074dafaf4f0ce7dadd50e81192a https://codeberg.org/chaten/zg/archive/749197a3f9d25e211615960c02380a3d659b20f9.tar.gz https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz @@ -28,11 +27,10 @@ https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d. https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz -https://deps.files.ghostty.org/zf-52ad2e5528ab754f77437edf08a07b5ec843661c.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz 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/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz -https://github.com/rockorager/libvaxis/archive/1bf887aa7e3736bad69fd4e277a378946edb0f2a.tar.gz +https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 1eba46fc2..eb8f57028 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -137,18 +137,18 @@ "dest": "vendor/p/uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT", "sha256": "56899260e17c7d12706fff06b551bf42a47a73de734a442de3ae3d0bfe0a5c07" }, + { + "type": "git", + "url": "https://github.com/rockorager/libvaxis", + "commit": "6eb16bb4190dc074dafaf4f0ce7dadd50e81192a", + "dest": "vendor/p/vaxis-0.5.1-BWNV_BMgCQDXdZzABeY4F_xwgE7nHFtYEP07KgEwJWo8" + }, { "type": "archive", "url": "https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz", "dest": "vendor/p/vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ", "sha256": "ec7e5ad09eee52caf394eec93407ff52cb22f56c6f9ac6f22529a1aaf97eaea2" }, - { - "type": "archive", - "url": "https://github.com/rockorager/libvaxis/archive/1bf887aa7e3736bad69fd4e277a378946edb0f2a.tar.gz", - "dest": "vendor/p/vaxis-0.5.1-BWNV_H0PCQAeMusmtLzh9P9xO2IW242GZ2IRe9iKYhcA", - "sha256": "7aae580b6e8e6348b671d409d195cc67ea36bc740b10534d1b342de59bb3e013" - }, { "type": "archive", "url": "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", @@ -175,15 +175,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/zf-52ad2e5528ab754f77437edf08a07b5ec843661c.tar.gz", - "dest": "vendor/p/zf-0.10.3-OIRy8QGJAACJcu3tCGtfbJnnd3Y4QL7OW_X8PJ8u_ASR", - "sha256": "f018a76da9d27d978103c481028a55c7024e6cddfafc14e9c551c004a89cb0c4" - }, - { - "type": "git", - "url": "https://codeberg.org/ivanstepanovftw/zg", - "commit": "4fe689e56ce2ed5a8f59308b471bccd7da89fac9", - "dest": "vendor/p/zg-0.14.1-oGqU3J4_tAKBfyes3AWleKDjo-IcYvnEwaB8qxOqFMwM" + "url": "https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.tar.gz", + "dest": "vendor/p/zf-0.10.3-OIRy8euIAACn1wj5gnZbqxbAdAKI5JGMeex8zrdPqORg", + "sha256": "29a76eba2e0b95bf45ab594228b39e1bc7164e49e3868b3c7124de279d0a3ff2" }, { "type": "archive", @@ -209,12 +203,6 @@ "dest": "vendor/p/wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe", "sha256": "4f146b735ed0d527f520e3bf71d3e93f72c3d0fa583ae8edd3a4851f7079124e" }, - { - "type": "git", - "url": "https://github.com/ivanstepanovftw/zigimg", - "commit": "aa4c31db872612c39edbb79f753b3cd9a79fe726", - "dest": "vendor/p/zigimg-0.1.0-8_eo2mWmEgBoqdr0sH9O5GTqDHthkoEPM5_tipcBRreL" - }, { "type": "archive", "url": "https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz", From 992e9e2a6e282ef44be5d2371ddc4e69223c651d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Oct 2025 08:14:27 -0700 Subject: [PATCH 028/702] doxygen: mobile styling --- Doxyfile | 6 +- dist/doxygen/footer.html | 18 + dist/doxygen/ghostty.css | 222 ++++ dist/doxygen/header.html | 77 ++ dist/doxygen/mobile-nav.js | 65 + dist/doxygen/stylesheet.css | 2461 +++++++++++++++++++++++++++++++++++ 6 files changed, 2848 insertions(+), 1 deletion(-) create mode 100644 dist/doxygen/footer.html create mode 100644 dist/doxygen/header.html create mode 100644 dist/doxygen/mobile-nav.js create mode 100644 dist/doxygen/stylesheet.css diff --git a/Doxyfile b/Doxyfile index 7c9a9c689..0c8688987 100644 --- a/Doxyfile +++ b/Doxyfile @@ -42,15 +42,19 @@ EXTRACT_LOCAL_CLASSES = NO GENERATE_HTML = YES HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty HTML_EXTRA_STYLESHEET = dist/doxygen/ghostty.css -HTML_EXTRA_FILES = dist/doxygen/favicon.png +HTML_EXTRA_FILES = dist/doxygen/favicon.png \ + dist/doxygen/mobile-nav.js HTML_COLORSTYLE = DARK HTML_CODE_FOLDING = NO +HTML_HEADER = dist/doxygen/header.html LAYOUT_FILE = DoxygenLayout.xml GENERATE_TREEVIEW = YES HTML_DYNAMIC_SECTIONS = YES SEARCHENGINE = YES ALPHABETICAL_INDEX = YES HTML_TIMESTAMP = NO +DISABLE_INDEX = NO +FULL_SIDEBAR = NO #--------------------------------------------------------------------------- # Graphs and Diagrams diff --git a/dist/doxygen/footer.html b/dist/doxygen/footer.html new file mode 100644 index 000000000..fca4b87d9 --- /dev/null +++ b/dist/doxygen/footer.html @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/dist/doxygen/ghostty.css b/dist/doxygen/ghostty.css index f9f1bd6d7..9670a70ca 100644 --- a/dist/doxygen/ghostty.css +++ b/dist/doxygen/ghostty.css @@ -116,3 +116,225 @@ span.lineno a { color: #8a8a8a !important; background-color: transparent !important; } + +/* Desktop: ensure page-nav maintains default width */ +@media screen and (min-width: 768px) { + #page-nav-toggle { + display: none !important; + } + + #page-nav { + position: relative !important; + width: 250px !important; + height: auto !important; + right: auto !important; + top: auto !important; + box-shadow: none !important; + } +} + +/* Mobile-friendly responsive styles */ +@media screen and (max-width: 767px) { + body { + font-size: 14px !important; + } + + /* Make navigation tree collapsible on mobile */ + #side-nav { + display: none; + } + + #doc-content { + margin-left: 0 !important; + margin-right: 0 !important; + } + + /* Make right sidebar (page-nav) overlay on mobile */ + #page-nav { + position: fixed !important; + top: 0 !important; + right: -280px !important; + width: 280px !important; + height: 100vh !important; + z-index: 10000 !important; + background: #101826 !important; + box-shadow: -2px 0 10px rgba(0, 0, 0, 0.5) !important; + transition: right 0.3s ease !important; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch !important; + } + + #page-nav.mobile-open { + right: 0 !important; + } + + /* Hamburger menu button for page nav */ + #page-nav-toggle { + display: block !important; + position: fixed !important; + top: 10px !important; + right: 15px !important; + z-index: 10001 !important; + width: 40px !important; + height: 40px !important; + background: rgba(53, 81, 243, 0.9) !important; + border: none !important; + border-radius: 5px !important; + cursor: pointer !important; + padding: 8px !important; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3) !important; + } + + #page-nav-toggle span { + display: block !important; + width: 24px !important; + height: 3px !important; + background: #fff !important; + margin: 4px 0 !important; + border-radius: 2px !important; + transition: 0.3s !important; + } + + /* Mobile overlay backdrop */ + #page-nav-backdrop { + display: none !important; + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + background: rgba(0, 0, 0, 0.5) !important; + z-index: 9999 !important; + } + + #page-nav-backdrop.active { + display: block !important; + } + + /* Improve header and navigation */ + #top { + height: auto !important; + } + + #titlearea { + padding: 10px !important; + } + + #projectname { + font-size: 18px !important; + } + + #projectbrief, + #projectnumber { + font-size: 12px !important; + } + + /* Make tabs stack better on mobile */ + #navrow1, + #navrow2, + #navrow3, + #navrow4 { + overflow-x: auto !important; + white-space: nowrap !important; + -webkit-overflow-scrolling: touch !important; + } + + .tablist li { + display: inline-block !important; + } + + /* Content adjustments */ + .contents { + padding: 10px !important; + width: 100% !important; + box-sizing: border-box !important; + } + + .header { + padding: 5px !important; + } + + /* Code blocks */ + .fragment { + font-size: 12px !important; + overflow-x: auto !important; + -webkit-overflow-scrolling: touch !important; + } + + div.line { + font-size: 12px !important; + } + + /* Tables */ + table { + display: block !important; + overflow-x: auto !important; + -webkit-overflow-scrolling: touch !important; + width: 100% !important; + } + + .memberdecls table, + .fieldtable { + font-size: 12px !important; + } + + .memtitle { + font-size: 14px !important; + padding: 8px !important; + } + + .memname { + font-size: 13px !important; + word-break: break-word !important; + } + + .memitem { + margin: 5px 0 !important; + } + + /* Search box */ + #MSearchBox { + width: 100% !important; + right: 0 !important; + } + + /* Reduce padding and margins */ + h1, h2, h3, h4, h5, h6 { + margin-top: 10px !important; + margin-bottom: 8px !important; + } + + h1 { font-size: 22px !important; } + h2 { font-size: 18px !important; } + h3 { font-size: 16px !important; } + h4 { font-size: 14px !important; } + + /* Directory/file listings */ + .directory .levels span { + display: none !important; + } + + .directory .arrow { + margin-right: 5px !important; + } + + /* Treeview adjustments */ + #nav-tree { + width: 100% !important; + } +} + +/* Tablet adjustments */ +@media screen and (min-width: 768px) and (max-width: 1024px) { + .contents { + padding: 15px !important; + } + + #side-nav { + width: 200px !important; + } + + #doc-content { + margin-left: 200px !important; + } +} diff --git a/dist/doxygen/header.html b/dist/doxygen/header.html new file mode 100644 index 000000000..223ec4953 --- /dev/null +++ b/dist/doxygen/header.html @@ -0,0 +1,77 @@ + + + + + + + + +$projectname: $title +$title + + + + + + + + + + + + +$treeview +$search +$mathjax +$darkmode + +$extrastylesheet + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
$projectname $projectnumber +
+
$projectbrief
+
+
$projectbrief
+
$searchbox
$searchbox
+
+ + diff --git a/dist/doxygen/mobile-nav.js b/dist/doxygen/mobile-nav.js new file mode 100644 index 000000000..1262360b8 --- /dev/null +++ b/dist/doxygen/mobile-nav.js @@ -0,0 +1,65 @@ +/** + * Mobile navigation toggle for Doxygen documentation + */ + +(function() { + // Only run on mobile devices + function isMobile() { + return window.innerWidth <= 767; + } + + function initMobileNav() { + if (!isMobile()) return; + + const pageNav = document.getElementById('page-nav'); + if (!pageNav) return; + + // Create toggle button + const toggleBtn = document.createElement('button'); + toggleBtn.id = 'page-nav-toggle'; + toggleBtn.setAttribute('aria-label', 'Toggle page navigation'); + toggleBtn.innerHTML = ''; + document.body.appendChild(toggleBtn); + + // Create backdrop + const backdrop = document.createElement('div'); + backdrop.id = 'page-nav-backdrop'; + document.body.appendChild(backdrop); + + // Toggle function + function toggleNav() { + const isOpen = pageNav.classList.toggle('mobile-open'); + backdrop.classList.toggle('active', isOpen); + document.body.style.overflow = isOpen ? 'hidden' : ''; + } + + // Event listeners + toggleBtn.addEventListener('click', toggleNav); + backdrop.addEventListener('click', toggleNav); + + // Close on escape key + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && pageNav.classList.contains('mobile-open')) { + toggleNav(); + } + }); + } + + // Initialize on load and resize + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initMobileNav); + } else { + initMobileNav(); + } + + window.addEventListener('resize', function() { + const pageNav = document.getElementById('page-nav'); + const backdrop = document.getElementById('page-nav-backdrop'); + + if (!isMobile() && pageNav) { + pageNav.classList.remove('mobile-open'); + if (backdrop) backdrop.classList.remove('active'); + document.body.style.overflow = ''; + } + }); +})(); diff --git a/dist/doxygen/stylesheet.css b/dist/doxygen/stylesheet.css new file mode 100644 index 000000000..5d2eecd6d --- /dev/null +++ b/dist/doxygen/stylesheet.css @@ -0,0 +1,2461 @@ +/* The standard CSS for doxygen 1.14.0*/ + +html { +/* page base colors */ +--page-background-color: white; +--page-foreground-color: black; +--page-link-color: #3D578C; +--page-visited-link-color: #3D578C; +--page-external-link-color: #334975; + +/* index */ +--index-odd-item-bg-color: #F8F9FC; +--index-even-item-bg-color: white; +--index-header-color: black; +--index-separator-color: #A0A0A0; + +/* header */ +--header-background-color: #F9FAFC; +--header-separator-color: #C4CFE5; +--group-header-separator-color: #D9E0EE; +--group-header-color: #354C7B; +--inherit-header-color: gray; + +--footer-foreground-color: #2A3D61; +--footer-logo-width: 75px; +--citation-label-color: #334975; +--glow-color: cyan; + +--title-background-color: white; +--title-separator-color: #C4CFE5; +--directory-separator-color: #9CAFD4; +--separator-color: #4A6AAA; + +--blockquote-background-color: #F7F8FB; +--blockquote-border-color: #9CAFD4; + +--scrollbar-thumb-color: #C4CFE5; +--scrollbar-background-color: #F9FAFC; + +--icon-background-color: #728DC1; +--icon-foreground-color: white; +/* +--icon-doc-image: url('doc.svg'); +--icon-folder-open-image: url('folderopen.svg'); +--icon-folder-closed-image: url('folderclosed.svg');*/ +--icon-folder-open-fill-color: #C4CFE5; +--icon-folder-fill-color: #D8DFEE; +--icon-folder-border-color: #4665A2; +--icon-doc-fill-color: #D8DFEE; +--icon-doc-border-color: #4665A2; + +/* brief member declaration list */ +--memdecl-background-color: #F9FAFC; +--memdecl-separator-color: #DEE4F0; +--memdecl-foreground-color: #555; +--memdecl-template-color: #4665A2; +--memdecl-border-color: #D5DDEC; + +/* detailed member list */ +--memdef-border-color: #A8B8D9; +--memdef-title-background-color: #E2E8F2; +--memdef-proto-background-color: #EEF1F7; +--memdef-proto-text-color: #253555; +--memdef-doc-background-color: white; +--memdef-param-name-color: #602020; +--memdef-template-color: #4665A2; + +/* tables */ +--table-cell-border-color: #2D4068; +--table-header-background-color: #374F7F; +--table-header-foreground-color: #FFFFFF; + +/* labels */ +--label-background-color: #728DC1; +--label-left-top-border-color: #5373B4; +--label-right-bottom-border-color: #C4CFE5; +--label-foreground-color: white; + +/** navigation bar/tree/menu */ +--nav-background-color: #F9FAFC; +--nav-foreground-color: #364D7C; +--nav-border-color: #C4CFE5; +--nav-breadcrumb-separator-color: #C4CFE5; +--nav-breadcrumb-active-bg: #EEF1F7; +--nav-breadcrumb-color: #354C7B; +--nav-breadcrumb-border-color: #E1E7F2; +--nav-splitbar-bg-color: #DCE2EF; +--nav-splitbar-handle-color: #9CAFD4; +--nav-font-size-level1: 13px; +--nav-font-size-level2: 10px; +--nav-font-size-level3: 9px; +--nav-text-normal-color: #283A5D; +--nav-text-hover-color: white; +--nav-text-active-color: white; +--nav-menu-button-color: #364D7C; +--nav-menu-background-color: white; +--nav-menu-foreground-color: #555555; +--nav-menu-active-bg: #DCE2EF; +--nav-menu-active-color: #9CAFD4; +--nav-menu-toggle-color: rgba(255, 255, 255, 0.5); +--nav-arrow-color: #B6C4DF; +--nav-arrow-selected-color: #90A5CE; + +/* sync icon */ +--sync-icon-border-color: #C4CFE5; +--sync-icon-background-color: #F9FAFC; +--sync-icon-selected-background-color: #EEF1F7; +--sync-icon-color: #C4CFE5; +--sync-icon-selected-color: #6884BD; + +/* table of contents */ +--toc-background-color: #F4F6FA; +--toc-border-color: #D8DFEE; +--toc-header-color: #4665A2; +--toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); + +/** search field */ +--search-background-color: white; +--search-foreground-color: #909090; +--search-active-color: black; +--search-filter-background-color: rgba(255,255,255,.7); +--search-filter-backdrop-filter: blur(4px); +--search-filter-foreground-color: black; +--search-filter-border-color: rgba(150,150,150,.4); +--search-filter-highlight-text-color: white; +--search-filter-highlight-bg-color: #3D578C; +--search-results-foreground-color: #425E97; +--search-results-background-color: rgba(255,255,255,.8); +--search-results-backdrop-filter: blur(4px); +--search-results-border-color: rgba(150,150,150,.4); +--search-box-border-color: #B6C4DF; +--search-close-icon-bg-color: #A0A0A0; +--search-close-icon-fg-color: white; + +/** code fragments */ +--code-keyword-color: #008000; +--code-type-keyword-color: #604020; +--code-flow-keyword-color: #E08000; +--code-comment-color: #800000; +--code-preprocessor-color: #806020; +--code-string-literal-color: #002080; +--code-char-literal-color: #008080; +--code-xml-cdata-color: black; +--code-vhdl-digit-color: #FF00FF; +--code-vhdl-char-color: #000000; +--code-vhdl-keyword-color: #700070; +--code-vhdl-logic-color: #FF0000; +--code-link-color: #4665A2; +--code-external-link-color: #4665A2; +--fragment-foreground-color: black; +--fragment-background-color: #FBFCFD; +--fragment-border-color: #C4CFE5; +--fragment-lineno-border-color: #00FF00; +--fragment-lineno-background-color: #E8E8E8; +--fragment-lineno-foreground-color: black; +--fragment-lineno-link-fg-color: #4665A2; +--fragment-lineno-link-bg-color: #D8D8D8; +--fragment-lineno-link-hover-fg-color: #4665A2; +--fragment-lineno-link-hover-bg-color: #C8C8C8; +--fragment-copy-ok-color: #2EC82E; +--tooltip-foreground-color: black; +--tooltip-background-color: rgba(255,255,255,0.8); +--tooltip-arrow-background-color: white; +--tooltip-border-color: rgba(150,150,150,0.7); +--tooltip-backdrop-filter: blur(3px); +--tooltip-doc-color: grey; +--tooltip-declaration-color: #006318; +--tooltip-link-color: #4665A2; +--tooltip-shadow: 0 4px 8px 0 rgba(0,0,0,.25); +--fold-line-color: #808080; + +/** font-family */ +--font-family-normal: system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; +--font-family-monospace: 'JetBrains Mono',Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace,fixed; +--font-family-nav: 'Lucida Grande',Geneva,Helvetica,Arial,sans-serif; +--font-family-title: system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; +--font-family-toc: Verdana,'DejaVu Sans',Geneva,sans-serif; +--font-family-search: Arial,Verdana,sans-serif; +--font-family-icon: Arial,Helvetica; +--font-family-tooltip: Roboto,sans-serif; + +/** special sections */ +--warning-color-bg: #f8d1cc; +--warning-color-hl: #b61825; +--warning-color-text: #75070f; +--note-color-bg: #faf3d8; +--note-color-hl: #f3a600; +--note-color-text: #5f4204; +--todo-color-bg: #e4f3ff; +--todo-color-hl: #1879C4; +--todo-color-text: #274a5c; +--test-color-bg: #e8e8ff; +--test-color-hl: #3939C4; +--test-color-text: #1a1a5c; +--deprecated-color-bg: #ecf0f3; +--deprecated-color-hl: #5b6269; +--deprecated-color-text: #43454a; +--bug-color-bg: #e4dafd; +--bug-color-hl: #5b2bdd; +--bug-color-text: #2a0d72; +--invariant-color-bg: #d8f1e3; +--invariant-color-hl: #44b86f; +--invariant-color-text: #265532; +} + +@media (prefers-color-scheme: dark) { + html:not(.dark-mode) { + color-scheme: dark; + +/* page base colors */ +--page-background-color: black; +--page-foreground-color: #C9D1D9; +--page-link-color: #90A5CE; +--page-visited-link-color: #90A5CE; +--page-external-link-color: #A3B4D7; + +/* index */ +--index-odd-item-bg-color: #0B101A; +--index-even-item-bg-color: black; +--index-header-color: #C4CFE5; +--index-separator-color: #334975; + +/* header */ +--header-background-color: #070B11; +--header-separator-color: #141C2E; +--group-header-separator-color: #1D2A43; +--group-header-color: #90A5CE; +--inherit-header-color: #A0A0A0; + +--footer-foreground-color: #5B7AB7; +--footer-logo-width: 60px; +--citation-label-color: #90A5CE; +--glow-color: cyan; + +--title-background-color: #090D16; +--title-separator-color: #212F4B; +--directory-separator-color: #283A5D; +--separator-color: #283A5D; + +--blockquote-background-color: #101826; +--blockquote-border-color: #283A5D; + +--scrollbar-thumb-color: #2C3F65; +--scrollbar-background-color: #070B11; + +--icon-background-color: #334975; +--icon-foreground-color: #C4CFE5; +--icon-folder-open-fill-color: #4665A2; +--icon-folder-fill-color: #5373B4; +--icon-folder-border-color: #C4CFE5; +--icon-doc-fill-color: #6884BD; +--icon-doc-border-color: #C4CFE5; + +/* brief member declaration list */ +--memdecl-background-color: #0B101A; +--memdecl-separator-color: #2C3F65; +--memdecl-foreground-color: #BBB; +--memdecl-template-color: #7C95C6; +--memdecl-border-color: #233250; + +/* detailed member list */ +--memdef-border-color: #233250; +--memdef-title-background-color: #1B2840; +--memdef-proto-background-color: #19243A; +--memdef-proto-text-color: #9DB0D4; +--memdef-doc-background-color: black; +--memdef-param-name-color: #D28757; +--memdef-template-color: #7C95C6; + +/* tables */ +--table-cell-border-color: #283A5D; +--table-header-background-color: #283A5D; +--table-header-foreground-color: #C4CFE5; + +/* labels */ +--label-background-color: #354C7B; +--label-left-top-border-color: #4665A2; +--label-right-bottom-border-color: #283A5D; +--label-foreground-color: #CCCCCC; + +/** navigation bar/tree/menu */ +--nav-background-color: #101826; +--nav-foreground-color: #364D7C; +--nav-border-color: #212F4B; +--nav-breadcrumb-separator-color: #212F4B; +--nav-breadcrumb-active-bg: #1D2A43; +--nav-breadcrumb-color: #90A5CE; +--nav-breadcrumb-border-color: #2A3D61; +--nav-splitbar-bg-color: #283A5D; +--nav-splitbar-handle-color: #4665A2; +--nav-font-size-level1: 13px; +--nav-font-size-level2: 10px; +--nav-font-size-level3: 9px; +--nav-text-normal-color: #B6C4DF; +--nav-text-hover-color: #DCE2EF; +--nav-text-active-color: #DCE2EF; +--nav-menu-button-color: #B6C4DF; +--nav-menu-background-color: #05070C; +--nav-menu-foreground-color: #BBBBBB; +--nav-menu-active-bg: #1D2A43; +--nav-menu-active-color: #C9D3E7; +--nav-menu-toggle-color: rgba(255, 255, 255, 0.2); +--nav-arrow-color: #4665A2; +--nav-arrow-selected-color: #6884BD; + +/* sync icon */ +--sync-icon-border-color: #212F4B; +--sync-icon-background-color: #101826; +--sync-icon-selected-background-color: #1D2A43; +--sync-icon-color: #4665A2; +--sync-icon-selected-color: #5373B4; + +/* table of contents */ +--toc-background-color: #151E30; +--toc-border-color: #202E4A; +--toc-header-color: #A3B4D7; +--toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); + +/** search field */ +--search-background-color: black; +--search-foreground-color: #C5C5C5; +--search-active-color: #F5F5F5; +--search-filter-background-color: #101826; +--search-filter-foreground-color: #90A5CE; +--search-filter-backdrop-filter: none; +--search-filter-border-color: #7C95C6; +--search-filter-highlight-text-color: #BCC9E2; +--search-filter-highlight-bg-color: #283A5D; +--search-results-background-color: black; +--search-results-foreground-color: #90A5CE; +--search-results-backdrop-filter: none; +--search-results-border-color: #334975; +--search-box-border-color: #334975; +--search-close-icon-bg-color: #909090; +--search-close-icon-fg-color: black; + +/** code fragments */ +--code-keyword-color: #CC99CD; +--code-type-keyword-color: #AB99CD; +--code-flow-keyword-color: #E08000; +--code-comment-color: #717790; +--code-preprocessor-color: #65CABE; +--code-string-literal-color: #7EC699; +--code-char-literal-color: #00E0F0; +--code-xml-cdata-color: #C9D1D9; +--code-vhdl-digit-color: #FF00FF; +--code-vhdl-char-color: #C0C0C0; +--code-vhdl-keyword-color: #CF53C9; +--code-vhdl-logic-color: #FF0000; +--code-link-color: #79C0FF; +--code-external-link-color: #79C0FF; +--fragment-foreground-color: #C9D1D9; +--fragment-background-color: #090D16; +--fragment-border-color: #30363D; +--fragment-lineno-border-color: #30363D; +--fragment-lineno-background-color: black; +--fragment-lineno-foreground-color: #6E7681; +--fragment-lineno-link-fg-color: #6E7681; +--fragment-lineno-link-bg-color: #303030; +--fragment-lineno-link-hover-fg-color: #8E96A1; +--fragment-lineno-link-hover-bg-color: #505050; +--fragment-copy-ok-color: #0EA80E; +--tooltip-foreground-color: #C9D1D9; +--tooltip-background-color: #202020; +--tooltip-arrow-background-color: #202020; +--tooltip-backdrop-filter: none; +--tooltip-border-color: #C9D1D9; +--tooltip-doc-color: #D9E1E9; +--tooltip-declaration-color: #20C348; +--tooltip-link-color: #79C0FF; +--tooltip-shadow: none; +--fold-line-color: #808080; + +/** font-family */ +--font-family-normal: system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; +--font-family-monospace: 'JetBrains Mono',Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace,fixed; +--font-family-nav: 'Lucida Grande',Geneva,Helvetica,Arial,sans-serif; +--font-family-title: system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; +--font-family-toc: Verdana,'DejaVu Sans',Geneva,sans-serif; +--font-family-search: Arial,Verdana,sans-serif; +--font-family-icon: Arial,Helvetica; +--font-family-tooltip: Roboto,sans-serif; + +/** special sections */ +--warning-color-bg: #2e1917; +--warning-color-hl: #ad2617; +--warning-color-text: #f5b1aa; +--note-color-bg: #3b2e04; +--note-color-hl: #f1b602; +--note-color-text: #ceb670; +--todo-color-bg: #163750; +--todo-color-hl: #1982D2; +--todo-color-text: #dcf0fa; +--test-color-bg: #121258; +--test-color-hl: #4242cf; +--test-color-text: #c0c0da; +--deprecated-color-bg: #2e323b; +--deprecated-color-hl: #738396; +--deprecated-color-text: #abb0bd; +--bug-color-bg: #2a2536; +--bug-color-hl: #7661b3; +--bug-color-text: #ae9ed6; +--invariant-color-bg: #303a35; +--invariant-color-hl: #76ce96; +--invariant-color-text: #cceed5; +}} +body { + background-color: var(--page-background-color); + color: var(--page-foreground-color); +} + +body, table, div, p, dl { + font-weight: 400; + font-size: 14px; + font-family: var(--font-family-normal); + line-height: 22px; +} + +body.resizing { + user-select: none; + -webkit-user-select: none; +} + +#doc-content { + scrollbar-width: thin; +} + +/* @group Heading Levels */ + +.title { + font-family: var(--font-family-normal); + line-height: 28px; + font-size: 160%; + font-weight: 400; + margin: 10px 2px; +} + +h1.groupheader { + font-size: 150%; +} + +h2.groupheader { + box-shadow: 12px 0 var(--page-background-color), + -12px 0 var(--page-background-color), + 12px 1px var(--group-header-separator-color), + -12px 1px var(--group-header-separator-color); + color: var(--group-header-color); + font-size: 150%; + font-weight: normal; + margin-top: 1.75em; + padding-top: 8px; + padding-bottom: 4px; + width: 100%; +} + +td h2.groupheader { + box-shadow: 13px 0 var(--page-background-color), + -13px 0 var(--page-background-color), + 13px 1px var(--group-header-separator-color), + -13px 1px var(--group-header-separator-color); +} + +h3.groupheader { + font-size: 100%; +} + +h1, h2, h3, h4, h5, h6 { + -webkit-transition: text-shadow 0.5s linear; + -moz-transition: text-shadow 0.5s linear; + -ms-transition: text-shadow 0.5s linear; + -o-transition: text-shadow 0.5s linear; + transition: text-shadow 0.5s linear; + margin-right: 15px; +} + +h1.glow, h2.glow, h3.glow, h4.glow, h5.glow, h6.glow { + text-shadow: 0 0 15px var(--glow-color); +} + +dt { + font-weight: bold; +} + +p.startli, p.startdd { + margin-top: 2px; +} + +th p.starttd, th p.intertd, th p.endtd { + font-size: 100%; + font-weight: 700; +} + +p.starttd { + margin-top: 0px; +} + +p.endli { + margin-bottom: 0px; +} + +p.enddd { + margin-bottom: 4px; +} + +p.endtd { + margin-bottom: 2px; +} + +p.interli { +} + +p.interdd { +} + +p.intertd { +} + +/* @end */ + +caption { + font-weight: bold; +} + +span.legend { + font-size: 70%; + text-align: center; +} + +h3.version { + font-size: 90%; + text-align: center; +} + +div.navtab { + margin-right: 6px; + padding-right: 6px; + text-align: right; + line-height: 110%; + background-color: var(--nav-background-color); +} + +div.navtab table { + border-spacing: 0; +} + +td.navtab { + padding-right: 6px; + padding-left: 6px; +} + +td.navtabHL { + padding-right: 6px; + padding-left: 6px; + border-radius: 0 6px 6px 0; + background-color: var(--nav-menu-active-bg); +} + +div.qindex{ + text-align: center; + width: 100%; + line-height: 140%; + font-size: 130%; + color: var(--index-separator-color); +} + +#main-menu a:focus { + outline: auto; + z-index: 10; + position: relative; +} + +dt.alphachar{ + font-size: 180%; + font-weight: bold; +} + +.alphachar a{ + color: var(--index-header-color); +} + +.alphachar a:hover, .alphachar a:visited{ + text-decoration: none; +} + +.classindex dl { + padding: 25px; + column-count:1 +} + +.classindex dd { + display:inline-block; + margin-left: 50px; + width: 90%; + line-height: 1.15em; +} + +.classindex dl.even { + background-color: var(--index-even-item-bg-color); +} + +.classindex dl.odd { + background-color: var(--index-odd-item-bg-color); +} + +@media(min-width: 1120px) { + .classindex dl { + column-count:2 + } +} + +@media(min-width: 1320px) { + .classindex dl { + column-count:3 + } +} + + +/* @group Link Styling */ + +a { + color: var(--page-link-color); + font-weight: normal; + text-decoration: none; +} + +.contents a:visited { + color: var(--page-visited-link-color); +} + +span.label a:hover { + text-decoration: none; + background: linear-gradient(to bottom, transparent 0,transparent calc(100% - 1px), currentColor 100%); +} + +a.el { + font-weight: bold; +} + +a.elRef { +} + +a.el, a.el:visited, a.code, a.code:visited, a.line, a.line:visited { + color: var(--page-link-color); +} + +a.codeRef, a.codeRef:visited, a.lineRef, a.lineRef:visited { + color: var(--page-external-link-color); +} + +a.code.hl_class { /* style for links to class names in code snippets */ } +a.code.hl_struct { /* style for links to struct names in code snippets */ } +a.code.hl_union { /* style for links to union names in code snippets */ } +a.code.hl_interface { /* style for links to interface names in code snippets */ } +a.code.hl_protocol { /* style for links to protocol names in code snippets */ } +a.code.hl_category { /* style for links to category names in code snippets */ } +a.code.hl_exception { /* style for links to exception names in code snippets */ } +a.code.hl_service { /* style for links to service names in code snippets */ } +a.code.hl_singleton { /* style for links to singleton names in code snippets */ } +a.code.hl_concept { /* style for links to concept names in code snippets */ } +a.code.hl_namespace { /* style for links to namespace names in code snippets */ } +a.code.hl_package { /* style for links to package names in code snippets */ } +a.code.hl_define { /* style for links to macro names in code snippets */ } +a.code.hl_function { /* style for links to function names in code snippets */ } +a.code.hl_variable { /* style for links to variable names in code snippets */ } +a.code.hl_typedef { /* style for links to typedef names in code snippets */ } +a.code.hl_enumvalue { /* style for links to enum value names in code snippets */ } +a.code.hl_enumeration { /* style for links to enumeration names in code snippets */ } +a.code.hl_signal { /* style for links to Qt signal names in code snippets */ } +a.code.hl_slot { /* style for links to Qt slot names in code snippets */ } +a.code.hl_friend { /* style for links to friend names in code snippets */ } +a.code.hl_dcop { /* style for links to KDE3 DCOP names in code snippets */ } +a.code.hl_property { /* style for links to property names in code snippets */ } +a.code.hl_event { /* style for links to event names in code snippets */ } +a.code.hl_sequence { /* style for links to sequence names in code snippets */ } +a.code.hl_dictionary { /* style for links to dictionary names in code snippets */ } + +/* @end */ + +dl.el { + margin-left: -1cm; +} + +ul.check { + list-style:none; + text-indent: -16px; + padding-left: 38px; +} +li.unchecked:before { + content: "\2610\A0"; +} +li.checked:before { + content: "\2611\A0"; +} + +ol { + text-indent: 0px; +} + +ul { + text-indent: 0px; + overflow: visible; +} + +ul.multicol { + -moz-column-gap: 1em; + -webkit-column-gap: 1em; + column-gap: 1em; + -moz-column-count: 3; + -webkit-column-count: 3; + column-count: 3; + list-style-type: none; +} + +#side-nav ul { + overflow: visible; /* reset ul rule for scroll bar in GENERATE_TREEVIEW window */ +} + +#main-nav ul { + overflow: visible; /* reset ul rule for the navigation bar drop down lists */ +} + +.fragment { + text-align: left; + direction: ltr; + overflow-x: auto; + overflow-y: hidden; + position: relative; + min-height: 12px; + margin: 10px 0px; + padding: 10px 10px; + border: 1px solid var(--fragment-border-color); + border-radius: 4px; + background-color: var(--fragment-background-color); + color: var(--fragment-foreground-color); +} + +pre.fragment { + word-wrap: break-word; + font-size: 10pt; + line-height: 125%; + font-family: var(--font-family-monospace); +} + +span.tt { + white-space: pre; + font-family: var(--font-family-monospace); +} + +.clipboard { + width: 24px; + height: 24px; + right: 5px; + top: 5px; + opacity: 0; + position: absolute; + display: inline; + overflow: hidden; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.clipboard.success { + border: 1px solid var(--fragment-foreground-color); + border-radius: 4px; +} + +.fragment:hover .clipboard, .clipboard.success { + opacity: .4; +} + +.clipboard:hover, .clipboard.success { + opacity: 1 !important; +} + +.clipboard:active:not([class~=success]) svg { + transform: scale(.91); +} + +.clipboard.success svg { + fill: var(--fragment-copy-ok-color); +} + +.clipboard.success { + border-color: var(--fragment-copy-ok-color); +} + +div.line { + font-family: var(--font-family-monospace); + font-size: 13px; + min-height: 13px; + line-height: 1.2; + text-wrap: wrap; + word-break: break-all; + white-space: -moz-pre-wrap; /* Moz */ + white-space: -pre-wrap; /* Opera 4-6 */ + white-space: -o-pre-wrap; /* Opera 7 */ + white-space: pre-wrap; /* CSS3 */ + word-wrap: break-word; /* IE 5.5+ */ + text-indent: -62px; + padding-left: 62px; + padding-bottom: 0px; + margin: 0px; + -webkit-transition-property: background-color, box-shadow; + -webkit-transition-duration: 0.5s; + -moz-transition-property: background-color, box-shadow; + -moz-transition-duration: 0.5s; + -ms-transition-property: background-color, box-shadow; + -ms-transition-duration: 0.5s; + -o-transition-property: background-color, box-shadow; + -o-transition-duration: 0.5s; + transition-property: background-color, box-shadow; + transition-duration: 0.5s; +} + +div.line:after { + content:"\000A"; + white-space: pre; +} + +div.line.glow { + background-color: var(--glow-color); + box-shadow: 0 0 10px var(--glow-color); +} + +span.fold { + display: inline-block; + width: 12px; + height: 12px; + margin-left: 4px; + margin-right: 1px; +} + +span.foldnone { + display: inline-block; + position: relative; + cursor: pointer; + user-select: none; +} + +span.fold.plus, span.fold.minus { + width: 10px; + height: 10px; + background-color: var(--fragment-background-color); + position: relative; + border: 1px solid var(--fold-line-color); + margin-right: 1px; +} + +span.fold.plus::before, span.fold.minus::before { + content: ''; + position: absolute; + background-color: var(--fold-line-color); +} + +span.fold.plus::before { + width: 2px; + height: 6px; + top: 2px; + left: 4px; +} + +span.fold.plus::after { + content: ''; + position: absolute; + width: 6px; + height: 2px; + top: 4px; + left: 2px; + background-color: var(--fold-line-color); +} + +span.fold.minus::before { + width: 6px; + height: 2px; + top: 4px; + left: 2px; +} + +span.lineno { + padding-right: 4px; + margin-right: 9px; + text-align: right; + border-right: 2px solid var(--fragment-lineno-border-color); + color: var(--fragment-lineno-foreground-color); + background-color: var(--fragment-lineno-background-color); + white-space: pre; +} +span.lineno a, span.lineno a:visited { + color: var(--fragment-lineno-link-fg-color); + background-color: var(--fragment-lineno-link-bg-color); +} + +span.lineno a:hover { + color: var(--fragment-lineno-link-hover-fg-color); + background-color: var(--fragment-lineno-link-hover-bg-color); +} + +.lineno { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +div.classindex ul { + list-style: none; + padding-left: 0; +} + +div.classindex span.ai { + display: inline-block; +} + +div.groupHeader { + box-shadow: 13px 0 var(--page-background-color), + -13px 0 var(--page-background-color), + 13px 1px var(--group-header-separator-color), + -13px 1px var(--group-header-separator-color); + color: var(--group-header-color); + font-size: 110%; + font-weight: 500; + margin-left: 0px; + margin-top: 0em; + margin-bottom: 6px; + padding-top: 8px; + padding-bottom: 4px; +} + +div.groupText { + margin-left: 16px; + font-style: italic; +} + +body { + color: var(--page-foreground-color); + margin: 0; +} + +div.contents { + margin-top: 10px; + margin-left: 12px; + margin-right: 12px; +} + +p.formulaDsp { + text-align: center; +} + +img.dark-mode-visible { + display: none; +} +img.light-mode-visible { + display: none; +} + +img.formulaInl, img.inline { + vertical-align: middle; +} + +div.center { + text-align: center; + margin-top: 0px; + margin-bottom: 0px; + padding: 0px; +} + +div.center img { + border: 0px; +} + +address.footer { + text-align: right; + padding-right: 12px; +} + +img.footer { + border: 0px; + vertical-align: middle; + width: var(--footer-logo-width); +} + +.compoundTemplParams { + color: var(--memdecl-template-color); + font-size: 80%; + line-height: 120%; +} + +/* @group Code Colorization */ + +span.keyword { + color: var(--code-keyword-color); +} + +span.keywordtype { + color: var(--code-type-keyword-color); +} + +span.keywordflow { + color: var(--code-flow-keyword-color); +} + +span.comment { + color: var(--code-comment-color); +} + +span.preprocessor { + color: var(--code-preprocessor-color); +} + +span.stringliteral { + color: var(--code-string-literal-color); +} + +span.charliteral { + color: var(--code-char-literal-color); +} + +span.xmlcdata { + color: var(--code-xml-cdata-color); +} + +span.vhdldigit { + color: var(--code-vhdl-digit-color); +} + +span.vhdlchar { + color: var(--code-vhdl-char-color); +} + +span.vhdlkeyword { + color: var(--code-vhdl-keyword-color); +} + +span.vhdllogic { + color: var(--code-vhdl-logic-color); +} + +blockquote { + background-color: var(--blockquote-background-color); + border-left: 2px solid var(--blockquote-border-color); + margin: 0 24px 0 4px; + padding: 0 12px 0 16px; +} + +/* @end */ + +td.tiny { + font-size: 75%; +} + +.dirtab { + padding: 4px; + border-collapse: collapse; + border: 1px solid var(--table-cell-border-color); +} + +th.dirtab { + background-color: var(--table-header-background-color); + color: var(--table-header-foreground-color); + font-weight: bold; +} + +hr { + border: none; + margin-top: 16px; + margin-bottom: 16px; + height: 1px; + box-shadow: 13px 0 var(--page-background-color), + -13px 0 var(--page-background-color), + 13px 1px var(--group-header-separator-color), + -13px 1px var(--group-header-separator-color); +} + +hr.footer { + height: 1px; +} + +/* @group Member Descriptions */ + +table.memberdecls { + border-spacing: 0px; + padding: 0px; +} + +.memberdecls td, .fieldtable tr { + transition-property: background-color, box-shadow; + transition-duration: 0.5s; +} + +.memberdecls td.glow, .fieldtable tr.glow { + background-color: var(--glow-color); + box-shadow: 0 0 15px var(--glow-color); +} + +.memberdecls tr[class^='memitem'] { + font-family: var(--font-family-monospace); +} + +.mdescLeft, .mdescRight, +.memItemLeft, .memItemRight { + padding-top: 2px; + padding-bottom: 2px; +} + +.memTemplParams { + padding-left: 10px; + padding-top: 5px; +} + +.memItemLeft, .memItemRight, .memTemplParams { + background-color: var(--memdecl-background-color); +} + +.mdescLeft, .mdescRight { + padding: 0px 8px 4px 8px; + color: var(--memdecl-foreground-color); +} + +tr[class^='memdesc'] { + box-shadow: inset 0px 1px 3px 0px rgba(0,0,0,.075); +} + +.mdescLeft { + border-left: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); +} + +.mdescRight { + border-right: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); +} + +.memTemplParams { + color: var(--memdecl-template-color); + white-space: nowrap; + font-size: 80%; + border-left: 1px solid var(--memdecl-border-color); + border-right: 1px solid var(--memdecl-border-color); +} + +td.ititle { + border: 1px solid var(--memdecl-border-color); + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding-left: 10px; +} + +tr:not(:first-child) > td.ititle { + border-top: 0; + border-radius: 0; +} + +.memItemLeft { + white-space: nowrap; + border-left: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); + padding-left: 10px; + transition: none; +} + +.memItemRight { + width: 100%; + border-right: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); + padding-right: 10px; + transition: none; +} + +tr.heading + tr[class^='memitem'] td.memItemLeft, +tr.groupHeader + tr[class^='memitem'] td.memItemLeft, +tr.inherit_header + tr[class^='memitem'] td.memItemLeft { + border-top: 1px solid var(--memdecl-border-color); + border-top-left-radius: 4px; +} + +tr.heading + tr[class^='memitem'] td.memItemRight, +tr.groupHeader + tr[class^='memitem'] td.memItemRight, +tr.inherit_header + tr[class^='memitem'] td.memItemRight { + border-top: 1px solid var(--memdecl-border-color); + border-top-right-radius: 4px; +} + +tr.heading + tr[class^='memitem'] td.memTemplParams, +tr.heading + tr td.ititle, +tr.groupHeader + tr[class^='memitem'] td.memTemplParams, +tr.groupHeader + tr td.ititle, +tr.inherit_header + tr[class^='memitem'] td.memTemplParams { + border-top: 1px solid var(--memdecl-border-color); + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +table.memberdecls tr:last-child td.memItemLeft, +table.memberdecls tr:last-child td.mdescLeft, +table.memberdecls tr[class^='memitem']:has(+ tr.groupHeader) td.memItemLeft, +table.memberdecls tr[class^='memitem']:has(+ tr.inherit_header) td.memItemLeft, +table.memberdecls tr[class^='memdesc']:has(+ tr.groupHeader) td.mdescLeft, +table.memberdecls tr[class^='memdesc']:has(+ tr.inherit_header) td.mdescLeft { + border-bottom-left-radius: 4px; +} + +table.memberdecls tr:last-child td.memItemRight, +table.memberdecls tr:last-child td.mdescRight, +table.memberdecls tr[class^='memitem']:has(+ tr.groupHeader) td.memItemRight, +table.memberdecls tr[class^='memitem']:has(+ tr.inherit_header) td.memItemRight, +table.memberdecls tr[class^='memdesc']:has(+ tr.groupHeader) td.mdescRight, +table.memberdecls tr[class^='memdesc']:has(+ tr.inherit_header) td.mdescRight { + border-bottom-right-radius: 4px; +} + +tr.template .memItemLeft, tr.template .memItemRight { + border-top: none; + padding-top: 0; +} + + +/* @end */ + +/* @group Member Details */ + +/* Styles for detailed member documentation */ + +.memtitle { + padding: 8px; + border-top: 1px solid var(--memdef-border-color); + border-left: 1px solid var(--memdef-border-color); + border-right: 1px solid var(--memdef-border-color); + border-top-right-radius: 4px; + border-top-left-radius: 4px; + margin-bottom: -1px; + background-color: var(--memdef-proto-background-color); + line-height: 1.25; + font-family: var(--font-family-monospace); + font-weight: 500; + font-size: 16px; + float:left; + box-shadow: 0 10px 0 -1px var(--memdef-proto-background-color), + 0 2px 8px 0 rgba(0,0,0,.075); + position: relative; +} + +.memtitle:after { + content: ''; + display: block; + background: var(--memdef-proto-background-color); + height: 10px; + bottom: -10px; + left: 0px; + right: -14px; + position: absolute; + border-top-right-radius: 6px; +} + +.permalink +{ + font-family: var(--font-family-monospace); + font-weight: 500; + line-height: 1.25; + font-size: 16px; + display: inline-block; + vertical-align: middle; +} + +.memtemplate { + font-size: 80%; + color: var(--memdef-template-color); + font-family: var(--font-family-monospace); + font-weight: normal; + margin-left: 9px; +} + +.mempage { + width: 100%; +} + +.memitem { + padding: 0; + margin-bottom: 10px; + margin-right: 5px; + display: table !important; + width: 100%; + box-shadow: 0 2px 8px 0 rgba(0,0,0,.075); + border-radius: 4px; +} + +.memitem.glow { + box-shadow: 0 0 15px var(--glow-color); +} + +.memname { + font-family: var(--font-family-monospace); + font-size: 13px; + font-weight: 400; + margin-left: 6px; +} + +.memname td { + vertical-align: bottom; +} + +.memproto, dl.reflist dt { + border-top: 1px solid var(--memdef-border-color); + border-left: 1px solid var(--memdef-border-color); + border-right: 1px solid var(--memdef-border-color); + padding: 6px 0px 6px 0px; + color: var(--memdef-proto-text-color); + font-weight: bold; + background-color: var(--memdef-proto-background-color); + border-top-right-radius: 4px; + border-bottom: 1px solid var(--memdef-border-color); +} + +.overload { + font-family: var(--font-family-monospace); + font-size: 65%; +} + +.memdoc, dl.reflist dd { + border-bottom: 1px solid var(--memdef-border-color); + border-left: 1px solid var(--memdef-border-color); + border-right: 1px solid var(--memdef-border-color); + padding: 6px 10px 2px 10px; + border-top-width: 0; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} + +dl.reflist dt { + padding: 5px; +} + +dl.reflist dd { + margin: 0px 0px 10px 0px; + padding: 5px; +} + +.paramkey { + text-align: right; +} + +.paramtype { + white-space: nowrap; + padding: 0px; + padding-bottom: 1px; +} + +.paramname { + white-space: nowrap; + padding: 0px; + padding-bottom: 1px; + margin-left: 2px; +} + +.paramname em { + color: var(--memdef-param-name-color); + font-style: normal; + margin-right: 1px; +} + +.paramname .paramdefval { + font-family: var(--font-family-monospace); +} + +.params, .retval, .exception, .tparams { + margin-left: 0px; + padding-left: 0px; +} + +.params .paramname, .retval .paramname, .tparams .paramname, .exception .paramname { + font-weight: bold; + vertical-align: top; +} + +.params .paramtype, .tparams .paramtype { + font-style: italic; + vertical-align: top; +} + +.params .paramdir, .tparams .paramdir { + font-family: var(--font-family-monospace); + vertical-align: top; +} + +table.mlabels { + border-spacing: 0px; +} + +td.mlabels-left { + width: 100%; + padding: 0px; +} + +td.mlabels-right { + vertical-align: bottom; + padding: 0px; + white-space: nowrap; +} + +span.mlabels { + margin-left: 8px; +} + +span.mlabel { + background-color: var(--label-background-color); + border-top:1px solid var(--label-left-top-border-color); + border-left:1px solid var(--label-left-top-border-color); + border-right:1px solid var(--label-right-bottom-border-color); + border-bottom:1px solid var(--label-right-bottom-border-color); + text-shadow: none; + color: var(--label-foreground-color); + margin-right: 4px; + padding: 2px 3px; + border-radius: 3px; + font-size: 7pt; + white-space: nowrap; + vertical-align: middle; +} + + + +/* @end */ + +/* these are for tree view inside a (index) page */ + +div.directory { + margin: 10px 0px; + width: 100%; +} + +.directory table { + border-collapse:collapse; +} + +.directory td { + margin: 0px; + padding: 0px; + vertical-align: top; +} + +.directory td.entry { + white-space: nowrap; + padding-right: 6px; + padding-top: 3px; +} + +.directory td.entry a { + outline:none; +} + +.directory td.entry a img { + border: none; +} + +.directory td.desc { + width: 100%; + padding-left: 6px; + padding-right: 6px; + padding-top: 3px; + border-left: 1px solid rgba(0,0,0,0.05); +} + +.directory tr.odd { + padding-left: 6px; + background-color: var(--index-odd-item-bg-color); +} + +.directory tr.even { + padding-left: 6px; + background-color: var(--index-even-item-bg-color); +} + +.directory img { + vertical-align: -30%; +} + +.directory .levels { + white-space: nowrap; + width: 100%; + text-align: right; + font-size: 9pt; +} + +.directory .levels span { + cursor: pointer; + padding-left: 2px; + padding-right: 2px; + color: var(--page-link-color); +} + +.arrow { + color: var(--nav-background-color); + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; + font-size: 80%; + display: inline-block; + width: 16px; + height: 14px; + transition: opacity 0.3s ease; +} + +span.arrowhead { + position: relative; + padding: 0; + margin: 0 0 0 2px; + display: inline-block; + width: 5px; + height: 5px; + border-right: 2px solid var(--nav-arrow-color); + border-bottom: 2px solid var(--nav-arrow-color); + transform: rotate(-45deg); + transition: transform 0.3s ease; +} + +span.arrowhead.opened { + transform: rotate(45deg); +} + +.selected span.arrowhead { + border-right: 2px solid var(--nav-arrow-selected-color); + border-bottom: 2px solid var(--nav-arrow-selected-color); +} + +.icon { + font-family: var(--font-family-icon); + line-height: normal; + font-weight: bold; + font-size: 12px; + height: 14px; + width: 16px; + display: inline-block; + background-color: var(--icon-background-color); + color: var(--icon-foreground-color); + text-align: center; + border-radius: 4px; + margin-left: 2px; + margin-right: 2px; +} + +.icona { + width: 24px; + height: 22px; + display: inline-block; +} + +.iconfolder { + width: 24px; + height: 18px; + margin-top: 6px; + vertical-align:top; + display: inline-block; + position: relative; +} + +.icondoc { + width: 24px; + height: 18px; + margin-top: 3px; + vertical-align:top; + display: inline-block; + position: relative; +} + +.folder-icon { + width: 16px; + height: 11px; + background-color: var(--icon-folder-fill-color); + border: 1px solid var(--icon-folder-border-color); + border-radius: 0 2px 2px 2px; + position: relative; + box-sizing: content-box; +} + +.folder-icon::after { + content: ''; + position: absolute; + top: 2px; + left: -1px; + width: 16px; + height: 7px; + background-color: var(--icon-folder-open-fill-color); + border: 1px solid var(--icon-folder-border-color); + border-radius: 7px 7px 2px 2px; + transform-origin: top left; + opacity: 0; + transition: all 0.3s linear; +} + +.folder-icon::before { + content: ''; + position: absolute; + top: -3px; + left: -1px; + width: 6px; + height: 2px; + background-color: var(--icon-folder-fill-color); + border-top: 1px solid var(--icon-folder-border-color); + border-left: 1px solid var(--icon-folder-border-color); + border-right: 1px solid var(--icon-folder-border-color); + border-radius: 2px 2px 0 0; +} + +.folder-icon.open::after { + top: 3px; + opacity: 1; +} + +.doc-icon { + left: 6px; + width: 12px; + height: 16px; + background-color: var(--icon-doc-border-color); + clip-path: polygon(0 0, 66% 0, 100% 25%, 100% 100%, 0 100%); + position: relative; + display: inline-block; +} +.doc-icon::before { + content: ""; + left: 1px; + top: 1px; + width: 10px; + height: 14px; + background-color: var(--icon-doc-fill-color); + clip-path: polygon(0 0, 66% 0, 100% 25%, 100% 100%, 0 100%); + position: absolute; + box-sizing: border-box; +} +.doc-icon::after { + content: ""; + left: 7px; + top: 0px; + width: 3px; + height: 3px; + background-color: transparent; + position: absolute; + border: 1px solid var(--icon-doc-border-color); +} + + + + +/* @end */ + +div.dynheader { + margin-top: 8px; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +span.dynarrow { + position: relative; + display: inline-block; + width: 12px; + bottom: 1px; +} + +address { + font-style: normal; + color: var(--footer-foreground-color); +} + +table.doxtable caption { + caption-side: top; +} + +table.doxtable { + border-collapse:collapse; + margin-top: 4px; + margin-bottom: 4px; +} + +table.doxtable td, table.doxtable th { + border: 1px solid var(--table-cell-border-color); + padding: 3px 7px 2px; +} + +table.doxtable th { + background-color: var(--table-header-background-color); + color: var(--table-header-foreground-color); + font-size: 110%; + padding-bottom: 4px; + padding-top: 5px; +} + +table.fieldtable { + margin-bottom: 10px; + border: 1px solid var(--memdef-border-color); + border-spacing: 0px; + border-radius: 4px; + box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.15); +} + +.fieldtable td, .fieldtable th { + padding: 3px 7px 2px; +} + +.fieldtable td.fieldtype, .fieldtable td.fieldname, .fieldtable td.fieldinit { + white-space: nowrap; + border-right: 1px solid var(--memdef-border-color); + border-bottom: 1px solid var(--memdef-border-color); + vertical-align: top; +} + +.fieldtable td.fieldname { + padding-top: 3px; +} + +.fieldtable td.fieldinit { + padding-top: 3px; + text-align: right; +} + + +.fieldtable td.fielddoc { + border-bottom: 1px solid var(--memdef-border-color); +} + +.fieldtable td.fielddoc p:first-child { + margin-top: 0px; +} + +.fieldtable td.fielddoc p:last-child { + margin-bottom: 2px; +} + +.fieldtable tr:last-child td { + border-bottom: none; +} + +.fieldtable th { + background-color: var(--memdef-title-background-color); + font-size: 90%; + color: var(--memdef-proto-text-color); + padding-bottom: 4px; + padding-top: 5px; + text-align:left; + font-weight: 400; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom: 1px solid var(--memdef-border-color); +} + +/* ----------- navigation breadcrumb styling ----------- */ + +#nav-path ul { + height: 30px; + line-height: 30px; + color: var(--nav-text-normal-color); + overflow: hidden; + margin: 0px; + padding-left: 4px; + background-image: none; + background: var(--page-background-color); + border-bottom: 1px solid var(--nav-breadcrumb-separator-color); + font-size: var(--nav-font-size-level1); + font-family: var(--font-family-nav); + position: relative; + z-index: 100; +} + +#main-nav { + border-bottom: 1px solid var(--nav-border-color); +} + +.navpath li { + list-style-type:none; + float:left; + color: var(--nav-foreground-color); +} + +.navpath li.footer { + list-style-type:none; + float:right; + padding-left:10px; + padding-right:15px; + background-image:none; + background-repeat:no-repeat; + background-position:right; + font-size: 8pt; + color: var(--footer-foreground-color); +} + +#nav-path li.navelem { + background-image: none; + display: flex; + align-items: center; + padding-left: 15px; +} + +.navpath li.navelem a { + text-shadow: none; + display: inline-block; + color: var(--nav-breadcrumb-color); + position: relative; + top: 0px; + height: 30px; + margin-right: -20px; +} + +#nav-path li.navelem:after { + content: ''; + display: inline-block; + position: relative; + top: 0; + right: -15px; + width: 30px; + height: 30px; + transform: scaleX(0.5) scale(0.707) rotate(45deg); + z-index: 10; + background: var(--page-background-color); + box-shadow: 2px -2px 0 2px var(--nav-breadcrumb-separator-color); + border-radius: 0 5px 0 50px; +} + +#nav-path li.navelem:first-child { + margin-left: -6px; +} + +#nav-path li.navelem:hover, +#nav-path li.navelem:hover:after { + background-color: var(--nav-breadcrumb-active-bg); +} + +/* ---------------------- */ + +div.summary +{ + float: right; + font-size: 8pt; + padding-right: 5px; + width: 50%; + text-align: right; +} + +div.summary a +{ + white-space: nowrap; +} + +table.classindex +{ + margin: 10px; + white-space: nowrap; + margin-left: 3%; + margin-right: 3%; + width: 94%; + border: 0; + border-spacing: 0; + padding: 0; +} + +div.ingroups +{ + font-size: 8pt; + width: 50%; + text-align: left; +} + +div.ingroups a +{ + white-space: nowrap; +} + +div.header +{ + margin: 0px; + background-color: var(--header-background-color); + border-bottom: 1px solid var(--header-separator-color); +} + +div.headertitle +{ + padding: 5px 5px 5px 10px; +} + +dl { + padding: 0 0 0 0; +} + +dl.bug dt a, dl.deprecated dt a, dl.todo dt a, dl.test a { + font-weight: bold !important; +} + +dl.warning, dl.attention, dl.important, dl.note, dl.deprecated, dl.bug, +dl.invariant, dl.pre, dl.post, dl.todo, dl.test, dl.remark { + padding: 10px; + margin: 10px 0px; + overflow: hidden; + margin-left: 0; + border-radius: 4px; +} + +dl.section dd { + margin-bottom: 2px; +} + +dl.warning, dl.attention, dl.important { + background: var(--warning-color-bg); + border-left: 8px solid var(--warning-color-hl); + color: var(--warning-color-text); +} + +dl.warning dt, dl.attention dt, dl.important dt { + color: var(--warning-color-hl); +} + +dl.note, dl.remark { + background: var(--note-color-bg); + border-left: 8px solid var(--note-color-hl); + color: var(--note-color-text); +} + +dl.note dt, dl.remark dt { + color: var(--note-color-hl); +} + +dl.todo { + background: var(--todo-color-bg); + border-left: 8px solid var(--todo-color-hl); + color: var(--todo-color-text); +} + +dl.todo dt { + color: var(--todo-color-hl); +} + +dl.test { + background: var(--test-color-bg); + border-left: 8px solid var(--test-color-hl); + color: var(--test-color-text); +} + +dl.test dt { + color: var(--test-color-hl); +} + +dl.bug dt a { + color: var(--bug-color-hl) !important; +} + +dl.bug { + background: var(--bug-color-bg); + border-left: 8px solid var(--bug-color-hl); + color: var(--bug-color-text); +} + +dl.bug dt a { + color: var(--bug-color-hl) !important; +} + +dl.deprecated { + background: var(--deprecated-color-bg); + border-left: 8px solid var(--deprecated-color-hl); + color: var(--deprecated-color-text); +} + +dl.deprecated dt a { + color: var(--deprecated-color-hl) !important; +} + +dl.note dd, dl.warning dd, dl.pre dd, dl.post dd, +dl.remark dd, dl.attention dd, dl.important dd, dl.invariant dd, +dl.bug dd, dl.deprecated dd, dl.todo dd, dl.test dd { + margin-inline-start: 0px; +} + +dl.invariant, dl.pre, dl.post { + background: var(--invariant-color-bg); + border-left: 8px solid var(--invariant-color-hl); + color: var(--invariant-color-text); +} + +dl.invariant dt, dl.pre dt, dl.post dt { + color: var(--invariant-color-hl); +} + + +#projectrow +{ + height: 56px; +} + +#projectlogo +{ + text-align: center; + vertical-align: bottom; + border-collapse: separate; +} + +#projectlogo img +{ + border: 0px none; +} + +#projectalign +{ + vertical-align: middle; + padding-left: 0.5em; +} + +#projectname +{ + font-size: 200%; + font-family: var(--font-family-title); + margin: 0; + padding: 0; +} + +#side-nav #projectname +{ + font-size: 130%; +} + +#projectbrief +{ + font-size: 90%; + font-family: var(--font-family-title); + margin: 0px; + padding: 0px; +} + +#projectnumber +{ + font-size: 50%; + font-family: var(--font-family-title); + margin: 0px; + padding: 0px; +} + +#titlearea +{ + padding: 0 0 0 5px; + margin: 0px; + border-bottom: 1px solid var(--title-separator-color); + background-color: var(--title-background-color); +} + +.image +{ + text-align: center; +} + +.dotgraph +{ + text-align: center; +} + +.mscgraph +{ + text-align: center; +} + +.plantumlgraph +{ + text-align: center; +} + +.diagraph +{ + text-align: center; +} + +.caption +{ + font-weight: bold; +} + +dl.citelist { + margin-bottom:50px; +} + +dl.citelist dt { + color:var(--citation-label-color); + float:left; + font-weight:bold; + margin-right:10px; + padding:5px; + text-align:right; + width:52px; +} + +dl.citelist dd { + margin:2px 0 2px 72px; + padding:5px 0; +} + +div.toc { + padding: 14px 25px; + background-color: var(--toc-background-color); + border: 1px solid var(--toc-border-color); + border-radius: 7px 7px 7px 7px; + float: right; + height: auto; + margin: 0 8px 10px 10px; + width: 200px; +} + +div.toc li { + background: var(--toc-down-arrow-image) no-repeat scroll 0 5px transparent; + font: 10px/1.2 var(--font-family-toc); + margin-top: 5px; + padding-left: 10px; + padding-top: 2px; +} + +div.toc h3 { + font: bold 12px/1.2 var(--font-family-toc); + color: var(--toc-header-color); + border-bottom: 0 none; + margin: 0; +} + +div.toc ul { + list-style: none outside none; + border: medium none; + padding: 0px; +} + +div.toc li[class^='level'] { + margin-left: 15px; +} + +div.toc li.level1 { + margin-left: 0px; +} + +div.toc li.empty { + background-image: none; + margin-top: 0px; +} + +span.emoji { + /* font family used at the site: https://unicode.org/emoji/charts/full-emoji-list.html + * font-family: "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", Times, Symbola, Aegyptus, Code2000, Code2001, Code2002, Musica, serif, LastResort; + */ +} + +span.obfuscator { + display: none; +} + +.inherit_header { + font-weight: 400; + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.inherit_header td { + padding: 6px 0 2px 0; +} + +.inherit { + display: none; +} + +tr.heading h2 { + margin-top: 12px; + margin-bottom: 12px; +} + +/* tooltip related style info */ + +.ttc { + position: absolute; + display: none; +} + +#powerTip { + cursor: default; + color: var(--tooltip-foreground-color); + background-color: var(--tooltip-background-color); + backdrop-filter: var(--tooltip-backdrop-filter); + -webkit-backdrop-filter: var(--tooltip-backdrop-filter); + border: 1px solid var(--tooltip-border-color); + border-radius: 4px; + box-shadow: var(--tooltip-shadow); + display: none; + font-size: smaller; + max-width: 80%; + padding: 1ex 1em 1em; + position: absolute; + z-index: 2147483647; +} + +#powerTip div.ttdoc { + color: var(--tooltip-doc-color); + font-style: italic; +} + +#powerTip div.ttname a { + font-weight: bold; +} + +#powerTip a { + color: var(--tooltip-link-color); +} + +#powerTip div.ttname { + font-weight: bold; +} + +#powerTip div.ttdeci { + color: var(--tooltip-declaration-color); +} + +#powerTip div { + margin: 0px; + padding: 0px; + font-size: 12px; + font-family: var(--font-family-tooltip); + line-height: 16px; +} + +#powerTip:before, #powerTip:after { + content: ""; + position: absolute; + margin: 0px; +} + +#powerTip.n:after, #powerTip.n:before, +#powerTip.s:after, #powerTip.s:before, +#powerTip.w:after, #powerTip.w:before, +#powerTip.e:after, #powerTip.e:before, +#powerTip.ne:after, #powerTip.ne:before, +#powerTip.se:after, #powerTip.se:before, +#powerTip.nw:after, #powerTip.nw:before, +#powerTip.sw:after, #powerTip.sw:before { + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; +} + +#powerTip.n:after, #powerTip.s:after, +#powerTip.w:after, #powerTip.e:after, +#powerTip.nw:after, #powerTip.ne:after, +#powerTip.sw:after, #powerTip.se:after { + border-color: rgba(255, 255, 255, 0); +} + +#powerTip.n:before, #powerTip.s:before, +#powerTip.w:before, #powerTip.e:before, +#powerTip.nw:before, #powerTip.ne:before, +#powerTip.sw:before, #powerTip.se:before { + border-color: rgba(128, 128, 128, 0); +} + +#powerTip.n:after, #powerTip.n:before, +#powerTip.ne:after, #powerTip.ne:before, +#powerTip.nw:after, #powerTip.nw:before { + top: 100%; +} + +#powerTip.n:after, #powerTip.ne:after, #powerTip.nw:after { + border-top-color: var(--tooltip-arrow-background-color); + border-width: 10px; + margin: 0px -10px; +} +#powerTip.n:before, #powerTip.ne:before, #powerTip.nw:before { + border-top-color: var(--tooltip-border-color); + border-width: 11px; + margin: 0px -11px; +} +#powerTip.n:after, #powerTip.n:before { + left: 50%; +} + +#powerTip.nw:after, #powerTip.nw:before { + right: 14px; +} + +#powerTip.ne:after, #powerTip.ne:before { + left: 14px; +} + +#powerTip.s:after, #powerTip.s:before, +#powerTip.se:after, #powerTip.se:before, +#powerTip.sw:after, #powerTip.sw:before { + bottom: 100%; +} + +#powerTip.s:after, #powerTip.se:after, #powerTip.sw:after { + border-bottom-color: var(--tooltip-arrow-background-color); + border-width: 10px; + margin: 0px -10px; +} + +#powerTip.s:before, #powerTip.se:before, #powerTip.sw:before { + border-bottom-color: var(--tooltip-border-color); + border-width: 11px; + margin: 0px -11px; +} + +#powerTip.s:after, #powerTip.s:before { + left: 50%; +} + +#powerTip.sw:after, #powerTip.sw:before { + right: 14px; +} + +#powerTip.se:after, #powerTip.se:before { + left: 14px; +} + +#powerTip.e:after, #powerTip.e:before { + left: 100%; +} +#powerTip.e:after { + border-left-color: var(--tooltip-border-color); + border-width: 10px; + top: 50%; + margin-top: -10px; +} +#powerTip.e:before { + border-left-color: var(--tooltip-border-color); + border-width: 11px; + top: 50%; + margin-top: -11px; +} + +#powerTip.w:after, #powerTip.w:before { + right: 100%; +} +#powerTip.w:after { + border-right-color: var(--tooltip-border-color); + border-width: 10px; + top: 50%; + margin-top: -10px; +} +#powerTip.w:before { + border-right-color: var(--tooltip-border-color); + border-width: 11px; + top: 50%; + margin-top: -11px; +} + +@media print +{ + #top { display: none; } + #side-nav { display: none; } + #nav-path { display: none; } + body { overflow:visible; } + h1, h2, h3, h4, h5, h6 { page-break-after: avoid; } + .summary { display: none; } + .memitem { page-break-inside: avoid; } + #doc-content + { + margin-left:0 !important; + height:auto !important; + width:auto !important; + overflow:inherit; + display:inline; + } +} + +/* @group Markdown */ + +table.markdownTable { + border-collapse:collapse; + margin-top: 4px; + margin-bottom: 4px; +} + +table.markdownTable td, table.markdownTable th { + border: 1px solid var(--table-cell-border-color); + padding: 3px 7px 2px; +} + +table.markdownTable tr { +} + +th.markdownTableHeadLeft, th.markdownTableHeadRight, th.markdownTableHeadCenter, th.markdownTableHeadNone { + background-color: var(--table-header-background-color); + color: var(--table-header-foreground-color); + font-size: 110%; + padding-bottom: 4px; + padding-top: 5px; +} + +th.markdownTableHeadLeft, td.markdownTableBodyLeft { + text-align: left +} + +th.markdownTableHeadRight, td.markdownTableBodyRight { + text-align: right +} + +th.markdownTableHeadCenter, td.markdownTableBodyCenter { + text-align: center +} + +tt, code, kbd +{ + display: inline-block; +} +tt, code, kbd +{ + vertical-align: top; +} +/* @end */ + +u { + text-decoration: underline; +} + +details>summary { + list-style-type: none; +} + +details > summary::-webkit-details-marker { + display: none; +} + +details>summary::before { + content: "\25ba"; + padding-right:4px; + font-size: 80%; +} + +details[open]>summary::before { + content: "\25bc"; + padding-right:4px; + font-size: 80%; +} + +:root { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-background-color); +} + +::-webkit-scrollbar { + background-color: var(--scrollbar-background-color); + height: 12px; + width: 12px; +} +::-webkit-scrollbar-thumb { + border-radius: 6px; + box-shadow: inset 0 0 12px 12px var(--scrollbar-thumb-color); + border: solid 2px transparent; +} +::-webkit-scrollbar-corner { + background-color: var(--scrollbar-background-color); +} + From 48d5fc925f0cae0c8f0b4eaa7422d9d05f62b383 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Oct 2025 08:27:27 -0700 Subject: [PATCH 029/702] doxygen: better scrollbar styling --- dist/doxygen/ghostty.css | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/dist/doxygen/ghostty.css b/dist/doxygen/ghostty.css index 9670a70ca..11b5130ee 100644 --- a/dist/doxygen/ghostty.css +++ b/dist/doxygen/ghostty.css @@ -54,6 +54,43 @@ h6, background: rgba(53, 81, 243, 0.6); } +/* Modern scrollbar styling for WebKit browsers (Safari, Chrome) */ +::-webkit-scrollbar { + width: 14px; + height: 14px; + -webkit-appearance: none; +} + +::-webkit-scrollbar-track { + background: #1a1f2e; + border-radius: 8px; +} + +::-webkit-scrollbar-thumb { + background: #4a5260; + border-radius: 8px; + border: 3px solid #1a1f2e; + min-height: 40px; +} + +::-webkit-scrollbar-thumb:hover { + background: #5a6270; +} + +::-webkit-scrollbar-thumb:active { + background: #6a7280; +} + +::-webkit-scrollbar-corner { + background: #1a1f2e; +} + +/* Firefox scrollbar styling */ +* { + scrollbar-width: thin; + scrollbar-color: #404754 #1a1f2e; +} + /* Tree view selected item */ #nav-tree .selected { background-color: #3551f3 !important; From 9194d6c496c56a680fb6010ae52cfaf8bdc53343 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Oct 2025 08:36:19 -0700 Subject: [PATCH 030/702] doxygen: integrate examples into documentation --- Doxyfile | 3 +++ include/ghostty/vt.h | 25 +++++++++++++++++++++---- src/build/docker/lib-c-docs/Dockerfile | 1 + 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Doxyfile b/Doxyfile index 0c8688987..58b6be48a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -6,6 +6,9 @@ PROJECT_LOGO = images/gnome/64.png INPUT = include/ghostty/vt.h INPUT_ENCODING = UTF-8 RECURSIVE = NO +EXAMPLE_PATH = example +EXAMPLE_RECURSIVE = YES +EXAMPLE_PATTERNS = * FULL_PATH_NAMES = NO STRIP_FROM_INC_PATH = include SOURCE_BROWSER = YES diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index ebb41e300..bcbb01d00 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -1,7 +1,7 @@ /** * @file vt.h * - * libghostty-vt - Virtual terminal sequence parsing library + * libghostty-vt - Virtual terminal emulator library * * This library provides functionality for parsing and handling terminal * escape sequences as well as maintaining terminal state such as styles, @@ -12,14 +12,15 @@ */ /** - * @mainpage libghostty-vt - Virtual Terminal Sequence Parser + * @mainpage libghostty-vt - Virtual Terminal Emulator Library * * libghostty-vt is a C library which implements a modern terminal emulator, * extracted from the [Ghostty](https://ghostty.org) terminal emulator. * * libghostty-vt contains the logic for handling the core parts of a terminal - * emulator: parsing terminal escape sequences and maintaining terminal state. - * It can handle scrollback, line wrapping, reflow on resize, and more. + * emulator: parsing terminal escape sequences, maintaining terminal state, + * encoding input events, etc. It can handle scrollback, line wrapping, + * reflow on resize, and more. * * @warning This library is currently in development and the API is not yet stable. * Breaking changes are expected in future versions. Use with caution in production code. @@ -31,6 +32,22 @@ * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref allocator "Memory Management" - Memory management and custom allocators * + * @section examples_sec Examples + * + * Complete working examples: + * - @ref c-vt/src/main.c - OSC parser example + * - @ref c-vt-key-encode/src/main.c - Key encoding example + * + */ + +/** @example c-vt/src/main.c + * This example demonstrates how to use the OSC parser to parse an OSC sequence, + * extract command information, and retrieve command-specific data like window titles. + */ + +/** @example c-vt-key-encode/src/main.c + * This example demonstrates how to use the key encoder to convert key events + * into terminal escape sequences using the Kitty keyboard protocol. */ #ifndef GHOSTTY_VT_H diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index 8b4f2f6df..a3cfdcc98 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -14,6 +14,7 @@ WORKDIR /ghostty COPY include/ ./include/ COPY images/ ./images/ COPY dist/doxygen/ ./dist/doxygen/ +COPY example/ ./example/ COPY Doxyfile ./ COPY DoxygenLayout.xml ./ RUN mkdir -p zig-out/share/ghostty/doc/libghostty From ffbdfbd1e68083d5d1f5bdbbeafc7352552e738b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Oct 2025 08:45:24 -0700 Subject: [PATCH 031/702] prettier --- dist/doxygen/ghostty.css | 87 +- dist/doxygen/mobile-nav.js | 50 +- dist/doxygen/stylesheet.css | 3254 +++++++++++++++++++---------------- 3 files changed, 1801 insertions(+), 1590 deletions(-) diff --git a/dist/doxygen/ghostty.css b/dist/doxygen/ghostty.css index 11b5130ee..678414b70 100644 --- a/dist/doxygen/ghostty.css +++ b/dist/doxygen/ghostty.css @@ -159,7 +159,7 @@ span.lineno a { #page-nav-toggle { display: none !important; } - + #page-nav { position: relative !important; width: 250px !important; @@ -175,17 +175,17 @@ span.lineno a { body { font-size: 14px !important; } - + /* Make navigation tree collapsible on mobile */ #side-nav { display: none; } - + #doc-content { margin-left: 0 !important; margin-right: 0 !important; } - + /* Make right sidebar (page-nav) overlay on mobile */ #page-nav { position: fixed !important; @@ -200,11 +200,11 @@ span.lineno a { overflow-y: auto !important; -webkit-overflow-scrolling: touch !important; } - + #page-nav.mobile-open { right: 0 !important; } - + /* Hamburger menu button for page nav */ #page-nav-toggle { display: block !important; @@ -221,7 +221,7 @@ span.lineno a { padding: 8px !important; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3) !important; } - + #page-nav-toggle span { display: block !important; width: 24px !important; @@ -231,7 +231,7 @@ span.lineno a { border-radius: 2px !important; transition: 0.3s !important; } - + /* Mobile overlay backdrop */ #page-nav-backdrop { display: none !important; @@ -243,29 +243,29 @@ span.lineno a { background: rgba(0, 0, 0, 0.5) !important; z-index: 9999 !important; } - + #page-nav-backdrop.active { display: block !important; } - + /* Improve header and navigation */ #top { height: auto !important; } - + #titlearea { padding: 10px !important; } - + #projectname { font-size: 18px !important; } - + #projectbrief, #projectnumber { font-size: 12px !important; } - + /* Make tabs stack better on mobile */ #navrow1, #navrow2, @@ -275,33 +275,33 @@ span.lineno a { white-space: nowrap !important; -webkit-overflow-scrolling: touch !important; } - + .tablist li { display: inline-block !important; } - + /* Content adjustments */ .contents { padding: 10px !important; width: 100% !important; box-sizing: border-box !important; } - + .header { padding: 5px !important; } - + /* Code blocks */ .fragment { font-size: 12px !important; overflow-x: auto !important; -webkit-overflow-scrolling: touch !important; } - + div.line { font-size: 12px !important; } - + /* Tables */ table { display: block !important; @@ -309,52 +309,65 @@ span.lineno a { -webkit-overflow-scrolling: touch !important; width: 100% !important; } - + .memberdecls table, .fieldtable { font-size: 12px !important; } - + .memtitle { font-size: 14px !important; padding: 8px !important; } - + .memname { font-size: 13px !important; word-break: break-word !important; } - + .memitem { margin: 5px 0 !important; } - + /* Search box */ #MSearchBox { width: 100% !important; right: 0 !important; } - + /* Reduce padding and margins */ - h1, h2, h3, h4, h5, h6 { + h1, + h2, + h3, + h4, + h5, + h6 { margin-top: 10px !important; margin-bottom: 8px !important; } - - h1 { font-size: 22px !important; } - h2 { font-size: 18px !important; } - h3 { font-size: 16px !important; } - h4 { font-size: 14px !important; } - + + h1 { + font-size: 22px !important; + } + h2 { + font-size: 18px !important; + } + h3 { + font-size: 16px !important; + } + h4 { + font-size: 14px !important; + } + /* Directory/file listings */ .directory .levels span { display: none !important; } - + .directory .arrow { margin-right: 5px !important; } - + /* Treeview adjustments */ #nav-tree { width: 100% !important; @@ -366,11 +379,11 @@ span.lineno a { .contents { padding: 15px !important; } - + #side-nav { width: 200px !important; } - + #doc-content { margin-left: 200px !important; } diff --git a/dist/doxygen/mobile-nav.js b/dist/doxygen/mobile-nav.js index 1262360b8..c6c4e2214 100644 --- a/dist/doxygen/mobile-nav.js +++ b/dist/doxygen/mobile-nav.js @@ -2,7 +2,7 @@ * Mobile navigation toggle for Doxygen documentation */ -(function() { +(function () { // Only run on mobile devices function isMobile() { return window.innerWidth <= 767; @@ -10,56 +10,56 @@ function initMobileNav() { if (!isMobile()) return; - - const pageNav = document.getElementById('page-nav'); + + const pageNav = document.getElementById("page-nav"); if (!pageNav) return; // Create toggle button - const toggleBtn = document.createElement('button'); - toggleBtn.id = 'page-nav-toggle'; - toggleBtn.setAttribute('aria-label', 'Toggle page navigation'); - toggleBtn.innerHTML = ''; + const toggleBtn = document.createElement("button"); + toggleBtn.id = "page-nav-toggle"; + toggleBtn.setAttribute("aria-label", "Toggle page navigation"); + toggleBtn.innerHTML = ""; document.body.appendChild(toggleBtn); // Create backdrop - const backdrop = document.createElement('div'); - backdrop.id = 'page-nav-backdrop'; + const backdrop = document.createElement("div"); + backdrop.id = "page-nav-backdrop"; document.body.appendChild(backdrop); // Toggle function function toggleNav() { - const isOpen = pageNav.classList.toggle('mobile-open'); - backdrop.classList.toggle('active', isOpen); - document.body.style.overflow = isOpen ? 'hidden' : ''; + const isOpen = pageNav.classList.toggle("mobile-open"); + backdrop.classList.toggle("active", isOpen); + document.body.style.overflow = isOpen ? "hidden" : ""; } // Event listeners - toggleBtn.addEventListener('click', toggleNav); - backdrop.addEventListener('click', toggleNav); + toggleBtn.addEventListener("click", toggleNav); + backdrop.addEventListener("click", toggleNav); // Close on escape key - document.addEventListener('keydown', function(e) { - if (e.key === 'Escape' && pageNav.classList.contains('mobile-open')) { + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && pageNav.classList.contains("mobile-open")) { toggleNav(); } }); } // Initialize on load and resize - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initMobileNav); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initMobileNav); } else { initMobileNav(); } - window.addEventListener('resize', function() { - const pageNav = document.getElementById('page-nav'); - const backdrop = document.getElementById('page-nav-backdrop'); - + window.addEventListener("resize", function () { + const pageNav = document.getElementById("page-nav"); + const backdrop = document.getElementById("page-nav-backdrop"); + if (!isMobile() && pageNav) { - pageNav.classList.remove('mobile-open'); - if (backdrop) backdrop.classList.remove('active'); - document.body.style.overflow = ''; + pageNav.classList.remove("mobile-open"); + if (backdrop) backdrop.classList.remove("active"); + document.body.style.overflow = ""; } }); })(); diff --git a/dist/doxygen/stylesheet.css b/dist/doxygen/stylesheet.css index 5d2eecd6d..21f34435e 100644 --- a/dist/doxygen/stylesheet.css +++ b/dist/doxygen/stylesheet.css @@ -1,509 +1,545 @@ /* The standard CSS for doxygen 1.14.0*/ html { -/* page base colors */ ---page-background-color: white; ---page-foreground-color: black; ---page-link-color: #3D578C; ---page-visited-link-color: #3D578C; ---page-external-link-color: #334975; + /* page base colors */ + --page-background-color: white; + --page-foreground-color: black; + --page-link-color: #3d578c; + --page-visited-link-color: #3d578c; + --page-external-link-color: #334975; -/* index */ ---index-odd-item-bg-color: #F8F9FC; ---index-even-item-bg-color: white; ---index-header-color: black; ---index-separator-color: #A0A0A0; + /* index */ + --index-odd-item-bg-color: #f8f9fc; + --index-even-item-bg-color: white; + --index-header-color: black; + --index-separator-color: #a0a0a0; -/* header */ ---header-background-color: #F9FAFC; ---header-separator-color: #C4CFE5; ---group-header-separator-color: #D9E0EE; ---group-header-color: #354C7B; ---inherit-header-color: gray; + /* header */ + --header-background-color: #f9fafc; + --header-separator-color: #c4cfe5; + --group-header-separator-color: #d9e0ee; + --group-header-color: #354c7b; + --inherit-header-color: gray; ---footer-foreground-color: #2A3D61; ---footer-logo-width: 75px; ---citation-label-color: #334975; ---glow-color: cyan; + --footer-foreground-color: #2a3d61; + --footer-logo-width: 75px; + --citation-label-color: #334975; + --glow-color: cyan; ---title-background-color: white; ---title-separator-color: #C4CFE5; ---directory-separator-color: #9CAFD4; ---separator-color: #4A6AAA; + --title-background-color: white; + --title-separator-color: #c4cfe5; + --directory-separator-color: #9cafd4; + --separator-color: #4a6aaa; ---blockquote-background-color: #F7F8FB; ---blockquote-border-color: #9CAFD4; + --blockquote-background-color: #f7f8fb; + --blockquote-border-color: #9cafd4; ---scrollbar-thumb-color: #C4CFE5; ---scrollbar-background-color: #F9FAFC; + --scrollbar-thumb-color: #c4cfe5; + --scrollbar-background-color: #f9fafc; ---icon-background-color: #728DC1; ---icon-foreground-color: white; -/* + --icon-background-color: #728dc1; + --icon-foreground-color: white; + /* --icon-doc-image: url('doc.svg'); --icon-folder-open-image: url('folderopen.svg'); --icon-folder-closed-image: url('folderclosed.svg');*/ ---icon-folder-open-fill-color: #C4CFE5; ---icon-folder-fill-color: #D8DFEE; ---icon-folder-border-color: #4665A2; ---icon-doc-fill-color: #D8DFEE; ---icon-doc-border-color: #4665A2; + --icon-folder-open-fill-color: #c4cfe5; + --icon-folder-fill-color: #d8dfee; + --icon-folder-border-color: #4665a2; + --icon-doc-fill-color: #d8dfee; + --icon-doc-border-color: #4665a2; -/* brief member declaration list */ ---memdecl-background-color: #F9FAFC; ---memdecl-separator-color: #DEE4F0; ---memdecl-foreground-color: #555; ---memdecl-template-color: #4665A2; ---memdecl-border-color: #D5DDEC; + /* brief member declaration list */ + --memdecl-background-color: #f9fafc; + --memdecl-separator-color: #dee4f0; + --memdecl-foreground-color: #555; + --memdecl-template-color: #4665a2; + --memdecl-border-color: #d5ddec; -/* detailed member list */ ---memdef-border-color: #A8B8D9; ---memdef-title-background-color: #E2E8F2; ---memdef-proto-background-color: #EEF1F7; ---memdef-proto-text-color: #253555; ---memdef-doc-background-color: white; ---memdef-param-name-color: #602020; ---memdef-template-color: #4665A2; + /* detailed member list */ + --memdef-border-color: #a8b8d9; + --memdef-title-background-color: #e2e8f2; + --memdef-proto-background-color: #eef1f7; + --memdef-proto-text-color: #253555; + --memdef-doc-background-color: white; + --memdef-param-name-color: #602020; + --memdef-template-color: #4665a2; -/* tables */ ---table-cell-border-color: #2D4068; ---table-header-background-color: #374F7F; ---table-header-foreground-color: #FFFFFF; + /* tables */ + --table-cell-border-color: #2d4068; + --table-header-background-color: #374f7f; + --table-header-foreground-color: #ffffff; -/* labels */ ---label-background-color: #728DC1; ---label-left-top-border-color: #5373B4; ---label-right-bottom-border-color: #C4CFE5; ---label-foreground-color: white; + /* labels */ + --label-background-color: #728dc1; + --label-left-top-border-color: #5373b4; + --label-right-bottom-border-color: #c4cfe5; + --label-foreground-color: white; -/** navigation bar/tree/menu */ ---nav-background-color: #F9FAFC; ---nav-foreground-color: #364D7C; ---nav-border-color: #C4CFE5; ---nav-breadcrumb-separator-color: #C4CFE5; ---nav-breadcrumb-active-bg: #EEF1F7; ---nav-breadcrumb-color: #354C7B; ---nav-breadcrumb-border-color: #E1E7F2; ---nav-splitbar-bg-color: #DCE2EF; ---nav-splitbar-handle-color: #9CAFD4; ---nav-font-size-level1: 13px; ---nav-font-size-level2: 10px; ---nav-font-size-level3: 9px; ---nav-text-normal-color: #283A5D; ---nav-text-hover-color: white; ---nav-text-active-color: white; ---nav-menu-button-color: #364D7C; ---nav-menu-background-color: white; ---nav-menu-foreground-color: #555555; ---nav-menu-active-bg: #DCE2EF; ---nav-menu-active-color: #9CAFD4; ---nav-menu-toggle-color: rgba(255, 255, 255, 0.5); ---nav-arrow-color: #B6C4DF; ---nav-arrow-selected-color: #90A5CE; + /** navigation bar/tree/menu */ + --nav-background-color: #f9fafc; + --nav-foreground-color: #364d7c; + --nav-border-color: #c4cfe5; + --nav-breadcrumb-separator-color: #c4cfe5; + --nav-breadcrumb-active-bg: #eef1f7; + --nav-breadcrumb-color: #354c7b; + --nav-breadcrumb-border-color: #e1e7f2; + --nav-splitbar-bg-color: #dce2ef; + --nav-splitbar-handle-color: #9cafd4; + --nav-font-size-level1: 13px; + --nav-font-size-level2: 10px; + --nav-font-size-level3: 9px; + --nav-text-normal-color: #283a5d; + --nav-text-hover-color: white; + --nav-text-active-color: white; + --nav-menu-button-color: #364d7c; + --nav-menu-background-color: white; + --nav-menu-foreground-color: #555555; + --nav-menu-active-bg: #dce2ef; + --nav-menu-active-color: #9cafd4; + --nav-menu-toggle-color: rgba(255, 255, 255, 0.5); + --nav-arrow-color: #b6c4df; + --nav-arrow-selected-color: #90a5ce; -/* sync icon */ ---sync-icon-border-color: #C4CFE5; ---sync-icon-background-color: #F9FAFC; ---sync-icon-selected-background-color: #EEF1F7; ---sync-icon-color: #C4CFE5; ---sync-icon-selected-color: #6884BD; + /* sync icon */ + --sync-icon-border-color: #c4cfe5; + --sync-icon-background-color: #f9fafc; + --sync-icon-selected-background-color: #eef1f7; + --sync-icon-color: #c4cfe5; + --sync-icon-selected-color: #6884bd; -/* table of contents */ ---toc-background-color: #F4F6FA; ---toc-border-color: #D8DFEE; ---toc-header-color: #4665A2; ---toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); + /* table of contents */ + --toc-background-color: #f4f6fa; + --toc-border-color: #d8dfee; + --toc-header-color: #4665a2; + --toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); -/** search field */ ---search-background-color: white; ---search-foreground-color: #909090; ---search-active-color: black; ---search-filter-background-color: rgba(255,255,255,.7); ---search-filter-backdrop-filter: blur(4px); ---search-filter-foreground-color: black; ---search-filter-border-color: rgba(150,150,150,.4); ---search-filter-highlight-text-color: white; ---search-filter-highlight-bg-color: #3D578C; ---search-results-foreground-color: #425E97; ---search-results-background-color: rgba(255,255,255,.8); ---search-results-backdrop-filter: blur(4px); ---search-results-border-color: rgba(150,150,150,.4); ---search-box-border-color: #B6C4DF; ---search-close-icon-bg-color: #A0A0A0; ---search-close-icon-fg-color: white; + /** search field */ + --search-background-color: white; + --search-foreground-color: #909090; + --search-active-color: black; + --search-filter-background-color: rgba(255, 255, 255, 0.7); + --search-filter-backdrop-filter: blur(4px); + --search-filter-foreground-color: black; + --search-filter-border-color: rgba(150, 150, 150, 0.4); + --search-filter-highlight-text-color: white; + --search-filter-highlight-bg-color: #3d578c; + --search-results-foreground-color: #425e97; + --search-results-background-color: rgba(255, 255, 255, 0.8); + --search-results-backdrop-filter: blur(4px); + --search-results-border-color: rgba(150, 150, 150, 0.4); + --search-box-border-color: #b6c4df; + --search-close-icon-bg-color: #a0a0a0; + --search-close-icon-fg-color: white; -/** code fragments */ ---code-keyword-color: #008000; ---code-type-keyword-color: #604020; ---code-flow-keyword-color: #E08000; ---code-comment-color: #800000; ---code-preprocessor-color: #806020; ---code-string-literal-color: #002080; ---code-char-literal-color: #008080; ---code-xml-cdata-color: black; ---code-vhdl-digit-color: #FF00FF; ---code-vhdl-char-color: #000000; ---code-vhdl-keyword-color: #700070; ---code-vhdl-logic-color: #FF0000; ---code-link-color: #4665A2; ---code-external-link-color: #4665A2; ---fragment-foreground-color: black; ---fragment-background-color: #FBFCFD; ---fragment-border-color: #C4CFE5; ---fragment-lineno-border-color: #00FF00; ---fragment-lineno-background-color: #E8E8E8; ---fragment-lineno-foreground-color: black; ---fragment-lineno-link-fg-color: #4665A2; ---fragment-lineno-link-bg-color: #D8D8D8; ---fragment-lineno-link-hover-fg-color: #4665A2; ---fragment-lineno-link-hover-bg-color: #C8C8C8; ---fragment-copy-ok-color: #2EC82E; ---tooltip-foreground-color: black; ---tooltip-background-color: rgba(255,255,255,0.8); ---tooltip-arrow-background-color: white; ---tooltip-border-color: rgba(150,150,150,0.7); ---tooltip-backdrop-filter: blur(3px); ---tooltip-doc-color: grey; ---tooltip-declaration-color: #006318; ---tooltip-link-color: #4665A2; ---tooltip-shadow: 0 4px 8px 0 rgba(0,0,0,.25); ---fold-line-color: #808080; + /** code fragments */ + --code-keyword-color: #008000; + --code-type-keyword-color: #604020; + --code-flow-keyword-color: #e08000; + --code-comment-color: #800000; + --code-preprocessor-color: #806020; + --code-string-literal-color: #002080; + --code-char-literal-color: #008080; + --code-xml-cdata-color: black; + --code-vhdl-digit-color: #ff00ff; + --code-vhdl-char-color: #000000; + --code-vhdl-keyword-color: #700070; + --code-vhdl-logic-color: #ff0000; + --code-link-color: #4665a2; + --code-external-link-color: #4665a2; + --fragment-foreground-color: black; + --fragment-background-color: #fbfcfd; + --fragment-border-color: #c4cfe5; + --fragment-lineno-border-color: #00ff00; + --fragment-lineno-background-color: #e8e8e8; + --fragment-lineno-foreground-color: black; + --fragment-lineno-link-fg-color: #4665a2; + --fragment-lineno-link-bg-color: #d8d8d8; + --fragment-lineno-link-hover-fg-color: #4665a2; + --fragment-lineno-link-hover-bg-color: #c8c8c8; + --fragment-copy-ok-color: #2ec82e; + --tooltip-foreground-color: black; + --tooltip-background-color: rgba(255, 255, 255, 0.8); + --tooltip-arrow-background-color: white; + --tooltip-border-color: rgba(150, 150, 150, 0.7); + --tooltip-backdrop-filter: blur(3px); + --tooltip-doc-color: grey; + --tooltip-declaration-color: #006318; + --tooltip-link-color: #4665a2; + --tooltip-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.25); + --fold-line-color: #808080; -/** font-family */ ---font-family-normal: system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; ---font-family-monospace: 'JetBrains Mono',Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace,fixed; ---font-family-nav: 'Lucida Grande',Geneva,Helvetica,Arial,sans-serif; ---font-family-title: system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; ---font-family-toc: Verdana,'DejaVu Sans',Geneva,sans-serif; ---font-family-search: Arial,Verdana,sans-serif; ---font-family-icon: Arial,Helvetica; ---font-family-tooltip: Roboto,sans-serif; + /** font-family */ + --font-family-normal: + system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, + sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --font-family-monospace: + "JetBrains Mono", Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace, + fixed; + --font-family-nav: "Lucida Grande", Geneva, Helvetica, Arial, sans-serif; + --font-family-title: + system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, + sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --font-family-toc: Verdana, "DejaVu Sans", Geneva, sans-serif; + --font-family-search: Arial, Verdana, sans-serif; + --font-family-icon: Arial, Helvetica; + --font-family-tooltip: Roboto, sans-serif; -/** special sections */ ---warning-color-bg: #f8d1cc; ---warning-color-hl: #b61825; ---warning-color-text: #75070f; ---note-color-bg: #faf3d8; ---note-color-hl: #f3a600; ---note-color-text: #5f4204; ---todo-color-bg: #e4f3ff; ---todo-color-hl: #1879C4; ---todo-color-text: #274a5c; ---test-color-bg: #e8e8ff; ---test-color-hl: #3939C4; ---test-color-text: #1a1a5c; ---deprecated-color-bg: #ecf0f3; ---deprecated-color-hl: #5b6269; ---deprecated-color-text: #43454a; ---bug-color-bg: #e4dafd; ---bug-color-hl: #5b2bdd; ---bug-color-text: #2a0d72; ---invariant-color-bg: #d8f1e3; ---invariant-color-hl: #44b86f; ---invariant-color-text: #265532; + /** special sections */ + --warning-color-bg: #f8d1cc; + --warning-color-hl: #b61825; + --warning-color-text: #75070f; + --note-color-bg: #faf3d8; + --note-color-hl: #f3a600; + --note-color-text: #5f4204; + --todo-color-bg: #e4f3ff; + --todo-color-hl: #1879c4; + --todo-color-text: #274a5c; + --test-color-bg: #e8e8ff; + --test-color-hl: #3939c4; + --test-color-text: #1a1a5c; + --deprecated-color-bg: #ecf0f3; + --deprecated-color-hl: #5b6269; + --deprecated-color-text: #43454a; + --bug-color-bg: #e4dafd; + --bug-color-hl: #5b2bdd; + --bug-color-text: #2a0d72; + --invariant-color-bg: #d8f1e3; + --invariant-color-hl: #44b86f; + --invariant-color-text: #265532; } @media (prefers-color-scheme: dark) { html:not(.dark-mode) { color-scheme: dark; -/* page base colors */ ---page-background-color: black; ---page-foreground-color: #C9D1D9; ---page-link-color: #90A5CE; ---page-visited-link-color: #90A5CE; ---page-external-link-color: #A3B4D7; + /* page base colors */ + --page-background-color: black; + --page-foreground-color: #c9d1d9; + --page-link-color: #90a5ce; + --page-visited-link-color: #90a5ce; + --page-external-link-color: #a3b4d7; -/* index */ ---index-odd-item-bg-color: #0B101A; ---index-even-item-bg-color: black; ---index-header-color: #C4CFE5; ---index-separator-color: #334975; + /* index */ + --index-odd-item-bg-color: #0b101a; + --index-even-item-bg-color: black; + --index-header-color: #c4cfe5; + --index-separator-color: #334975; -/* header */ ---header-background-color: #070B11; ---header-separator-color: #141C2E; ---group-header-separator-color: #1D2A43; ---group-header-color: #90A5CE; ---inherit-header-color: #A0A0A0; + /* header */ + --header-background-color: #070b11; + --header-separator-color: #141c2e; + --group-header-separator-color: #1d2a43; + --group-header-color: #90a5ce; + --inherit-header-color: #a0a0a0; ---footer-foreground-color: #5B7AB7; ---footer-logo-width: 60px; ---citation-label-color: #90A5CE; ---glow-color: cyan; + --footer-foreground-color: #5b7ab7; + --footer-logo-width: 60px; + --citation-label-color: #90a5ce; + --glow-color: cyan; ---title-background-color: #090D16; ---title-separator-color: #212F4B; ---directory-separator-color: #283A5D; ---separator-color: #283A5D; + --title-background-color: #090d16; + --title-separator-color: #212f4b; + --directory-separator-color: #283a5d; + --separator-color: #283a5d; ---blockquote-background-color: #101826; ---blockquote-border-color: #283A5D; + --blockquote-background-color: #101826; + --blockquote-border-color: #283a5d; ---scrollbar-thumb-color: #2C3F65; ---scrollbar-background-color: #070B11; + --scrollbar-thumb-color: #2c3f65; + --scrollbar-background-color: #070b11; ---icon-background-color: #334975; ---icon-foreground-color: #C4CFE5; ---icon-folder-open-fill-color: #4665A2; ---icon-folder-fill-color: #5373B4; ---icon-folder-border-color: #C4CFE5; ---icon-doc-fill-color: #6884BD; ---icon-doc-border-color: #C4CFE5; + --icon-background-color: #334975; + --icon-foreground-color: #c4cfe5; + --icon-folder-open-fill-color: #4665a2; + --icon-folder-fill-color: #5373b4; + --icon-folder-border-color: #c4cfe5; + --icon-doc-fill-color: #6884bd; + --icon-doc-border-color: #c4cfe5; -/* brief member declaration list */ ---memdecl-background-color: #0B101A; ---memdecl-separator-color: #2C3F65; ---memdecl-foreground-color: #BBB; ---memdecl-template-color: #7C95C6; ---memdecl-border-color: #233250; + /* brief member declaration list */ + --memdecl-background-color: #0b101a; + --memdecl-separator-color: #2c3f65; + --memdecl-foreground-color: #bbb; + --memdecl-template-color: #7c95c6; + --memdecl-border-color: #233250; -/* detailed member list */ ---memdef-border-color: #233250; ---memdef-title-background-color: #1B2840; ---memdef-proto-background-color: #19243A; ---memdef-proto-text-color: #9DB0D4; ---memdef-doc-background-color: black; ---memdef-param-name-color: #D28757; ---memdef-template-color: #7C95C6; + /* detailed member list */ + --memdef-border-color: #233250; + --memdef-title-background-color: #1b2840; + --memdef-proto-background-color: #19243a; + --memdef-proto-text-color: #9db0d4; + --memdef-doc-background-color: black; + --memdef-param-name-color: #d28757; + --memdef-template-color: #7c95c6; -/* tables */ ---table-cell-border-color: #283A5D; ---table-header-background-color: #283A5D; ---table-header-foreground-color: #C4CFE5; + /* tables */ + --table-cell-border-color: #283a5d; + --table-header-background-color: #283a5d; + --table-header-foreground-color: #c4cfe5; -/* labels */ ---label-background-color: #354C7B; ---label-left-top-border-color: #4665A2; ---label-right-bottom-border-color: #283A5D; ---label-foreground-color: #CCCCCC; + /* labels */ + --label-background-color: #354c7b; + --label-left-top-border-color: #4665a2; + --label-right-bottom-border-color: #283a5d; + --label-foreground-color: #cccccc; -/** navigation bar/tree/menu */ ---nav-background-color: #101826; ---nav-foreground-color: #364D7C; ---nav-border-color: #212F4B; ---nav-breadcrumb-separator-color: #212F4B; ---nav-breadcrumb-active-bg: #1D2A43; ---nav-breadcrumb-color: #90A5CE; ---nav-breadcrumb-border-color: #2A3D61; ---nav-splitbar-bg-color: #283A5D; ---nav-splitbar-handle-color: #4665A2; ---nav-font-size-level1: 13px; ---nav-font-size-level2: 10px; ---nav-font-size-level3: 9px; ---nav-text-normal-color: #B6C4DF; ---nav-text-hover-color: #DCE2EF; ---nav-text-active-color: #DCE2EF; ---nav-menu-button-color: #B6C4DF; ---nav-menu-background-color: #05070C; ---nav-menu-foreground-color: #BBBBBB; ---nav-menu-active-bg: #1D2A43; ---nav-menu-active-color: #C9D3E7; ---nav-menu-toggle-color: rgba(255, 255, 255, 0.2); ---nav-arrow-color: #4665A2; ---nav-arrow-selected-color: #6884BD; + /** navigation bar/tree/menu */ + --nav-background-color: #101826; + --nav-foreground-color: #364d7c; + --nav-border-color: #212f4b; + --nav-breadcrumb-separator-color: #212f4b; + --nav-breadcrumb-active-bg: #1d2a43; + --nav-breadcrumb-color: #90a5ce; + --nav-breadcrumb-border-color: #2a3d61; + --nav-splitbar-bg-color: #283a5d; + --nav-splitbar-handle-color: #4665a2; + --nav-font-size-level1: 13px; + --nav-font-size-level2: 10px; + --nav-font-size-level3: 9px; + --nav-text-normal-color: #b6c4df; + --nav-text-hover-color: #dce2ef; + --nav-text-active-color: #dce2ef; + --nav-menu-button-color: #b6c4df; + --nav-menu-background-color: #05070c; + --nav-menu-foreground-color: #bbbbbb; + --nav-menu-active-bg: #1d2a43; + --nav-menu-active-color: #c9d3e7; + --nav-menu-toggle-color: rgba(255, 255, 255, 0.2); + --nav-arrow-color: #4665a2; + --nav-arrow-selected-color: #6884bd; -/* sync icon */ ---sync-icon-border-color: #212F4B; ---sync-icon-background-color: #101826; ---sync-icon-selected-background-color: #1D2A43; ---sync-icon-color: #4665A2; ---sync-icon-selected-color: #5373B4; + /* sync icon */ + --sync-icon-border-color: #212f4b; + --sync-icon-background-color: #101826; + --sync-icon-selected-background-color: #1d2a43; + --sync-icon-color: #4665a2; + --sync-icon-selected-color: #5373b4; -/* table of contents */ ---toc-background-color: #151E30; ---toc-border-color: #202E4A; ---toc-header-color: #A3B4D7; ---toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); + /* table of contents */ + --toc-background-color: #151e30; + --toc-border-color: #202e4a; + --toc-header-color: #a3b4d7; + --toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); -/** search field */ ---search-background-color: black; ---search-foreground-color: #C5C5C5; ---search-active-color: #F5F5F5; ---search-filter-background-color: #101826; ---search-filter-foreground-color: #90A5CE; ---search-filter-backdrop-filter: none; ---search-filter-border-color: #7C95C6; ---search-filter-highlight-text-color: #BCC9E2; ---search-filter-highlight-bg-color: #283A5D; ---search-results-background-color: black; ---search-results-foreground-color: #90A5CE; ---search-results-backdrop-filter: none; ---search-results-border-color: #334975; ---search-box-border-color: #334975; ---search-close-icon-bg-color: #909090; ---search-close-icon-fg-color: black; + /** search field */ + --search-background-color: black; + --search-foreground-color: #c5c5c5; + --search-active-color: #f5f5f5; + --search-filter-background-color: #101826; + --search-filter-foreground-color: #90a5ce; + --search-filter-backdrop-filter: none; + --search-filter-border-color: #7c95c6; + --search-filter-highlight-text-color: #bcc9e2; + --search-filter-highlight-bg-color: #283a5d; + --search-results-background-color: black; + --search-results-foreground-color: #90a5ce; + --search-results-backdrop-filter: none; + --search-results-border-color: #334975; + --search-box-border-color: #334975; + --search-close-icon-bg-color: #909090; + --search-close-icon-fg-color: black; -/** code fragments */ ---code-keyword-color: #CC99CD; ---code-type-keyword-color: #AB99CD; ---code-flow-keyword-color: #E08000; ---code-comment-color: #717790; ---code-preprocessor-color: #65CABE; ---code-string-literal-color: #7EC699; ---code-char-literal-color: #00E0F0; ---code-xml-cdata-color: #C9D1D9; ---code-vhdl-digit-color: #FF00FF; ---code-vhdl-char-color: #C0C0C0; ---code-vhdl-keyword-color: #CF53C9; ---code-vhdl-logic-color: #FF0000; ---code-link-color: #79C0FF; ---code-external-link-color: #79C0FF; ---fragment-foreground-color: #C9D1D9; ---fragment-background-color: #090D16; ---fragment-border-color: #30363D; ---fragment-lineno-border-color: #30363D; ---fragment-lineno-background-color: black; ---fragment-lineno-foreground-color: #6E7681; ---fragment-lineno-link-fg-color: #6E7681; ---fragment-lineno-link-bg-color: #303030; ---fragment-lineno-link-hover-fg-color: #8E96A1; ---fragment-lineno-link-hover-bg-color: #505050; ---fragment-copy-ok-color: #0EA80E; ---tooltip-foreground-color: #C9D1D9; ---tooltip-background-color: #202020; ---tooltip-arrow-background-color: #202020; ---tooltip-backdrop-filter: none; ---tooltip-border-color: #C9D1D9; ---tooltip-doc-color: #D9E1E9; ---tooltip-declaration-color: #20C348; ---tooltip-link-color: #79C0FF; ---tooltip-shadow: none; ---fold-line-color: #808080; + /** code fragments */ + --code-keyword-color: #cc99cd; + --code-type-keyword-color: #ab99cd; + --code-flow-keyword-color: #e08000; + --code-comment-color: #717790; + --code-preprocessor-color: #65cabe; + --code-string-literal-color: #7ec699; + --code-char-literal-color: #00e0f0; + --code-xml-cdata-color: #c9d1d9; + --code-vhdl-digit-color: #ff00ff; + --code-vhdl-char-color: #c0c0c0; + --code-vhdl-keyword-color: #cf53c9; + --code-vhdl-logic-color: #ff0000; + --code-link-color: #79c0ff; + --code-external-link-color: #79c0ff; + --fragment-foreground-color: #c9d1d9; + --fragment-background-color: #090d16; + --fragment-border-color: #30363d; + --fragment-lineno-border-color: #30363d; + --fragment-lineno-background-color: black; + --fragment-lineno-foreground-color: #6e7681; + --fragment-lineno-link-fg-color: #6e7681; + --fragment-lineno-link-bg-color: #303030; + --fragment-lineno-link-hover-fg-color: #8e96a1; + --fragment-lineno-link-hover-bg-color: #505050; + --fragment-copy-ok-color: #0ea80e; + --tooltip-foreground-color: #c9d1d9; + --tooltip-background-color: #202020; + --tooltip-arrow-background-color: #202020; + --tooltip-backdrop-filter: none; + --tooltip-border-color: #c9d1d9; + --tooltip-doc-color: #d9e1e9; + --tooltip-declaration-color: #20c348; + --tooltip-link-color: #79c0ff; + --tooltip-shadow: none; + --fold-line-color: #808080; -/** font-family */ ---font-family-normal: system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; ---font-family-monospace: 'JetBrains Mono',Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace,fixed; ---font-family-nav: 'Lucida Grande',Geneva,Helvetica,Arial,sans-serif; ---font-family-title: system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; ---font-family-toc: Verdana,'DejaVu Sans',Geneva,sans-serif; ---font-family-search: Arial,Verdana,sans-serif; ---font-family-icon: Arial,Helvetica; ---font-family-tooltip: Roboto,sans-serif; + /** font-family */ + --font-family-normal: + system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, + sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --font-family-monospace: + "JetBrains Mono", Consolas, Monaco, "Andale Mono", "Ubuntu Mono", + monospace, fixed; + --font-family-nav: "Lucida Grande", Geneva, Helvetica, Arial, sans-serif; + --font-family-title: + system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, + sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --font-family-toc: Verdana, "DejaVu Sans", Geneva, sans-serif; + --font-family-search: Arial, Verdana, sans-serif; + --font-family-icon: Arial, Helvetica; + --font-family-tooltip: Roboto, sans-serif; -/** special sections */ ---warning-color-bg: #2e1917; ---warning-color-hl: #ad2617; ---warning-color-text: #f5b1aa; ---note-color-bg: #3b2e04; ---note-color-hl: #f1b602; ---note-color-text: #ceb670; ---todo-color-bg: #163750; ---todo-color-hl: #1982D2; ---todo-color-text: #dcf0fa; ---test-color-bg: #121258; ---test-color-hl: #4242cf; ---test-color-text: #c0c0da; ---deprecated-color-bg: #2e323b; ---deprecated-color-hl: #738396; ---deprecated-color-text: #abb0bd; ---bug-color-bg: #2a2536; ---bug-color-hl: #7661b3; ---bug-color-text: #ae9ed6; ---invariant-color-bg: #303a35; ---invariant-color-hl: #76ce96; ---invariant-color-text: #cceed5; -}} + /** special sections */ + --warning-color-bg: #2e1917; + --warning-color-hl: #ad2617; + --warning-color-text: #f5b1aa; + --note-color-bg: #3b2e04; + --note-color-hl: #f1b602; + --note-color-text: #ceb670; + --todo-color-bg: #163750; + --todo-color-hl: #1982d2; + --todo-color-text: #dcf0fa; + --test-color-bg: #121258; + --test-color-hl: #4242cf; + --test-color-text: #c0c0da; + --deprecated-color-bg: #2e323b; + --deprecated-color-hl: #738396; + --deprecated-color-text: #abb0bd; + --bug-color-bg: #2a2536; + --bug-color-hl: #7661b3; + --bug-color-text: #ae9ed6; + --invariant-color-bg: #303a35; + --invariant-color-hl: #76ce96; + --invariant-color-text: #cceed5; + } +} body { - background-color: var(--page-background-color); - color: var(--page-foreground-color); + background-color: var(--page-background-color); + color: var(--page-foreground-color); } -body, table, div, p, dl { - font-weight: 400; - font-size: 14px; - font-family: var(--font-family-normal); - line-height: 22px; +body, +table, +div, +p, +dl { + font-weight: 400; + font-size: 14px; + font-family: var(--font-family-normal); + line-height: 22px; } body.resizing { - user-select: none; - -webkit-user-select: none; + user-select: none; + -webkit-user-select: none; } #doc-content { - scrollbar-width: thin; + scrollbar-width: thin; } /* @group Heading Levels */ .title { - font-family: var(--font-family-normal); - line-height: 28px; - font-size: 160%; - font-weight: 400; - margin: 10px 2px; + font-family: var(--font-family-normal); + line-height: 28px; + font-size: 160%; + font-weight: 400; + margin: 10px 2px; } h1.groupheader { - font-size: 150%; + font-size: 150%; } h2.groupheader { - box-shadow: 12px 0 var(--page-background-color), - -12px 0 var(--page-background-color), - 12px 1px var(--group-header-separator-color), - -12px 1px var(--group-header-separator-color); - color: var(--group-header-color); - font-size: 150%; - font-weight: normal; - margin-top: 1.75em; - padding-top: 8px; - padding-bottom: 4px; - width: 100%; + box-shadow: + 12px 0 var(--page-background-color), + -12px 0 var(--page-background-color), + 12px 1px var(--group-header-separator-color), + -12px 1px var(--group-header-separator-color); + color: var(--group-header-color); + font-size: 150%; + font-weight: normal; + margin-top: 1.75em; + padding-top: 8px; + padding-bottom: 4px; + width: 100%; } td h2.groupheader { - box-shadow: 13px 0 var(--page-background-color), - -13px 0 var(--page-background-color), - 13px 1px var(--group-header-separator-color), - -13px 1px var(--group-header-separator-color); + box-shadow: + 13px 0 var(--page-background-color), + -13px 0 var(--page-background-color), + 13px 1px var(--group-header-separator-color), + -13px 1px var(--group-header-separator-color); } h3.groupheader { - font-size: 100%; + font-size: 100%; } -h1, h2, h3, h4, h5, h6 { - -webkit-transition: text-shadow 0.5s linear; - -moz-transition: text-shadow 0.5s linear; - -ms-transition: text-shadow 0.5s linear; - -o-transition: text-shadow 0.5s linear; - transition: text-shadow 0.5s linear; - margin-right: 15px; +h1, +h2, +h3, +h4, +h5, +h6 { + -webkit-transition: text-shadow 0.5s linear; + -moz-transition: text-shadow 0.5s linear; + -ms-transition: text-shadow 0.5s linear; + -o-transition: text-shadow 0.5s linear; + transition: text-shadow 0.5s linear; + margin-right: 15px; } -h1.glow, h2.glow, h3.glow, h4.glow, h5.glow, h6.glow { - text-shadow: 0 0 15px var(--glow-color); +h1.glow, +h2.glow, +h3.glow, +h4.glow, +h5.glow, +h6.glow { + text-shadow: 0 0 15px var(--glow-color); } dt { - font-weight: bold; + font-weight: bold; } -p.startli, p.startdd { - margin-top: 2px; +p.startli, +p.startdd { + margin-top: 2px; } -th p.starttd, th p.intertd, th p.endtd { - font-size: 100%; - font-weight: 700; +th p.starttd, +th p.intertd, +th p.endtd { + font-size: 100%; + font-weight: 700; } p.starttd { - margin-top: 0px; + margin-top: 0px; } p.endli { - margin-bottom: 0px; + margin-bottom: 0px; } p.enddd { - margin-bottom: 4px; + margin-bottom: 4px; } p.endtd { - margin-bottom: 2px; + margin-bottom: 2px; } p.interli { @@ -518,705 +554,785 @@ p.intertd { /* @end */ caption { - font-weight: bold; + font-weight: bold; } span.legend { - font-size: 70%; - text-align: center; + font-size: 70%; + text-align: center; } h3.version { - font-size: 90%; - text-align: center; + font-size: 90%; + text-align: center; } div.navtab { - margin-right: 6px; - padding-right: 6px; - text-align: right; - line-height: 110%; - background-color: var(--nav-background-color); + margin-right: 6px; + padding-right: 6px; + text-align: right; + line-height: 110%; + background-color: var(--nav-background-color); } div.navtab table { - border-spacing: 0; + border-spacing: 0; } td.navtab { - padding-right: 6px; - padding-left: 6px; + padding-right: 6px; + padding-left: 6px; } td.navtabHL { - padding-right: 6px; - padding-left: 6px; - border-radius: 0 6px 6px 0; - background-color: var(--nav-menu-active-bg); + padding-right: 6px; + padding-left: 6px; + border-radius: 0 6px 6px 0; + background-color: var(--nav-menu-active-bg); } -div.qindex{ - text-align: center; - width: 100%; - line-height: 140%; - font-size: 130%; - color: var(--index-separator-color); +div.qindex { + text-align: center; + width: 100%; + line-height: 140%; + font-size: 130%; + color: var(--index-separator-color); } #main-menu a:focus { - outline: auto; - z-index: 10; - position: relative; + outline: auto; + z-index: 10; + position: relative; } -dt.alphachar{ - font-size: 180%; - font-weight: bold; +dt.alphachar { + font-size: 180%; + font-weight: bold; } -.alphachar a{ - color: var(--index-header-color); +.alphachar a { + color: var(--index-header-color); } -.alphachar a:hover, .alphachar a:visited{ - text-decoration: none; +.alphachar a:hover, +.alphachar a:visited { + text-decoration: none; } .classindex dl { - padding: 25px; - column-count:1 + padding: 25px; + column-count: 1; } .classindex dd { - display:inline-block; - margin-left: 50px; - width: 90%; - line-height: 1.15em; + display: inline-block; + margin-left: 50px; + width: 90%; + line-height: 1.15em; } .classindex dl.even { - background-color: var(--index-even-item-bg-color); + background-color: var(--index-even-item-bg-color); } .classindex dl.odd { - background-color: var(--index-odd-item-bg-color); + background-color: var(--index-odd-item-bg-color); } -@media(min-width: 1120px) { - .classindex dl { - column-count:2 - } +@media (min-width: 1120px) { + .classindex dl { + column-count: 2; + } } -@media(min-width: 1320px) { - .classindex dl { - column-count:3 - } +@media (min-width: 1320px) { + .classindex dl { + column-count: 3; + } } - /* @group Link Styling */ a { - color: var(--page-link-color); - font-weight: normal; - text-decoration: none; + color: var(--page-link-color); + font-weight: normal; + text-decoration: none; } .contents a:visited { - color: var(--page-visited-link-color); + color: var(--page-visited-link-color); } span.label a:hover { - text-decoration: none; - background: linear-gradient(to bottom, transparent 0,transparent calc(100% - 1px), currentColor 100%); + text-decoration: none; + background: linear-gradient( + to bottom, + transparent 0, + transparent calc(100% - 1px), + currentColor 100% + ); } a.el { - font-weight: bold; + font-weight: bold; } a.elRef { } -a.el, a.el:visited, a.code, a.code:visited, a.line, a.line:visited { - color: var(--page-link-color); +a.el, +a.el:visited, +a.code, +a.code:visited, +a.line, +a.line:visited { + color: var(--page-link-color); } -a.codeRef, a.codeRef:visited, a.lineRef, a.lineRef:visited { - color: var(--page-external-link-color); +a.codeRef, +a.codeRef:visited, +a.lineRef, +a.lineRef:visited { + color: var(--page-external-link-color); } -a.code.hl_class { /* style for links to class names in code snippets */ } -a.code.hl_struct { /* style for links to struct names in code snippets */ } -a.code.hl_union { /* style for links to union names in code snippets */ } -a.code.hl_interface { /* style for links to interface names in code snippets */ } -a.code.hl_protocol { /* style for links to protocol names in code snippets */ } -a.code.hl_category { /* style for links to category names in code snippets */ } -a.code.hl_exception { /* style for links to exception names in code snippets */ } -a.code.hl_service { /* style for links to service names in code snippets */ } -a.code.hl_singleton { /* style for links to singleton names in code snippets */ } -a.code.hl_concept { /* style for links to concept names in code snippets */ } -a.code.hl_namespace { /* style for links to namespace names in code snippets */ } -a.code.hl_package { /* style for links to package names in code snippets */ } -a.code.hl_define { /* style for links to macro names in code snippets */ } -a.code.hl_function { /* style for links to function names in code snippets */ } -a.code.hl_variable { /* style for links to variable names in code snippets */ } -a.code.hl_typedef { /* style for links to typedef names in code snippets */ } -a.code.hl_enumvalue { /* style for links to enum value names in code snippets */ } -a.code.hl_enumeration { /* style for links to enumeration names in code snippets */ } -a.code.hl_signal { /* style for links to Qt signal names in code snippets */ } -a.code.hl_slot { /* style for links to Qt slot names in code snippets */ } -a.code.hl_friend { /* style for links to friend names in code snippets */ } -a.code.hl_dcop { /* style for links to KDE3 DCOP names in code snippets */ } -a.code.hl_property { /* style for links to property names in code snippets */ } -a.code.hl_event { /* style for links to event names in code snippets */ } -a.code.hl_sequence { /* style for links to sequence names in code snippets */ } -a.code.hl_dictionary { /* style for links to dictionary names in code snippets */ } +a.code.hl_class { + /* style for links to class names in code snippets */ +} +a.code.hl_struct { + /* style for links to struct names in code snippets */ +} +a.code.hl_union { + /* style for links to union names in code snippets */ +} +a.code.hl_interface { + /* style for links to interface names in code snippets */ +} +a.code.hl_protocol { + /* style for links to protocol names in code snippets */ +} +a.code.hl_category { + /* style for links to category names in code snippets */ +} +a.code.hl_exception { + /* style for links to exception names in code snippets */ +} +a.code.hl_service { + /* style for links to service names in code snippets */ +} +a.code.hl_singleton { + /* style for links to singleton names in code snippets */ +} +a.code.hl_concept { + /* style for links to concept names in code snippets */ +} +a.code.hl_namespace { + /* style for links to namespace names in code snippets */ +} +a.code.hl_package { + /* style for links to package names in code snippets */ +} +a.code.hl_define { + /* style for links to macro names in code snippets */ +} +a.code.hl_function { + /* style for links to function names in code snippets */ +} +a.code.hl_variable { + /* style for links to variable names in code snippets */ +} +a.code.hl_typedef { + /* style for links to typedef names in code snippets */ +} +a.code.hl_enumvalue { + /* style for links to enum value names in code snippets */ +} +a.code.hl_enumeration { + /* style for links to enumeration names in code snippets */ +} +a.code.hl_signal { + /* style for links to Qt signal names in code snippets */ +} +a.code.hl_slot { + /* style for links to Qt slot names in code snippets */ +} +a.code.hl_friend { + /* style for links to friend names in code snippets */ +} +a.code.hl_dcop { + /* style for links to KDE3 DCOP names in code snippets */ +} +a.code.hl_property { + /* style for links to property names in code snippets */ +} +a.code.hl_event { + /* style for links to event names in code snippets */ +} +a.code.hl_sequence { + /* style for links to sequence names in code snippets */ +} +a.code.hl_dictionary { + /* style for links to dictionary names in code snippets */ +} /* @end */ dl.el { - margin-left: -1cm; + margin-left: -1cm; } ul.check { - list-style:none; - text-indent: -16px; - padding-left: 38px; + list-style: none; + text-indent: -16px; + padding-left: 38px; } li.unchecked:before { - content: "\2610\A0"; + content: "\2610\A0"; } li.checked:before { - content: "\2611\A0"; + content: "\2611\A0"; } ol { - text-indent: 0px; + text-indent: 0px; } ul { - text-indent: 0px; - overflow: visible; + text-indent: 0px; + overflow: visible; } ul.multicol { - -moz-column-gap: 1em; - -webkit-column-gap: 1em; - column-gap: 1em; - -moz-column-count: 3; - -webkit-column-count: 3; - column-count: 3; - list-style-type: none; + -moz-column-gap: 1em; + -webkit-column-gap: 1em; + column-gap: 1em; + -moz-column-count: 3; + -webkit-column-count: 3; + column-count: 3; + list-style-type: none; } #side-nav ul { - overflow: visible; /* reset ul rule for scroll bar in GENERATE_TREEVIEW window */ + overflow: visible; /* reset ul rule for scroll bar in GENERATE_TREEVIEW window */ } #main-nav ul { - overflow: visible; /* reset ul rule for the navigation bar drop down lists */ + overflow: visible; /* reset ul rule for the navigation bar drop down lists */ } .fragment { - text-align: left; - direction: ltr; - overflow-x: auto; - overflow-y: hidden; - position: relative; - min-height: 12px; - margin: 10px 0px; - padding: 10px 10px; - border: 1px solid var(--fragment-border-color); - border-radius: 4px; - background-color: var(--fragment-background-color); - color: var(--fragment-foreground-color); + text-align: left; + direction: ltr; + overflow-x: auto; + overflow-y: hidden; + position: relative; + min-height: 12px; + margin: 10px 0px; + padding: 10px 10px; + border: 1px solid var(--fragment-border-color); + border-radius: 4px; + background-color: var(--fragment-background-color); + color: var(--fragment-foreground-color); } pre.fragment { - word-wrap: break-word; - font-size: 10pt; - line-height: 125%; - font-family: var(--font-family-monospace); + word-wrap: break-word; + font-size: 10pt; + line-height: 125%; + font-family: var(--font-family-monospace); } span.tt { - white-space: pre; - font-family: var(--font-family-monospace); + white-space: pre; + font-family: var(--font-family-monospace); } .clipboard { - width: 24px; - height: 24px; - right: 5px; - top: 5px; - opacity: 0; - position: absolute; - display: inline; - overflow: hidden; - justify-content: center; - align-items: center; - cursor: pointer; + width: 24px; + height: 24px; + right: 5px; + top: 5px; + opacity: 0; + position: absolute; + display: inline; + overflow: hidden; + justify-content: center; + align-items: center; + cursor: pointer; } .clipboard.success { - border: 1px solid var(--fragment-foreground-color); - border-radius: 4px; + border: 1px solid var(--fragment-foreground-color); + border-radius: 4px; } -.fragment:hover .clipboard, .clipboard.success { - opacity: .4; +.fragment:hover .clipboard, +.clipboard.success { + opacity: 0.4; } -.clipboard:hover, .clipboard.success { - opacity: 1 !important; +.clipboard:hover, +.clipboard.success { + opacity: 1 !important; } -.clipboard:active:not([class~=success]) svg { - transform: scale(.91); +.clipboard:active:not([class~="success"]) svg { + transform: scale(0.91); } .clipboard.success svg { - fill: var(--fragment-copy-ok-color); + fill: var(--fragment-copy-ok-color); } .clipboard.success { - border-color: var(--fragment-copy-ok-color); + border-color: var(--fragment-copy-ok-color); } div.line { - font-family: var(--font-family-monospace); - font-size: 13px; - min-height: 13px; - line-height: 1.2; - text-wrap: wrap; - word-break: break-all; - white-space: -moz-pre-wrap; /* Moz */ - white-space: -pre-wrap; /* Opera 4-6 */ - white-space: -o-pre-wrap; /* Opera 7 */ - white-space: pre-wrap; /* CSS3 */ - word-wrap: break-word; /* IE 5.5+ */ - text-indent: -62px; - padding-left: 62px; - padding-bottom: 0px; - margin: 0px; - -webkit-transition-property: background-color, box-shadow; - -webkit-transition-duration: 0.5s; - -moz-transition-property: background-color, box-shadow; - -moz-transition-duration: 0.5s; - -ms-transition-property: background-color, box-shadow; - -ms-transition-duration: 0.5s; - -o-transition-property: background-color, box-shadow; - -o-transition-duration: 0.5s; - transition-property: background-color, box-shadow; - transition-duration: 0.5s; + font-family: var(--font-family-monospace); + font-size: 13px; + min-height: 13px; + line-height: 1.2; + text-wrap: wrap; + word-break: break-all; + white-space: -moz-pre-wrap; /* Moz */ + white-space: -pre-wrap; /* Opera 4-6 */ + white-space: -o-pre-wrap; /* Opera 7 */ + white-space: pre-wrap; /* CSS3 */ + word-wrap: break-word; /* IE 5.5+ */ + text-indent: -62px; + padding-left: 62px; + padding-bottom: 0px; + margin: 0px; + -webkit-transition-property: background-color, box-shadow; + -webkit-transition-duration: 0.5s; + -moz-transition-property: background-color, box-shadow; + -moz-transition-duration: 0.5s; + -ms-transition-property: background-color, box-shadow; + -ms-transition-duration: 0.5s; + -o-transition-property: background-color, box-shadow; + -o-transition-duration: 0.5s; + transition-property: background-color, box-shadow; + transition-duration: 0.5s; } div.line:after { - content:"\000A"; - white-space: pre; + content: "\000A"; + white-space: pre; } div.line.glow { - background-color: var(--glow-color); - box-shadow: 0 0 10px var(--glow-color); + background-color: var(--glow-color); + box-shadow: 0 0 10px var(--glow-color); } span.fold { - display: inline-block; - width: 12px; - height: 12px; - margin-left: 4px; - margin-right: 1px; + display: inline-block; + width: 12px; + height: 12px; + margin-left: 4px; + margin-right: 1px; } span.foldnone { - display: inline-block; - position: relative; - cursor: pointer; - user-select: none; + display: inline-block; + position: relative; + cursor: pointer; + user-select: none; } -span.fold.plus, span.fold.minus { - width: 10px; - height: 10px; - background-color: var(--fragment-background-color); - position: relative; - border: 1px solid var(--fold-line-color); - margin-right: 1px; +span.fold.plus, +span.fold.minus { + width: 10px; + height: 10px; + background-color: var(--fragment-background-color); + position: relative; + border: 1px solid var(--fold-line-color); + margin-right: 1px; } -span.fold.plus::before, span.fold.minus::before { - content: ''; - position: absolute; - background-color: var(--fold-line-color); +span.fold.plus::before, +span.fold.minus::before { + content: ""; + position: absolute; + background-color: var(--fold-line-color); } span.fold.plus::before { - width: 2px; - height: 6px; - top: 2px; - left: 4px; + width: 2px; + height: 6px; + top: 2px; + left: 4px; } span.fold.plus::after { - content: ''; - position: absolute; - width: 6px; - height: 2px; - top: 4px; - left: 2px; - background-color: var(--fold-line-color); + content: ""; + position: absolute; + width: 6px; + height: 2px; + top: 4px; + left: 2px; + background-color: var(--fold-line-color); } span.fold.minus::before { - width: 6px; - height: 2px; - top: 4px; - left: 2px; + width: 6px; + height: 2px; + top: 4px; + left: 2px; } span.lineno { - padding-right: 4px; - margin-right: 9px; - text-align: right; - border-right: 2px solid var(--fragment-lineno-border-color); - color: var(--fragment-lineno-foreground-color); - background-color: var(--fragment-lineno-background-color); - white-space: pre; + padding-right: 4px; + margin-right: 9px; + text-align: right; + border-right: 2px solid var(--fragment-lineno-border-color); + color: var(--fragment-lineno-foreground-color); + background-color: var(--fragment-lineno-background-color); + white-space: pre; } -span.lineno a, span.lineno a:visited { - color: var(--fragment-lineno-link-fg-color); - background-color: var(--fragment-lineno-link-bg-color); +span.lineno a, +span.lineno a:visited { + color: var(--fragment-lineno-link-fg-color); + background-color: var(--fragment-lineno-link-bg-color); } span.lineno a:hover { - color: var(--fragment-lineno-link-hover-fg-color); - background-color: var(--fragment-lineno-link-hover-bg-color); + color: var(--fragment-lineno-link-hover-fg-color); + background-color: var(--fragment-lineno-link-hover-bg-color); } .lineno { - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } div.classindex ul { - list-style: none; - padding-left: 0; + list-style: none; + padding-left: 0; } div.classindex span.ai { - display: inline-block; + display: inline-block; } div.groupHeader { - box-shadow: 13px 0 var(--page-background-color), - -13px 0 var(--page-background-color), - 13px 1px var(--group-header-separator-color), - -13px 1px var(--group-header-separator-color); - color: var(--group-header-color); - font-size: 110%; - font-weight: 500; - margin-left: 0px; - margin-top: 0em; - margin-bottom: 6px; - padding-top: 8px; - padding-bottom: 4px; + box-shadow: + 13px 0 var(--page-background-color), + -13px 0 var(--page-background-color), + 13px 1px var(--group-header-separator-color), + -13px 1px var(--group-header-separator-color); + color: var(--group-header-color); + font-size: 110%; + font-weight: 500; + margin-left: 0px; + margin-top: 0em; + margin-bottom: 6px; + padding-top: 8px; + padding-bottom: 4px; } div.groupText { - margin-left: 16px; - font-style: italic; + margin-left: 16px; + font-style: italic; } body { - color: var(--page-foreground-color); - margin: 0; + color: var(--page-foreground-color); + margin: 0; } div.contents { - margin-top: 10px; - margin-left: 12px; - margin-right: 12px; + margin-top: 10px; + margin-left: 12px; + margin-right: 12px; } p.formulaDsp { - text-align: center; + text-align: center; } img.dark-mode-visible { - display: none; + display: none; } img.light-mode-visible { - display: none; + display: none; } -img.formulaInl, img.inline { - vertical-align: middle; +img.formulaInl, +img.inline { + vertical-align: middle; } div.center { - text-align: center; - margin-top: 0px; - margin-bottom: 0px; - padding: 0px; + text-align: center; + margin-top: 0px; + margin-bottom: 0px; + padding: 0px; } div.center img { - border: 0px; + border: 0px; } address.footer { - text-align: right; - padding-right: 12px; + text-align: right; + padding-right: 12px; } img.footer { - border: 0px; - vertical-align: middle; - width: var(--footer-logo-width); + border: 0px; + vertical-align: middle; + width: var(--footer-logo-width); } .compoundTemplParams { - color: var(--memdecl-template-color); - font-size: 80%; - line-height: 120%; + color: var(--memdecl-template-color); + font-size: 80%; + line-height: 120%; } /* @group Code Colorization */ span.keyword { - color: var(--code-keyword-color); + color: var(--code-keyword-color); } span.keywordtype { - color: var(--code-type-keyword-color); + color: var(--code-type-keyword-color); } span.keywordflow { - color: var(--code-flow-keyword-color); + color: var(--code-flow-keyword-color); } span.comment { - color: var(--code-comment-color); + color: var(--code-comment-color); } span.preprocessor { - color: var(--code-preprocessor-color); + color: var(--code-preprocessor-color); } span.stringliteral { - color: var(--code-string-literal-color); + color: var(--code-string-literal-color); } span.charliteral { - color: var(--code-char-literal-color); + color: var(--code-char-literal-color); } span.xmlcdata { - color: var(--code-xml-cdata-color); + color: var(--code-xml-cdata-color); } -span.vhdldigit { - color: var(--code-vhdl-digit-color); +span.vhdldigit { + color: var(--code-vhdl-digit-color); } -span.vhdlchar { - color: var(--code-vhdl-char-color); +span.vhdlchar { + color: var(--code-vhdl-char-color); } -span.vhdlkeyword { - color: var(--code-vhdl-keyword-color); +span.vhdlkeyword { + color: var(--code-vhdl-keyword-color); } -span.vhdllogic { - color: var(--code-vhdl-logic-color); +span.vhdllogic { + color: var(--code-vhdl-logic-color); } blockquote { - background-color: var(--blockquote-background-color); - border-left: 2px solid var(--blockquote-border-color); - margin: 0 24px 0 4px; - padding: 0 12px 0 16px; + background-color: var(--blockquote-background-color); + border-left: 2px solid var(--blockquote-border-color); + margin: 0 24px 0 4px; + padding: 0 12px 0 16px; } /* @end */ td.tiny { - font-size: 75%; + font-size: 75%; } .dirtab { - padding: 4px; - border-collapse: collapse; - border: 1px solid var(--table-cell-border-color); + padding: 4px; + border-collapse: collapse; + border: 1px solid var(--table-cell-border-color); } th.dirtab { - background-color: var(--table-header-background-color); - color: var(--table-header-foreground-color); - font-weight: bold; + background-color: var(--table-header-background-color); + color: var(--table-header-foreground-color); + font-weight: bold; } hr { - border: none; - margin-top: 16px; - margin-bottom: 16px; - height: 1px; - box-shadow: 13px 0 var(--page-background-color), - -13px 0 var(--page-background-color), - 13px 1px var(--group-header-separator-color), - -13px 1px var(--group-header-separator-color); + border: none; + margin-top: 16px; + margin-bottom: 16px; + height: 1px; + box-shadow: + 13px 0 var(--page-background-color), + -13px 0 var(--page-background-color), + 13px 1px var(--group-header-separator-color), + -13px 1px var(--group-header-separator-color); } hr.footer { - height: 1px; + height: 1px; } /* @group Member Descriptions */ table.memberdecls { - border-spacing: 0px; - padding: 0px; + border-spacing: 0px; + padding: 0px; } -.memberdecls td, .fieldtable tr { - transition-property: background-color, box-shadow; - transition-duration: 0.5s; +.memberdecls td, +.fieldtable tr { + transition-property: background-color, box-shadow; + transition-duration: 0.5s; } -.memberdecls td.glow, .fieldtable tr.glow { - background-color: var(--glow-color); - box-shadow: 0 0 15px var(--glow-color); +.memberdecls td.glow, +.fieldtable tr.glow { + background-color: var(--glow-color); + box-shadow: 0 0 15px var(--glow-color); } -.memberdecls tr[class^='memitem'] { - font-family: var(--font-family-monospace); +.memberdecls tr[class^="memitem"] { + font-family: var(--font-family-monospace); } -.mdescLeft, .mdescRight, -.memItemLeft, .memItemRight { - padding-top: 2px; - padding-bottom: 2px; +.mdescLeft, +.mdescRight, +.memItemLeft, +.memItemRight { + padding-top: 2px; + padding-bottom: 2px; } .memTemplParams { - padding-left: 10px; - padding-top: 5px; + padding-left: 10px; + padding-top: 5px; } -.memItemLeft, .memItemRight, .memTemplParams { - background-color: var(--memdecl-background-color); +.memItemLeft, +.memItemRight, +.memTemplParams { + background-color: var(--memdecl-background-color); } -.mdescLeft, .mdescRight { - padding: 0px 8px 4px 8px; - color: var(--memdecl-foreground-color); +.mdescLeft, +.mdescRight { + padding: 0px 8px 4px 8px; + color: var(--memdecl-foreground-color); } -tr[class^='memdesc'] { - box-shadow: inset 0px 1px 3px 0px rgba(0,0,0,.075); +tr[class^="memdesc"] { + box-shadow: inset 0px 1px 3px 0px rgba(0, 0, 0, 0.075); } .mdescLeft { - border-left: 1px solid var(--memdecl-border-color); - border-bottom: 1px solid var(--memdecl-border-color); + border-left: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); } .mdescRight { - border-right: 1px solid var(--memdecl-border-color); - border-bottom: 1px solid var(--memdecl-border-color); + border-right: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); } .memTemplParams { - color: var(--memdecl-template-color); - white-space: nowrap; - font-size: 80%; - border-left: 1px solid var(--memdecl-border-color); - border-right: 1px solid var(--memdecl-border-color); + color: var(--memdecl-template-color); + white-space: nowrap; + font-size: 80%; + border-left: 1px solid var(--memdecl-border-color); + border-right: 1px solid var(--memdecl-border-color); } td.ititle { - border: 1px solid var(--memdecl-border-color); - border-top-left-radius: 4px; - border-top-right-radius: 4px; - padding-left: 10px; + border: 1px solid var(--memdecl-border-color); + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding-left: 10px; } tr:not(:first-child) > td.ititle { - border-top: 0; - border-radius: 0; + border-top: 0; + border-radius: 0; } .memItemLeft { - white-space: nowrap; - border-left: 1px solid var(--memdecl-border-color); - border-bottom: 1px solid var(--memdecl-border-color); - padding-left: 10px; - transition: none; + white-space: nowrap; + border-left: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); + padding-left: 10px; + transition: none; } .memItemRight { - width: 100%; - border-right: 1px solid var(--memdecl-border-color); - border-bottom: 1px solid var(--memdecl-border-color); - padding-right: 10px; - transition: none; + width: 100%; + border-right: 1px solid var(--memdecl-border-color); + border-bottom: 1px solid var(--memdecl-border-color); + padding-right: 10px; + transition: none; } -tr.heading + tr[class^='memitem'] td.memItemLeft, -tr.groupHeader + tr[class^='memitem'] td.memItemLeft, -tr.inherit_header + tr[class^='memitem'] td.memItemLeft { - border-top: 1px solid var(--memdecl-border-color); - border-top-left-radius: 4px; +tr.heading + tr[class^="memitem"] td.memItemLeft, +tr.groupHeader + tr[class^="memitem"] td.memItemLeft, +tr.inherit_header + tr[class^="memitem"] td.memItemLeft { + border-top: 1px solid var(--memdecl-border-color); + border-top-left-radius: 4px; } -tr.heading + tr[class^='memitem'] td.memItemRight, -tr.groupHeader + tr[class^='memitem'] td.memItemRight, -tr.inherit_header + tr[class^='memitem'] td.memItemRight { - border-top: 1px solid var(--memdecl-border-color); - border-top-right-radius: 4px; +tr.heading + tr[class^="memitem"] td.memItemRight, +tr.groupHeader + tr[class^="memitem"] td.memItemRight, +tr.inherit_header + tr[class^="memitem"] td.memItemRight { + border-top: 1px solid var(--memdecl-border-color); + border-top-right-radius: 4px; } -tr.heading + tr[class^='memitem'] td.memTemplParams, -tr.heading + tr td.ititle, -tr.groupHeader + tr[class^='memitem'] td.memTemplParams, -tr.groupHeader + tr td.ititle, -tr.inherit_header + tr[class^='memitem'] td.memTemplParams { - border-top: 1px solid var(--memdecl-border-color); - border-top-left-radius: 4px; - border-top-right-radius: 4px; +tr.heading + tr[class^="memitem"] td.memTemplParams, +tr.heading + tr td.ititle, +tr.groupHeader + tr[class^="memitem"] td.memTemplParams, +tr.groupHeader + tr td.ititle, +tr.inherit_header + tr[class^="memitem"] td.memTemplParams { + border-top: 1px solid var(--memdecl-border-color); + border-top-left-radius: 4px; + border-top-right-radius: 4px; } table.memberdecls tr:last-child td.memItemLeft, table.memberdecls tr:last-child td.mdescLeft, -table.memberdecls tr[class^='memitem']:has(+ tr.groupHeader) td.memItemLeft, -table.memberdecls tr[class^='memitem']:has(+ tr.inherit_header) td.memItemLeft, -table.memberdecls tr[class^='memdesc']:has(+ tr.groupHeader) td.mdescLeft, -table.memberdecls tr[class^='memdesc']:has(+ tr.inherit_header) td.mdescLeft { - border-bottom-left-radius: 4px; +table.memberdecls tr[class^="memitem"]:has(+ tr.groupHeader) td.memItemLeft, +table.memberdecls tr[class^="memitem"]:has(+ tr.inherit_header) td.memItemLeft, +table.memberdecls tr[class^="memdesc"]:has(+ tr.groupHeader) td.mdescLeft, +table.memberdecls tr[class^="memdesc"]:has(+ tr.inherit_header) td.mdescLeft { + border-bottom-left-radius: 4px; } table.memberdecls tr:last-child td.memItemRight, table.memberdecls tr:last-child td.mdescRight, -table.memberdecls tr[class^='memitem']:has(+ tr.groupHeader) td.memItemRight, -table.memberdecls tr[class^='memitem']:has(+ tr.inherit_header) td.memItemRight, -table.memberdecls tr[class^='memdesc']:has(+ tr.groupHeader) td.mdescRight, -table.memberdecls tr[class^='memdesc']:has(+ tr.inherit_header) td.mdescRight { - border-bottom-right-radius: 4px; +table.memberdecls tr[class^="memitem"]:has(+ tr.groupHeader) td.memItemRight, +table.memberdecls tr[class^="memitem"]:has(+ tr.inherit_header) td.memItemRight, +table.memberdecls tr[class^="memdesc"]:has(+ tr.groupHeader) td.mdescRight, +table.memberdecls tr[class^="memdesc"]:has(+ tr.inherit_header) td.mdescRight { + border-bottom-right-radius: 4px; } -tr.template .memItemLeft, tr.template .memItemRight { - border-top: none; - padding-top: 0; +tr.template .memItemLeft, +tr.template .memItemRight { + border-top: none; + padding-top: 0; } - /* @end */ /* @group Member Details */ @@ -1224,1238 +1340,1320 @@ tr.template .memItemLeft, tr.template .memItemRight { /* Styles for detailed member documentation */ .memtitle { - padding: 8px; - border-top: 1px solid var(--memdef-border-color); - border-left: 1px solid var(--memdef-border-color); - border-right: 1px solid var(--memdef-border-color); - border-top-right-radius: 4px; - border-top-left-radius: 4px; - margin-bottom: -1px; - background-color: var(--memdef-proto-background-color); - line-height: 1.25; - font-family: var(--font-family-monospace); - font-weight: 500; - font-size: 16px; - float:left; - box-shadow: 0 10px 0 -1px var(--memdef-proto-background-color), - 0 2px 8px 0 rgba(0,0,0,.075); - position: relative; + padding: 8px; + border-top: 1px solid var(--memdef-border-color); + border-left: 1px solid var(--memdef-border-color); + border-right: 1px solid var(--memdef-border-color); + border-top-right-radius: 4px; + border-top-left-radius: 4px; + margin-bottom: -1px; + background-color: var(--memdef-proto-background-color); + line-height: 1.25; + font-family: var(--font-family-monospace); + font-weight: 500; + font-size: 16px; + float: left; + box-shadow: + 0 10px 0 -1px var(--memdef-proto-background-color), + 0 2px 8px 0 rgba(0, 0, 0, 0.075); + position: relative; } .memtitle:after { - content: ''; - display: block; - background: var(--memdef-proto-background-color); - height: 10px; - bottom: -10px; - left: 0px; - right: -14px; - position: absolute; - border-top-right-radius: 6px; + content: ""; + display: block; + background: var(--memdef-proto-background-color); + height: 10px; + bottom: -10px; + left: 0px; + right: -14px; + position: absolute; + border-top-right-radius: 6px; } -.permalink -{ - font-family: var(--font-family-monospace); - font-weight: 500; - line-height: 1.25; - font-size: 16px; - display: inline-block; - vertical-align: middle; +.permalink { + font-family: var(--font-family-monospace); + font-weight: 500; + line-height: 1.25; + font-size: 16px; + display: inline-block; + vertical-align: middle; } .memtemplate { - font-size: 80%; - color: var(--memdef-template-color); - font-family: var(--font-family-monospace); - font-weight: normal; - margin-left: 9px; + font-size: 80%; + color: var(--memdef-template-color); + font-family: var(--font-family-monospace); + font-weight: normal; + margin-left: 9px; } .mempage { - width: 100%; + width: 100%; } .memitem { - padding: 0; - margin-bottom: 10px; - margin-right: 5px; - display: table !important; - width: 100%; - box-shadow: 0 2px 8px 0 rgba(0,0,0,.075); - border-radius: 4px; + padding: 0; + margin-bottom: 10px; + margin-right: 5px; + display: table !important; + width: 100%; + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.075); + border-radius: 4px; } .memitem.glow { - box-shadow: 0 0 15px var(--glow-color); + box-shadow: 0 0 15px var(--glow-color); } .memname { - font-family: var(--font-family-monospace); - font-size: 13px; - font-weight: 400; - margin-left: 6px; + font-family: var(--font-family-monospace); + font-size: 13px; + font-weight: 400; + margin-left: 6px; } .memname td { - vertical-align: bottom; + vertical-align: bottom; } -.memproto, dl.reflist dt { - border-top: 1px solid var(--memdef-border-color); - border-left: 1px solid var(--memdef-border-color); - border-right: 1px solid var(--memdef-border-color); - padding: 6px 0px 6px 0px; - color: var(--memdef-proto-text-color); - font-weight: bold; - background-color: var(--memdef-proto-background-color); - border-top-right-radius: 4px; - border-bottom: 1px solid var(--memdef-border-color); +.memproto, +dl.reflist dt { + border-top: 1px solid var(--memdef-border-color); + border-left: 1px solid var(--memdef-border-color); + border-right: 1px solid var(--memdef-border-color); + padding: 6px 0px 6px 0px; + color: var(--memdef-proto-text-color); + font-weight: bold; + background-color: var(--memdef-proto-background-color); + border-top-right-radius: 4px; + border-bottom: 1px solid var(--memdef-border-color); } .overload { - font-family: var(--font-family-monospace); - font-size: 65%; + font-family: var(--font-family-monospace); + font-size: 65%; } -.memdoc, dl.reflist dd { - border-bottom: 1px solid var(--memdef-border-color); - border-left: 1px solid var(--memdef-border-color); - border-right: 1px solid var(--memdef-border-color); - padding: 6px 10px 2px 10px; - border-top-width: 0; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; +.memdoc, +dl.reflist dd { + border-bottom: 1px solid var(--memdef-border-color); + border-left: 1px solid var(--memdef-border-color); + border-right: 1px solid var(--memdef-border-color); + padding: 6px 10px 2px 10px; + border-top-width: 0; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; } dl.reflist dt { - padding: 5px; + padding: 5px; } dl.reflist dd { - margin: 0px 0px 10px 0px; - padding: 5px; + margin: 0px 0px 10px 0px; + padding: 5px; } .paramkey { - text-align: right; + text-align: right; } .paramtype { - white-space: nowrap; - padding: 0px; - padding-bottom: 1px; + white-space: nowrap; + padding: 0px; + padding-bottom: 1px; } .paramname { - white-space: nowrap; - padding: 0px; - padding-bottom: 1px; - margin-left: 2px; + white-space: nowrap; + padding: 0px; + padding-bottom: 1px; + margin-left: 2px; } .paramname em { - color: var(--memdef-param-name-color); - font-style: normal; - margin-right: 1px; + color: var(--memdef-param-name-color); + font-style: normal; + margin-right: 1px; } .paramname .paramdefval { - font-family: var(--font-family-monospace); + font-family: var(--font-family-monospace); } -.params, .retval, .exception, .tparams { - margin-left: 0px; - padding-left: 0px; +.params, +.retval, +.exception, +.tparams { + margin-left: 0px; + padding-left: 0px; } -.params .paramname, .retval .paramname, .tparams .paramname, .exception .paramname { - font-weight: bold; - vertical-align: top; +.params .paramname, +.retval .paramname, +.tparams .paramname, +.exception .paramname { + font-weight: bold; + vertical-align: top; } -.params .paramtype, .tparams .paramtype { - font-style: italic; - vertical-align: top; +.params .paramtype, +.tparams .paramtype { + font-style: italic; + vertical-align: top; } -.params .paramdir, .tparams .paramdir { - font-family: var(--font-family-monospace); - vertical-align: top; +.params .paramdir, +.tparams .paramdir { + font-family: var(--font-family-monospace); + vertical-align: top; } table.mlabels { - border-spacing: 0px; + border-spacing: 0px; } td.mlabels-left { - width: 100%; - padding: 0px; + width: 100%; + padding: 0px; } td.mlabels-right { - vertical-align: bottom; - padding: 0px; - white-space: nowrap; + vertical-align: bottom; + padding: 0px; + white-space: nowrap; } span.mlabels { - margin-left: 8px; + margin-left: 8px; } span.mlabel { - background-color: var(--label-background-color); - border-top:1px solid var(--label-left-top-border-color); - border-left:1px solid var(--label-left-top-border-color); - border-right:1px solid var(--label-right-bottom-border-color); - border-bottom:1px solid var(--label-right-bottom-border-color); - text-shadow: none; - color: var(--label-foreground-color); - margin-right: 4px; - padding: 2px 3px; - border-radius: 3px; - font-size: 7pt; - white-space: nowrap; - vertical-align: middle; + background-color: var(--label-background-color); + border-top: 1px solid var(--label-left-top-border-color); + border-left: 1px solid var(--label-left-top-border-color); + border-right: 1px solid var(--label-right-bottom-border-color); + border-bottom: 1px solid var(--label-right-bottom-border-color); + text-shadow: none; + color: var(--label-foreground-color); + margin-right: 4px; + padding: 2px 3px; + border-radius: 3px; + font-size: 7pt; + white-space: nowrap; + vertical-align: middle; } - - /* @end */ /* these are for tree view inside a (index) page */ div.directory { - margin: 10px 0px; - width: 100%; + margin: 10px 0px; + width: 100%; } .directory table { - border-collapse:collapse; + border-collapse: collapse; } .directory td { - margin: 0px; - padding: 0px; - vertical-align: top; + margin: 0px; + padding: 0px; + vertical-align: top; } .directory td.entry { - white-space: nowrap; - padding-right: 6px; - padding-top: 3px; + white-space: nowrap; + padding-right: 6px; + padding-top: 3px; } .directory td.entry a { - outline:none; + outline: none; } .directory td.entry a img { - border: none; + border: none; } .directory td.desc { - width: 100%; - padding-left: 6px; - padding-right: 6px; - padding-top: 3px; - border-left: 1px solid rgba(0,0,0,0.05); + width: 100%; + padding-left: 6px; + padding-right: 6px; + padding-top: 3px; + border-left: 1px solid rgba(0, 0, 0, 0.05); } .directory tr.odd { - padding-left: 6px; - background-color: var(--index-odd-item-bg-color); + padding-left: 6px; + background-color: var(--index-odd-item-bg-color); } .directory tr.even { - padding-left: 6px; - background-color: var(--index-even-item-bg-color); + padding-left: 6px; + background-color: var(--index-even-item-bg-color); } .directory img { - vertical-align: -30%; + vertical-align: -30%; } .directory .levels { - white-space: nowrap; - width: 100%; - text-align: right; - font-size: 9pt; + white-space: nowrap; + width: 100%; + text-align: right; + font-size: 9pt; } .directory .levels span { - cursor: pointer; - padding-left: 2px; - padding-right: 2px; - color: var(--page-link-color); + cursor: pointer; + padding-left: 2px; + padding-right: 2px; + color: var(--page-link-color); } .arrow { - color: var(--nav-background-color); - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - cursor: pointer; - font-size: 80%; - display: inline-block; - width: 16px; - height: 14px; - transition: opacity 0.3s ease; + color: var(--nav-background-color); + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: pointer; + font-size: 80%; + display: inline-block; + width: 16px; + height: 14px; + transition: opacity 0.3s ease; } span.arrowhead { - position: relative; - padding: 0; - margin: 0 0 0 2px; - display: inline-block; - width: 5px; - height: 5px; - border-right: 2px solid var(--nav-arrow-color); - border-bottom: 2px solid var(--nav-arrow-color); - transform: rotate(-45deg); - transition: transform 0.3s ease; + position: relative; + padding: 0; + margin: 0 0 0 2px; + display: inline-block; + width: 5px; + height: 5px; + border-right: 2px solid var(--nav-arrow-color); + border-bottom: 2px solid var(--nav-arrow-color); + transform: rotate(-45deg); + transition: transform 0.3s ease; } span.arrowhead.opened { - transform: rotate(45deg); + transform: rotate(45deg); } .selected span.arrowhead { - border-right: 2px solid var(--nav-arrow-selected-color); - border-bottom: 2px solid var(--nav-arrow-selected-color); + border-right: 2px solid var(--nav-arrow-selected-color); + border-bottom: 2px solid var(--nav-arrow-selected-color); } .icon { - font-family: var(--font-family-icon); - line-height: normal; - font-weight: bold; - font-size: 12px; - height: 14px; - width: 16px; - display: inline-block; - background-color: var(--icon-background-color); - color: var(--icon-foreground-color); - text-align: center; - border-radius: 4px; - margin-left: 2px; - margin-right: 2px; + font-family: var(--font-family-icon); + line-height: normal; + font-weight: bold; + font-size: 12px; + height: 14px; + width: 16px; + display: inline-block; + background-color: var(--icon-background-color); + color: var(--icon-foreground-color); + text-align: center; + border-radius: 4px; + margin-left: 2px; + margin-right: 2px; } .icona { - width: 24px; - height: 22px; - display: inline-block; + width: 24px; + height: 22px; + display: inline-block; } .iconfolder { - width: 24px; - height: 18px; - margin-top: 6px; - vertical-align:top; - display: inline-block; - position: relative; + width: 24px; + height: 18px; + margin-top: 6px; + vertical-align: top; + display: inline-block; + position: relative; } .icondoc { - width: 24px; - height: 18px; - margin-top: 3px; - vertical-align:top; - display: inline-block; - position: relative; + width: 24px; + height: 18px; + margin-top: 3px; + vertical-align: top; + display: inline-block; + position: relative; } .folder-icon { - width: 16px; - height: 11px; - background-color: var(--icon-folder-fill-color); - border: 1px solid var(--icon-folder-border-color); - border-radius: 0 2px 2px 2px; - position: relative; - box-sizing: content-box; + width: 16px; + height: 11px; + background-color: var(--icon-folder-fill-color); + border: 1px solid var(--icon-folder-border-color); + border-radius: 0 2px 2px 2px; + position: relative; + box-sizing: content-box; } .folder-icon::after { - content: ''; - position: absolute; - top: 2px; - left: -1px; - width: 16px; - height: 7px; - background-color: var(--icon-folder-open-fill-color); - border: 1px solid var(--icon-folder-border-color); - border-radius: 7px 7px 2px 2px; - transform-origin: top left; - opacity: 0; - transition: all 0.3s linear; + content: ""; + position: absolute; + top: 2px; + left: -1px; + width: 16px; + height: 7px; + background-color: var(--icon-folder-open-fill-color); + border: 1px solid var(--icon-folder-border-color); + border-radius: 7px 7px 2px 2px; + transform-origin: top left; + opacity: 0; + transition: all 0.3s linear; } .folder-icon::before { - content: ''; - position: absolute; - top: -3px; - left: -1px; - width: 6px; - height: 2px; - background-color: var(--icon-folder-fill-color); - border-top: 1px solid var(--icon-folder-border-color); - border-left: 1px solid var(--icon-folder-border-color); - border-right: 1px solid var(--icon-folder-border-color); - border-radius: 2px 2px 0 0; + content: ""; + position: absolute; + top: -3px; + left: -1px; + width: 6px; + height: 2px; + background-color: var(--icon-folder-fill-color); + border-top: 1px solid var(--icon-folder-border-color); + border-left: 1px solid var(--icon-folder-border-color); + border-right: 1px solid var(--icon-folder-border-color); + border-radius: 2px 2px 0 0; } .folder-icon.open::after { - top: 3px; - opacity: 1; + top: 3px; + opacity: 1; } .doc-icon { - left: 6px; - width: 12px; - height: 16px; - background-color: var(--icon-doc-border-color); - clip-path: polygon(0 0, 66% 0, 100% 25%, 100% 100%, 0 100%); - position: relative; - display: inline-block; + left: 6px; + width: 12px; + height: 16px; + background-color: var(--icon-doc-border-color); + clip-path: polygon(0 0, 66% 0, 100% 25%, 100% 100%, 0 100%); + position: relative; + display: inline-block; } .doc-icon::before { - content: ""; - left: 1px; - top: 1px; - width: 10px; - height: 14px; - background-color: var(--icon-doc-fill-color); - clip-path: polygon(0 0, 66% 0, 100% 25%, 100% 100%, 0 100%); - position: absolute; - box-sizing: border-box; + content: ""; + left: 1px; + top: 1px; + width: 10px; + height: 14px; + background-color: var(--icon-doc-fill-color); + clip-path: polygon(0 0, 66% 0, 100% 25%, 100% 100%, 0 100%); + position: absolute; + box-sizing: border-box; } .doc-icon::after { - content: ""; - left: 7px; - top: 0px; - width: 3px; - height: 3px; - background-color: transparent; - position: absolute; - border: 1px solid var(--icon-doc-border-color); + content: ""; + left: 7px; + top: 0px; + width: 3px; + height: 3px; + background-color: transparent; + position: absolute; + border: 1px solid var(--icon-doc-border-color); } - - - /* @end */ div.dynheader { - margin-top: 8px; - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + margin-top: 8px; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } span.dynarrow { - position: relative; - display: inline-block; - width: 12px; - bottom: 1px; + position: relative; + display: inline-block; + width: 12px; + bottom: 1px; } address { - font-style: normal; - color: var(--footer-foreground-color); + font-style: normal; + color: var(--footer-foreground-color); } table.doxtable caption { - caption-side: top; + caption-side: top; } table.doxtable { - border-collapse:collapse; - margin-top: 4px; - margin-bottom: 4px; + border-collapse: collapse; + margin-top: 4px; + margin-bottom: 4px; } -table.doxtable td, table.doxtable th { - border: 1px solid var(--table-cell-border-color); - padding: 3px 7px 2px; +table.doxtable td, +table.doxtable th { + border: 1px solid var(--table-cell-border-color); + padding: 3px 7px 2px; } table.doxtable th { - background-color: var(--table-header-background-color); - color: var(--table-header-foreground-color); - font-size: 110%; - padding-bottom: 4px; - padding-top: 5px; + background-color: var(--table-header-background-color); + color: var(--table-header-foreground-color); + font-size: 110%; + padding-bottom: 4px; + padding-top: 5px; } table.fieldtable { - margin-bottom: 10px; - border: 1px solid var(--memdef-border-color); - border-spacing: 0px; - border-radius: 4px; - box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.15); + margin-bottom: 10px; + border: 1px solid var(--memdef-border-color); + border-spacing: 0px; + border-radius: 4px; + box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.15); } -.fieldtable td, .fieldtable th { - padding: 3px 7px 2px; +.fieldtable td, +.fieldtable th { + padding: 3px 7px 2px; } -.fieldtable td.fieldtype, .fieldtable td.fieldname, .fieldtable td.fieldinit { - white-space: nowrap; - border-right: 1px solid var(--memdef-border-color); - border-bottom: 1px solid var(--memdef-border-color); - vertical-align: top; +.fieldtable td.fieldtype, +.fieldtable td.fieldname, +.fieldtable td.fieldinit { + white-space: nowrap; + border-right: 1px solid var(--memdef-border-color); + border-bottom: 1px solid var(--memdef-border-color); + vertical-align: top; } .fieldtable td.fieldname { - padding-top: 3px; + padding-top: 3px; } .fieldtable td.fieldinit { - padding-top: 3px; - text-align: right; + padding-top: 3px; + text-align: right; } - .fieldtable td.fielddoc { - border-bottom: 1px solid var(--memdef-border-color); + border-bottom: 1px solid var(--memdef-border-color); } .fieldtable td.fielddoc p:first-child { - margin-top: 0px; + margin-top: 0px; } .fieldtable td.fielddoc p:last-child { - margin-bottom: 2px; + margin-bottom: 2px; } .fieldtable tr:last-child td { - border-bottom: none; + border-bottom: none; } .fieldtable th { - background-color: var(--memdef-title-background-color); - font-size: 90%; - color: var(--memdef-proto-text-color); - padding-bottom: 4px; - padding-top: 5px; - text-align:left; - font-weight: 400; - border-top-left-radius: 4px; - border-top-right-radius: 4px; - border-bottom: 1px solid var(--memdef-border-color); + background-color: var(--memdef-title-background-color); + font-size: 90%; + color: var(--memdef-proto-text-color); + padding-bottom: 4px; + padding-top: 5px; + text-align: left; + font-weight: 400; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom: 1px solid var(--memdef-border-color); } /* ----------- navigation breadcrumb styling ----------- */ #nav-path ul { - height: 30px; - line-height: 30px; - color: var(--nav-text-normal-color); - overflow: hidden; - margin: 0px; - padding-left: 4px; - background-image: none; - background: var(--page-background-color); - border-bottom: 1px solid var(--nav-breadcrumb-separator-color); - font-size: var(--nav-font-size-level1); - font-family: var(--font-family-nav); - position: relative; - z-index: 100; + height: 30px; + line-height: 30px; + color: var(--nav-text-normal-color); + overflow: hidden; + margin: 0px; + padding-left: 4px; + background-image: none; + background: var(--page-background-color); + border-bottom: 1px solid var(--nav-breadcrumb-separator-color); + font-size: var(--nav-font-size-level1); + font-family: var(--font-family-nav); + position: relative; + z-index: 100; } #main-nav { - border-bottom: 1px solid var(--nav-border-color); + border-bottom: 1px solid var(--nav-border-color); } .navpath li { - list-style-type:none; - float:left; - color: var(--nav-foreground-color); + list-style-type: none; + float: left; + color: var(--nav-foreground-color); } .navpath li.footer { - list-style-type:none; - float:right; - padding-left:10px; - padding-right:15px; - background-image:none; - background-repeat:no-repeat; - background-position:right; - font-size: 8pt; - color: var(--footer-foreground-color); + list-style-type: none; + float: right; + padding-left: 10px; + padding-right: 15px; + background-image: none; + background-repeat: no-repeat; + background-position: right; + font-size: 8pt; + color: var(--footer-foreground-color); } #nav-path li.navelem { - background-image: none; - display: flex; - align-items: center; - padding-left: 15px; + background-image: none; + display: flex; + align-items: center; + padding-left: 15px; } .navpath li.navelem a { - text-shadow: none; - display: inline-block; - color: var(--nav-breadcrumb-color); - position: relative; - top: 0px; - height: 30px; - margin-right: -20px; + text-shadow: none; + display: inline-block; + color: var(--nav-breadcrumb-color); + position: relative; + top: 0px; + height: 30px; + margin-right: -20px; } #nav-path li.navelem:after { - content: ''; - display: inline-block; - position: relative; - top: 0; - right: -15px; - width: 30px; - height: 30px; - transform: scaleX(0.5) scale(0.707) rotate(45deg); - z-index: 10; - background: var(--page-background-color); - box-shadow: 2px -2px 0 2px var(--nav-breadcrumb-separator-color); - border-radius: 0 5px 0 50px; + content: ""; + display: inline-block; + position: relative; + top: 0; + right: -15px; + width: 30px; + height: 30px; + transform: scaleX(0.5) scale(0.707) rotate(45deg); + z-index: 10; + background: var(--page-background-color); + box-shadow: 2px -2px 0 2px var(--nav-breadcrumb-separator-color); + border-radius: 0 5px 0 50px; } #nav-path li.navelem:first-child { - margin-left: -6px; + margin-left: -6px; } #nav-path li.navelem:hover, #nav-path li.navelem:hover:after { - background-color: var(--nav-breadcrumb-active-bg); + background-color: var(--nav-breadcrumb-active-bg); } /* ---------------------- */ -div.summary -{ - float: right; - font-size: 8pt; - padding-right: 5px; - width: 50%; - text-align: right; +div.summary { + float: right; + font-size: 8pt; + padding-right: 5px; + width: 50%; + text-align: right; } -div.summary a -{ - white-space: nowrap; +div.summary a { + white-space: nowrap; } -table.classindex -{ - margin: 10px; - white-space: nowrap; - margin-left: 3%; - margin-right: 3%; - width: 94%; - border: 0; - border-spacing: 0; - padding: 0; +table.classindex { + margin: 10px; + white-space: nowrap; + margin-left: 3%; + margin-right: 3%; + width: 94%; + border: 0; + border-spacing: 0; + padding: 0; } -div.ingroups -{ - font-size: 8pt; - width: 50%; - text-align: left; +div.ingroups { + font-size: 8pt; + width: 50%; + text-align: left; } -div.ingroups a -{ - white-space: nowrap; +div.ingroups a { + white-space: nowrap; } -div.header -{ - margin: 0px; - background-color: var(--header-background-color); - border-bottom: 1px solid var(--header-separator-color); +div.header { + margin: 0px; + background-color: var(--header-background-color); + border-bottom: 1px solid var(--header-separator-color); } -div.headertitle -{ - padding: 5px 5px 5px 10px; +div.headertitle { + padding: 5px 5px 5px 10px; } dl { - padding: 0 0 0 0; + padding: 0 0 0 0; } -dl.bug dt a, dl.deprecated dt a, dl.todo dt a, dl.test a { - font-weight: bold !important; +dl.bug dt a, +dl.deprecated dt a, +dl.todo dt a, +dl.test a { + font-weight: bold !important; } -dl.warning, dl.attention, dl.important, dl.note, dl.deprecated, dl.bug, -dl.invariant, dl.pre, dl.post, dl.todo, dl.test, dl.remark { - padding: 10px; - margin: 10px 0px; - overflow: hidden; - margin-left: 0; - border-radius: 4px; +dl.warning, +dl.attention, +dl.important, +dl.note, +dl.deprecated, +dl.bug, +dl.invariant, +dl.pre, +dl.post, +dl.todo, +dl.test, +dl.remark { + padding: 10px; + margin: 10px 0px; + overflow: hidden; + margin-left: 0; + border-radius: 4px; } dl.section dd { - margin-bottom: 2px; + margin-bottom: 2px; } -dl.warning, dl.attention, dl.important { - background: var(--warning-color-bg); - border-left: 8px solid var(--warning-color-hl); - color: var(--warning-color-text); +dl.warning, +dl.attention, +dl.important { + background: var(--warning-color-bg); + border-left: 8px solid var(--warning-color-hl); + color: var(--warning-color-text); } -dl.warning dt, dl.attention dt, dl.important dt { - color: var(--warning-color-hl); +dl.warning dt, +dl.attention dt, +dl.important dt { + color: var(--warning-color-hl); } -dl.note, dl.remark { - background: var(--note-color-bg); - border-left: 8px solid var(--note-color-hl); - color: var(--note-color-text); +dl.note, +dl.remark { + background: var(--note-color-bg); + border-left: 8px solid var(--note-color-hl); + color: var(--note-color-text); } -dl.note dt, dl.remark dt { - color: var(--note-color-hl); +dl.note dt, +dl.remark dt { + color: var(--note-color-hl); } dl.todo { - background: var(--todo-color-bg); - border-left: 8px solid var(--todo-color-hl); - color: var(--todo-color-text); + background: var(--todo-color-bg); + border-left: 8px solid var(--todo-color-hl); + color: var(--todo-color-text); } dl.todo dt { - color: var(--todo-color-hl); + color: var(--todo-color-hl); } dl.test { - background: var(--test-color-bg); - border-left: 8px solid var(--test-color-hl); - color: var(--test-color-text); + background: var(--test-color-bg); + border-left: 8px solid var(--test-color-hl); + color: var(--test-color-text); } dl.test dt { - color: var(--test-color-hl); + color: var(--test-color-hl); } dl.bug dt a { - color: var(--bug-color-hl) !important; + color: var(--bug-color-hl) !important; } dl.bug { - background: var(--bug-color-bg); - border-left: 8px solid var(--bug-color-hl); - color: var(--bug-color-text); + background: var(--bug-color-bg); + border-left: 8px solid var(--bug-color-hl); + color: var(--bug-color-text); } dl.bug dt a { - color: var(--bug-color-hl) !important; + color: var(--bug-color-hl) !important; } dl.deprecated { - background: var(--deprecated-color-bg); - border-left: 8px solid var(--deprecated-color-hl); - color: var(--deprecated-color-text); + background: var(--deprecated-color-bg); + border-left: 8px solid var(--deprecated-color-hl); + color: var(--deprecated-color-text); } dl.deprecated dt a { - color: var(--deprecated-color-hl) !important; + color: var(--deprecated-color-hl) !important; } -dl.note dd, dl.warning dd, dl.pre dd, dl.post dd, -dl.remark dd, dl.attention dd, dl.important dd, dl.invariant dd, -dl.bug dd, dl.deprecated dd, dl.todo dd, dl.test dd { - margin-inline-start: 0px; +dl.note dd, +dl.warning dd, +dl.pre dd, +dl.post dd, +dl.remark dd, +dl.attention dd, +dl.important dd, +dl.invariant dd, +dl.bug dd, +dl.deprecated dd, +dl.todo dd, +dl.test dd { + margin-inline-start: 0px; } -dl.invariant, dl.pre, dl.post { - background: var(--invariant-color-bg); - border-left: 8px solid var(--invariant-color-hl); - color: var(--invariant-color-text); +dl.invariant, +dl.pre, +dl.post { + background: var(--invariant-color-bg); + border-left: 8px solid var(--invariant-color-hl); + color: var(--invariant-color-text); } -dl.invariant dt, dl.pre dt, dl.post dt { - color: var(--invariant-color-hl); +dl.invariant dt, +dl.pre dt, +dl.post dt { + color: var(--invariant-color-hl); } - -#projectrow -{ - height: 56px; +#projectrow { + height: 56px; } -#projectlogo -{ - text-align: center; - vertical-align: bottom; - border-collapse: separate; +#projectlogo { + text-align: center; + vertical-align: bottom; + border-collapse: separate; } -#projectlogo img -{ - border: 0px none; +#projectlogo img { + border: 0px none; } -#projectalign -{ - vertical-align: middle; - padding-left: 0.5em; +#projectalign { + vertical-align: middle; + padding-left: 0.5em; } -#projectname -{ - font-size: 200%; - font-family: var(--font-family-title); - margin: 0; - padding: 0; +#projectname { + font-size: 200%; + font-family: var(--font-family-title); + margin: 0; + padding: 0; } -#side-nav #projectname -{ - font-size: 130%; +#side-nav #projectname { + font-size: 130%; } -#projectbrief -{ - font-size: 90%; - font-family: var(--font-family-title); - margin: 0px; - padding: 0px; +#projectbrief { + font-size: 90%; + font-family: var(--font-family-title); + margin: 0px; + padding: 0px; } -#projectnumber -{ - font-size: 50%; - font-family: var(--font-family-title); - margin: 0px; - padding: 0px; +#projectnumber { + font-size: 50%; + font-family: var(--font-family-title); + margin: 0px; + padding: 0px; } -#titlearea -{ - padding: 0 0 0 5px; - margin: 0px; - border-bottom: 1px solid var(--title-separator-color); - background-color: var(--title-background-color); +#titlearea { + padding: 0 0 0 5px; + margin: 0px; + border-bottom: 1px solid var(--title-separator-color); + background-color: var(--title-background-color); } -.image -{ - text-align: center; +.image { + text-align: center; } -.dotgraph -{ - text-align: center; +.dotgraph { + text-align: center; } -.mscgraph -{ - text-align: center; +.mscgraph { + text-align: center; } -.plantumlgraph -{ - text-align: center; +.plantumlgraph { + text-align: center; } -.diagraph -{ - text-align: center; +.diagraph { + text-align: center; } -.caption -{ - font-weight: bold; +.caption { + font-weight: bold; } dl.citelist { - margin-bottom:50px; + margin-bottom: 50px; } dl.citelist dt { - color:var(--citation-label-color); - float:left; - font-weight:bold; - margin-right:10px; - padding:5px; - text-align:right; - width:52px; + color: var(--citation-label-color); + float: left; + font-weight: bold; + margin-right: 10px; + padding: 5px; + text-align: right; + width: 52px; } dl.citelist dd { - margin:2px 0 2px 72px; - padding:5px 0; + margin: 2px 0 2px 72px; + padding: 5px 0; } div.toc { - padding: 14px 25px; - background-color: var(--toc-background-color); - border: 1px solid var(--toc-border-color); - border-radius: 7px 7px 7px 7px; - float: right; - height: auto; - margin: 0 8px 10px 10px; - width: 200px; + padding: 14px 25px; + background-color: var(--toc-background-color); + border: 1px solid var(--toc-border-color); + border-radius: 7px 7px 7px 7px; + float: right; + height: auto; + margin: 0 8px 10px 10px; + width: 200px; } div.toc li { - background: var(--toc-down-arrow-image) no-repeat scroll 0 5px transparent; - font: 10px/1.2 var(--font-family-toc); - margin-top: 5px; - padding-left: 10px; - padding-top: 2px; + background: var(--toc-down-arrow-image) no-repeat scroll 0 5px transparent; + font: 10px/1.2 var(--font-family-toc); + margin-top: 5px; + padding-left: 10px; + padding-top: 2px; } div.toc h3 { - font: bold 12px/1.2 var(--font-family-toc); - color: var(--toc-header-color); - border-bottom: 0 none; - margin: 0; + font: bold 12px/1.2 var(--font-family-toc); + color: var(--toc-header-color); + border-bottom: 0 none; + margin: 0; } div.toc ul { - list-style: none outside none; - border: medium none; - padding: 0px; + list-style: none outside none; + border: medium none; + padding: 0px; } -div.toc li[class^='level'] { - margin-left: 15px; +div.toc li[class^="level"] { + margin-left: 15px; } div.toc li.level1 { - margin-left: 0px; + margin-left: 0px; } div.toc li.empty { - background-image: none; - margin-top: 0px; + background-image: none; + margin-top: 0px; } span.emoji { - /* font family used at the site: https://unicode.org/emoji/charts/full-emoji-list.html + /* font family used at the site: https://unicode.org/emoji/charts/full-emoji-list.html * font-family: "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", Times, Symbola, Aegyptus, Code2000, Code2001, Code2002, Musica, serif, LastResort; */ } span.obfuscator { - display: none; + display: none; } .inherit_header { - font-weight: 400; - cursor: pointer; - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + font-weight: 400; + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } .inherit_header td { - padding: 6px 0 2px 0; + padding: 6px 0 2px 0; } .inherit { - display: none; + display: none; } tr.heading h2 { - margin-top: 12px; - margin-bottom: 12px; + margin-top: 12px; + margin-bottom: 12px; } /* tooltip related style info */ .ttc { - position: absolute; - display: none; + position: absolute; + display: none; } #powerTip { - cursor: default; - color: var(--tooltip-foreground-color); - background-color: var(--tooltip-background-color); - backdrop-filter: var(--tooltip-backdrop-filter); - -webkit-backdrop-filter: var(--tooltip-backdrop-filter); - border: 1px solid var(--tooltip-border-color); - border-radius: 4px; - box-shadow: var(--tooltip-shadow); - display: none; - font-size: smaller; - max-width: 80%; - padding: 1ex 1em 1em; - position: absolute; - z-index: 2147483647; + cursor: default; + color: var(--tooltip-foreground-color); + background-color: var(--tooltip-background-color); + backdrop-filter: var(--tooltip-backdrop-filter); + -webkit-backdrop-filter: var(--tooltip-backdrop-filter); + border: 1px solid var(--tooltip-border-color); + border-radius: 4px; + box-shadow: var(--tooltip-shadow); + display: none; + font-size: smaller; + max-width: 80%; + padding: 1ex 1em 1em; + position: absolute; + z-index: 2147483647; } #powerTip div.ttdoc { - color: var(--tooltip-doc-color); - font-style: italic; + color: var(--tooltip-doc-color); + font-style: italic; } #powerTip div.ttname a { - font-weight: bold; + font-weight: bold; } #powerTip a { - color: var(--tooltip-link-color); + color: var(--tooltip-link-color); } #powerTip div.ttname { - font-weight: bold; + font-weight: bold; } #powerTip div.ttdeci { - color: var(--tooltip-declaration-color); + color: var(--tooltip-declaration-color); } #powerTip div { - margin: 0px; - padding: 0px; - font-size: 12px; - font-family: var(--font-family-tooltip); - line-height: 16px; + margin: 0px; + padding: 0px; + font-size: 12px; + font-family: var(--font-family-tooltip); + line-height: 16px; } -#powerTip:before, #powerTip:after { - content: ""; - position: absolute; - margin: 0px; +#powerTip:before, +#powerTip:after { + content: ""; + position: absolute; + margin: 0px; } -#powerTip.n:after, #powerTip.n:before, -#powerTip.s:after, #powerTip.s:before, -#powerTip.w:after, #powerTip.w:before, -#powerTip.e:after, #powerTip.e:before, -#powerTip.ne:after, #powerTip.ne:before, -#powerTip.se:after, #powerTip.se:before, -#powerTip.nw:after, #powerTip.nw:before, -#powerTip.sw:after, #powerTip.sw:before { - border: solid transparent; - content: " "; - height: 0; - width: 0; - position: absolute; +#powerTip.n:after, +#powerTip.n:before, +#powerTip.s:after, +#powerTip.s:before, +#powerTip.w:after, +#powerTip.w:before, +#powerTip.e:after, +#powerTip.e:before, +#powerTip.ne:after, +#powerTip.ne:before, +#powerTip.se:after, +#powerTip.se:before, +#powerTip.nw:after, +#powerTip.nw:before, +#powerTip.sw:after, +#powerTip.sw:before { + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; } -#powerTip.n:after, #powerTip.s:after, -#powerTip.w:after, #powerTip.e:after, -#powerTip.nw:after, #powerTip.ne:after, -#powerTip.sw:after, #powerTip.se:after { - border-color: rgba(255, 255, 255, 0); +#powerTip.n:after, +#powerTip.s:after, +#powerTip.w:after, +#powerTip.e:after, +#powerTip.nw:after, +#powerTip.ne:after, +#powerTip.sw:after, +#powerTip.se:after { + border-color: rgba(255, 255, 255, 0); } -#powerTip.n:before, #powerTip.s:before, -#powerTip.w:before, #powerTip.e:before, -#powerTip.nw:before, #powerTip.ne:before, -#powerTip.sw:before, #powerTip.se:before { - border-color: rgba(128, 128, 128, 0); +#powerTip.n:before, +#powerTip.s:before, +#powerTip.w:before, +#powerTip.e:before, +#powerTip.nw:before, +#powerTip.ne:before, +#powerTip.sw:before, +#powerTip.se:before { + border-color: rgba(128, 128, 128, 0); } -#powerTip.n:after, #powerTip.n:before, -#powerTip.ne:after, #powerTip.ne:before, -#powerTip.nw:after, #powerTip.nw:before { - top: 100%; +#powerTip.n:after, +#powerTip.n:before, +#powerTip.ne:after, +#powerTip.ne:before, +#powerTip.nw:after, +#powerTip.nw:before { + top: 100%; } -#powerTip.n:after, #powerTip.ne:after, #powerTip.nw:after { - border-top-color: var(--tooltip-arrow-background-color); - border-width: 10px; - margin: 0px -10px; +#powerTip.n:after, +#powerTip.ne:after, +#powerTip.nw:after { + border-top-color: var(--tooltip-arrow-background-color); + border-width: 10px; + margin: 0px -10px; } -#powerTip.n:before, #powerTip.ne:before, #powerTip.nw:before { - border-top-color: var(--tooltip-border-color); - border-width: 11px; - margin: 0px -11px; +#powerTip.n:before, +#powerTip.ne:before, +#powerTip.nw:before { + border-top-color: var(--tooltip-border-color); + border-width: 11px; + margin: 0px -11px; } -#powerTip.n:after, #powerTip.n:before { - left: 50%; +#powerTip.n:after, +#powerTip.n:before { + left: 50%; } -#powerTip.nw:after, #powerTip.nw:before { - right: 14px; +#powerTip.nw:after, +#powerTip.nw:before { + right: 14px; } -#powerTip.ne:after, #powerTip.ne:before { - left: 14px; +#powerTip.ne:after, +#powerTip.ne:before { + left: 14px; } -#powerTip.s:after, #powerTip.s:before, -#powerTip.se:after, #powerTip.se:before, -#powerTip.sw:after, #powerTip.sw:before { - bottom: 100%; +#powerTip.s:after, +#powerTip.s:before, +#powerTip.se:after, +#powerTip.se:before, +#powerTip.sw:after, +#powerTip.sw:before { + bottom: 100%; } -#powerTip.s:after, #powerTip.se:after, #powerTip.sw:after { - border-bottom-color: var(--tooltip-arrow-background-color); - border-width: 10px; - margin: 0px -10px; +#powerTip.s:after, +#powerTip.se:after, +#powerTip.sw:after { + border-bottom-color: var(--tooltip-arrow-background-color); + border-width: 10px; + margin: 0px -10px; } -#powerTip.s:before, #powerTip.se:before, #powerTip.sw:before { - border-bottom-color: var(--tooltip-border-color); - border-width: 11px; - margin: 0px -11px; +#powerTip.s:before, +#powerTip.se:before, +#powerTip.sw:before { + border-bottom-color: var(--tooltip-border-color); + border-width: 11px; + margin: 0px -11px; } -#powerTip.s:after, #powerTip.s:before { - left: 50%; +#powerTip.s:after, +#powerTip.s:before { + left: 50%; } -#powerTip.sw:after, #powerTip.sw:before { - right: 14px; +#powerTip.sw:after, +#powerTip.sw:before { + right: 14px; } -#powerTip.se:after, #powerTip.se:before { - left: 14px; +#powerTip.se:after, +#powerTip.se:before { + left: 14px; } -#powerTip.e:after, #powerTip.e:before { - left: 100%; +#powerTip.e:after, +#powerTip.e:before { + left: 100%; } #powerTip.e:after { - border-left-color: var(--tooltip-border-color); - border-width: 10px; - top: 50%; - margin-top: -10px; + border-left-color: var(--tooltip-border-color); + border-width: 10px; + top: 50%; + margin-top: -10px; } #powerTip.e:before { - border-left-color: var(--tooltip-border-color); - border-width: 11px; - top: 50%; - margin-top: -11px; + border-left-color: var(--tooltip-border-color); + border-width: 11px; + top: 50%; + margin-top: -11px; } -#powerTip.w:after, #powerTip.w:before { - right: 100%; +#powerTip.w:after, +#powerTip.w:before { + right: 100%; } #powerTip.w:after { - border-right-color: var(--tooltip-border-color); - border-width: 10px; - top: 50%; - margin-top: -10px; + border-right-color: var(--tooltip-border-color); + border-width: 10px; + top: 50%; + margin-top: -10px; } #powerTip.w:before { - border-right-color: var(--tooltip-border-color); - border-width: 11px; - top: 50%; - margin-top: -11px; + border-right-color: var(--tooltip-border-color); + border-width: 11px; + top: 50%; + margin-top: -11px; } -@media print -{ - #top { display: none; } - #side-nav { display: none; } - #nav-path { display: none; } - body { overflow:visible; } - h1, h2, h3, h4, h5, h6 { page-break-after: avoid; } - .summary { display: none; } - .memitem { page-break-inside: avoid; } - #doc-content - { - margin-left:0 !important; - height:auto !important; - width:auto !important; - overflow:inherit; - display:inline; - } +@media print { + #top { + display: none; + } + #side-nav { + display: none; + } + #nav-path { + display: none; + } + body { + overflow: visible; + } + h1, + h2, + h3, + h4, + h5, + h6 { + page-break-after: avoid; + } + .summary { + display: none; + } + .memitem { + page-break-inside: avoid; + } + #doc-content { + margin-left: 0 !important; + height: auto !important; + width: auto !important; + overflow: inherit; + display: inline; + } } /* @group Markdown */ table.markdownTable { - border-collapse:collapse; - margin-top: 4px; - margin-bottom: 4px; + border-collapse: collapse; + margin-top: 4px; + margin-bottom: 4px; } -table.markdownTable td, table.markdownTable th { - border: 1px solid var(--table-cell-border-color); - padding: 3px 7px 2px; +table.markdownTable td, +table.markdownTable th { + border: 1px solid var(--table-cell-border-color); + padding: 3px 7px 2px; } table.markdownTable tr { } -th.markdownTableHeadLeft, th.markdownTableHeadRight, th.markdownTableHeadCenter, th.markdownTableHeadNone { - background-color: var(--table-header-background-color); - color: var(--table-header-foreground-color); - font-size: 110%; - padding-bottom: 4px; - padding-top: 5px; +th.markdownTableHeadLeft, +th.markdownTableHeadRight, +th.markdownTableHeadCenter, +th.markdownTableHeadNone { + background-color: var(--table-header-background-color); + color: var(--table-header-foreground-color); + font-size: 110%; + padding-bottom: 4px; + padding-top: 5px; } -th.markdownTableHeadLeft, td.markdownTableBodyLeft { - text-align: left +th.markdownTableHeadLeft, +td.markdownTableBodyLeft { + text-align: left; } -th.markdownTableHeadRight, td.markdownTableBodyRight { - text-align: right +th.markdownTableHeadRight, +td.markdownTableBodyRight { + text-align: right; } -th.markdownTableHeadCenter, td.markdownTableBodyCenter { - text-align: center +th.markdownTableHeadCenter, +td.markdownTableBodyCenter { + text-align: center; } -tt, code, kbd -{ - display: inline-block; +tt, +code, +kbd { + display: inline-block; } -tt, code, kbd -{ - vertical-align: top; +tt, +code, +kbd { + vertical-align: top; } /* @end */ u { - text-decoration: underline; + text-decoration: underline; } -details>summary { - list-style-type: none; +details > summary { + list-style-type: none; } details > summary::-webkit-details-marker { - display: none; + display: none; } -details>summary::before { - content: "\25ba"; - padding-right:4px; - font-size: 80%; +details > summary::before { + content: "\25ba"; + padding-right: 4px; + font-size: 80%; } -details[open]>summary::before { - content: "\25bc"; - padding-right:4px; - font-size: 80%; +details[open] > summary::before { + content: "\25bc"; + padding-right: 4px; + font-size: 80%; } :root { - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-background-color); + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb-color) + var(--scrollbar-background-color); } ::-webkit-scrollbar { - background-color: var(--scrollbar-background-color); - height: 12px; - width: 12px; + background-color: var(--scrollbar-background-color); + height: 12px; + width: 12px; } ::-webkit-scrollbar-thumb { - border-radius: 6px; - box-shadow: inset 0 0 12px 12px var(--scrollbar-thumb-color); - border: solid 2px transparent; + border-radius: 6px; + box-shadow: inset 0 0 12px 12px var(--scrollbar-thumb-color); + border: solid 2px transparent; } ::-webkit-scrollbar-corner { - background-color: var(--scrollbar-background-color); + background-color: var(--scrollbar-background-color); } - From d7b9ce76a68baffa6ab1f70a2a5a79f00eb6105f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Oct 2025 08:47:01 -0700 Subject: [PATCH 032/702] typos --- dist/doxygen/stylesheet.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dist/doxygen/stylesheet.css b/dist/doxygen/stylesheet.css index 21f34435e..31ebcc685 100644 --- a/dist/doxygen/stylesheet.css +++ b/dist/doxygen/stylesheet.css @@ -112,7 +112,7 @@ html { --toc-background-color: #f4f6fa; --toc-border-color: #d8dfee; --toc-header-color: #4665a2; - --toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); + --toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); /** search field */ --search-background-color: white; @@ -163,7 +163,7 @@ html { --tooltip-arrow-background-color: white; --tooltip-border-color: rgba(150, 150, 150, 0.7); --tooltip-backdrop-filter: blur(3px); - --tooltip-doc-color: grey; + --tooltip-doc-color: gray; --tooltip-declaration-color: #006318; --tooltip-link-color: #4665a2; --tooltip-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.25); @@ -322,7 +322,7 @@ html { --toc-background-color: #151e30; --toc-border-color: #202e4a; --toc-header-color: #a3b4d7; - --toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); + --toc-down-arrow-image: url("data:image/svg+xml;utf8,&%238595;"); /** search field */ --search-background-color: black; From e4f0c366ff5b5006b6e837dc9350ae53e78e76e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Oct 2025 08:48:54 -0700 Subject: [PATCH 033/702] lib-vt docs: add etags to the pages --- src/build/docker/lib-c-docs/entrypoint.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/build/docker/lib-c-docs/entrypoint.sh b/src/build/docker/lib-c-docs/entrypoint.sh index 67cc44b03..ac9ca1c06 100755 --- a/src/build/docker/lib-c-docs/entrypoint.sh +++ b/src/build/docker/lib-c-docs/entrypoint.sh @@ -6,6 +6,8 @@ server { location / { root /usr/share/nginx/html; index index.html; + etag on; + add_header Cache-Control "no-cache" always; add_header X-Robots-Tag "noindex, nofollow" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always; } @@ -20,6 +22,8 @@ server { location / { root /usr/share/nginx/html; index index.html; + etag on; + add_header Cache-Control "no-cache" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always; } } From e1e5bfc09682b233f5d0ea65d20497ba42b5275a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Oct 2025 09:17:14 -0700 Subject: [PATCH 034/702] apprt/gtk: only close with no windows active if close delay is off Fixes #9052 --- src/apprt/gtk/class/application.zig | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 07663fec9..a35cd5b3f 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -530,12 +530,16 @@ pub const Application = extern struct { } // If we have no windows attached to our app, also quit. - if (priv.requested_window and @as( - ?*glib.List, - self.as(gtk.Application).getWindows(), - ) == null) { - log.debug("must_quit due to no app windows", .{}); - break :q true; + // We only do this if we don't have the closed delay set, + // because with the closed delay set we'll exit eventually. + if (config.@"quit-after-last-window-closed-delay" == null) { + if (priv.requested_window and @as( + ?*glib.List, + self.as(gtk.Application).getWindows(), + ) == null) { + log.debug("must_quit due to no app windows", .{}); + break :q true; + } } // No quit conditions met From debdf6bf03ff25a4ccbf00c1c74bb4f723de3e00 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Oct 2025 14:52:09 -0500 Subject: [PATCH 035/702] osc: parse additional OSC 133 options OSC 133;A can have: - special_key - click_events OSC 133;C can have: - cmdline - cmdline_url Notably, they are in use by `fish`. Not sure what other shells currently use these options. Note that the options are only parsed. Nothing further is done with them at this point. --- src/inspector/termio.zig | 5 + src/terminal/osc.zig | 228 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 218 insertions(+), 15 deletions(-) diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 03a3b0375..212f0ea4a 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -264,6 +264,11 @@ pub const VTEvent = struct { if (std.mem.eql(u8, field.name, tag_name)) { const s = if (field.type == void) try alloc.dupeZ(u8, tag_name) + else if (field.type == [:0]const u8 or field.type == []const u8) + try std.fmt.allocPrintSentinel(alloc, "{s}={s}", .{ + tag_name, + @field(value, field.name), + }, 0) else try std.fmt.allocPrintSentinel(alloc, "{s}={}", .{ tag_name, diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 1d41d95f2..4b0f9553c 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -44,19 +44,33 @@ pub const Command = union(Key) { /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a /// prompt string (as if followed by OSC 133;P;k=i\007). Note: I've noticed /// not all shells will send the prompt end code. - /// - /// "aid" is an optional "application identifier" that helps disambiguate - /// nested shell sessions. It can be anything but is usually a process ID. - /// - /// "kind" tells us which kind of semantic prompt sequence this is: - /// - primary: normal, left-aligned first-line prompt (initial, default) - /// - continuation: an editable continuation line - /// - secondary: a non-editable continuation line - /// - right: a right-aligned prompt that may need adjustment during reflow prompt_start: struct { + /// "aid" is an optional "application identifier" that helps disambiguate + /// nested shell sessions. It can be anything but is usually a process ID. aid: ?[:0]const u8 = null, + /// "kind" tells us which kind of semantic prompt sequence this is: + /// - primary: normal, left-aligned first-line prompt (initial, default) + /// - continuation: an editable continuation line + /// - secondary: a non-editable continuation line + /// - right: a right-aligned prompt that may need adjustment during reflow kind: enum { primary, continuation, secondary, right } = .primary, + /// If true, the shell will not redraw the prompt on resize so don't erase it. + /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers redraw: bool = true, + /// Use a special key instead of arrow keys to move the cursor on + /// mouse click. Useful if arrow keys have side-effets like triggering + /// auto-complete. The shell integration script should bind the special + /// key as needed. + /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + special_key: bool = false, + /// If true, the shell is capable of handling mouse click events. + /// Ghostty will then send a click event to the shell when the user + /// clicks somewhere in the prompt. The shell can then move the cursor + /// to that position or perform some other appropriate action. If false, + /// Ghostty may generate a number of fake key events to move the cursor + /// which is not very robust. + /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + click_events: bool = false, }, /// End of prompt and start of user input, terminated by a OSC "133;C" @@ -72,7 +86,16 @@ pub const Command = union(Key) { /// OSC "133;I" then this is the start of a continuation input line. /// If we see anything else, it is the start of the output area (or end /// of command). - end_of_input: void, + end_of_input: struct { + /// The command line that the user entered. + /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + cmdline: ?union(enum) { + /// The command line has been encoded with bash's 'printf "%q"'. + printf_q_encoded: [:0]const u8, + /// The command line has been encoded with URL percent encoding. + percent_encoded: [:0]const u8, + } = null, + }, /// End of current command. /// @@ -1286,7 +1309,7 @@ pub const Parser = struct { 'C' => { self.state = .semantic_option_start; - self.command = .{ .end_of_input = {} }; + self.command = .{ .end_of_input = .{} }; self.complete = true; }, @@ -1456,11 +1479,24 @@ pub const Parser = struct { .prompt_start => |*v| v.aid = value, else => {}, } + } else if (mem.eql(u8, self.temp_state.key, "cmdline")) { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + switch (self.command) { + .end_of_input => |*v| v.cmdline = .{ + .printf_q_encoded = value, + }, + else => {}, + } + } else if (mem.eql(u8, self.temp_state.key, "cmdline_url")) { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + switch (self.command) { + .end_of_input => |*v| v.cmdline = .{ + .percent_encoded = value, + }, + else => {}, + } } else if (mem.eql(u8, self.temp_state.key, "redraw")) { - // Kitty supports a "redraw" option for prompt_start. I can't find - // this documented anywhere but can see in the code that this is used - // by shell environments to tell the terminal that the shell will NOT - // redraw the prompt so we should attempt to resize it. + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers switch (self.command) { .prompt_start => |*v| { const valid = if (value.len == 1) valid: { @@ -1479,7 +1515,48 @@ pub const Parser = struct { }, else => {}, } + } else if (mem.eql(u8, self.temp_state.key, "special_key")) { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + switch (self.command) { + .prompt_start => |*v| { + const valid = if (value.len == 1) valid: { + switch (value[0]) { + '0' => v.special_key = false, + '1' => v.special_key = true, + else => break :valid false, + } + + break :valid true; + } else false; + + if (!valid) { + log.info("OSC 133 A invalid special_key value: {s}", .{value}); + } + }, + else => {}, + } + } else if (mem.eql(u8, self.temp_state.key, "click_events")) { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers + switch (self.command) { + .prompt_start => |*v| { + const valid = if (value.len == 1) valid: { + switch (value[0]) { + '0' => v.click_events = false, + '1' => v.click_events = true, + else => break :valid false, + } + + break :valid true; + } else false; + + if (!valid) { + log.info("OSC 133 A invalid click_events value: {s}", .{value}); + } + }, + else => {}, + } } else if (mem.eql(u8, self.temp_state.key, "k")) { + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers // The "k" marks the kind of prompt, or "primary" if we don't know. // This can be used to distinguish between the first (initial) prompt, // a continuation, etc. @@ -2846,6 +2923,97 @@ test "OSC 133: prompt_start with secondary" { try testing.expect(cmd.prompt_start.kind == .secondary); } +test "OSC 133: prompt_start with special_key" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;special_key=1"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == true); +} + +test "OSC 133: prompt_start with special_key invalid" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;special_key=bobr"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == false); +} + +test "OSC 133: prompt_start with special_key 0" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;special_key=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == false); +} + +test "OSC 133: prompt_start with special_key empty" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;special_key="; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.special_key == false); +} + +test "OSC 133: prompt_start with click_events true" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;click_events=1"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.click_events == true); +} + +test "OSC 133: prompt_start with click_events false" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;click_events=0"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.click_events == false); +} + +test "OSC 133: prompt_start with click_events empty" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;A;click_events="; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .prompt_start); + try testing.expect(cmd.prompt_start.click_events == false); +} + test "OSC 133: end_of_command no exit code" { const testing = std.testing; @@ -2895,6 +3063,36 @@ test "OSC 133: end_of_input" { try testing.expect(cmd == .end_of_input); } +test "OSC 133: end_of_input with cmdline" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expect(cmd.end_of_input.cmdline.? == .printf_q_encoded); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?.printf_q_encoded); +} + +test "OSC 133: end_of_input with cmdline_url" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expect(cmd.end_of_input.cmdline.? == .percent_encoded); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?.percent_encoded); +} + test "OSC: OSC 777 show desktop notification with title" { const testing = std.testing; From f72bbb50381176e85a338654992e35a47d8a0afa Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Oct 2025 15:01:16 -0500 Subject: [PATCH 036/702] fix custom-shader writergate breakage Fixes: #9060 --- src/renderer/shadertoy.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig index d31c36dee..b0a190a8b 100644 --- a/src/renderer/shadertoy.zig +++ b/src/renderer/shadertoy.zig @@ -80,9 +80,7 @@ pub fn loadFromFile( const file = try cwd.openFile(path, .{}); defer file.close(); - var buf: [4096]u8 = undefined; - var reader = file.reader(&buf); - break :src try reader.interface.readAlloc( + break :src try file.readToEndAlloc( alloc, 4 * 1024 * 1024, // 4MB ); From 725203d494c70d531eb75053a29072e2a3e119fe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Oct 2025 20:35:45 -0700 Subject: [PATCH 037/702] lib-vt: split header to be more consumable --- AGENTS.md | 1 + Doxyfile | 5 +- include/ghostty/vt.h | 1147 +----------------------------- include/ghostty/vt/allocator.h | 196 +++++ include/ghostty/vt/key.h | 80 +++ include/ghostty/vt/key/encoder.h | 221 ++++++ include/ghostty/vt/key/event.h | 474 ++++++++++++ include/ghostty/vt/osc.h | 231 ++++++ include/ghostty/vt/result.h | 20 + src/build/GhosttyLibVt.zig | 7 +- 10 files changed, 1234 insertions(+), 1148 deletions(-) create mode 100644 include/ghostty/vt/allocator.h create mode 100644 include/ghostty/vt/key.h create mode 100644 include/ghostty/vt/key/encoder.h create mode 100644 include/ghostty/vt/key/event.h create mode 100644 include/ghostty/vt/osc.h create mode 100644 include/ghostty/vt/result.h diff --git a/AGENTS.md b/AGENTS.md index 14fff7b3d..afa0fd1f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ A file for [guiding coding agents](https://agents.md/). - Test: `zig build test-lib-vt` - Test filter: `zig build test-lib-vt -Dtest-filter=` - When working on libghostty-vt, do not build the full app. +- For C only changes, don't run the Zig tests. Build all the examples. ## macOS App diff --git a/Doxyfile b/Doxyfile index 58b6be48a..63e73334d 100644 --- a/Doxyfile +++ b/Doxyfile @@ -3,9 +3,10 @@ DOXYFILE_ENCODING = UTF-8 PROJECT_NAME = "libghostty" PROJECT_LOGO = images/gnome/64.png -INPUT = include/ghostty/vt.h +INPUT = include/ghostty INPUT_ENCODING = UTF-8 -RECURSIVE = NO +RECURSIVE = YES +FILE_PATTERNS = *.h EXAMPLE_PATH = example EXAMPLE_RECURSIVE = YES EXAMPLE_PATTERNS = * diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index bcbb01d00..489996530 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -57,1149 +57,10 @@ extern "C" { #endif -#include -#include -#include - -//------------------------------------------------------------------- -// Types - -/** - * Opaque handle to an OSC parser instance. - * - * This handle represents an OSC (Operating System Command) parser that can - * be used to parse the contents of OSC sequences. This isn't a full VT - * parser; it is only the OSC parser component. This is useful if you have - * a parser already and want to only extract and handle OSC sequences. - * - * @ingroup osc - */ -typedef struct GhosttyOscParser *GhosttyOscParser; - -/** - * Opaque handle to a single OSC command. - * - * This handle represents a parsed OSC (Operating System Command) command. - * The command can be queried for its type and associated data using - * `ghostty_osc_command_type` and `ghostty_osc_command_data`. - * - * @ingroup osc - */ -typedef struct GhosttyOscCommand *GhosttyOscCommand; - -/** - * Result codes for libghostty-vt operations. - */ -typedef enum { - /** Operation completed successfully */ - GHOSTTY_SUCCESS = 0, - /** Operation failed due to failed allocation */ - GHOSTTY_OUT_OF_MEMORY = -1, -} GhosttyResult; - -/** - * OSC command types. - * - * @ingroup osc - */ -typedef enum { - GHOSTTY_OSC_COMMAND_INVALID = 0, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, - GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, - GHOSTTY_OSC_COMMAND_PROMPT_START = 3, - GHOSTTY_OSC_COMMAND_PROMPT_END = 4, - GHOSTTY_OSC_COMMAND_END_OF_INPUT = 5, - GHOSTTY_OSC_COMMAND_END_OF_COMMAND = 6, - GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 7, - GHOSTTY_OSC_COMMAND_REPORT_PWD = 8, - GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 9, - GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 10, - GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 11, - GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 12, - GHOSTTY_OSC_COMMAND_HYPERLINK_START = 13, - GHOSTTY_OSC_COMMAND_HYPERLINK_END = 14, - GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 15, - GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 16, - GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 17, - GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 18, - GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 19, - GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20, -} GhosttyOscCommandType; - -/** - * OSC command data types. - * - * These values specify what type of data to extract from an OSC command - * using `ghostty_osc_command_data`. - * - * @ingroup osc - */ -typedef enum { - /** Invalid data type. Never results in any data extraction. */ - GHOSTTY_OSC_DATA_INVALID = 0, - - /** - * Window title string data. - * - * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE - * - * Output type: const char ** (pointer to null-terminated string) - * - * Lifetime: Valid until the next call to any ghostty_osc_* function with - * the same parser instance. Memory is owned by the parser. - */ - GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, -} GhosttyOscCommandData; - -//------------------------------------------------------------------- -// Allocator Interface - -/** @defgroup allocator Memory Management - * - * libghostty-vt does require memory allocation for various operations, - * but is resilient to allocation failures and will gracefully handle - * out-of-memory situations by returning error codes. - * - * The exact memory management semantics are documented in the relevant - * functions and data structures. - * - * libghostty-vt uses explicit memory allocation via an allocator - * interface provided by GhosttyAllocator. The interface is based on the - * [Zig](https://ziglang.org) allocator interface, since this has been - * shown to be a flexible and powerful interface in practice and enables - * a wide variety of allocation strategies. - * - * **For the common case, you can pass NULL as the allocator for any - * function that accepts one,** and libghostty will use a default allocator. - * The default allocator will be libc malloc/free if libc is linked. - * Otherwise, a custom allocator is used (currently Zig's SMP allocator) - * that doesn't require any external dependencies. - * - * ## Basic Usage - * - * For simple use cases, you can ignore this interface entirely by passing NULL - * as the allocator parameter to functions that accept one. This will use the - * default allocator (typically libc malloc/free, if libc is linked, but - * we provide our own default allocator if libc isn't linked). - * - * To use a custom allocator: - * 1. Implement the GhosttyAllocatorVtable function pointers - * 2. Create a GhosttyAllocator struct with your vtable and context - * 3. Pass the allocator to functions that accept one - * - * @{ - */ - -/** - * Function table for custom memory allocator operations. - * - * This vtable defines the interface for a custom memory allocator. All - * function pointers must be valid and non-NULL. - * - * @ingroup allocator - * - * If you're not going to use a custom allocator, you can ignore all of - * this. All functions that take an allocator pointer allow NULL to use a - * default allocator. - * - * The interface is based on the Zig allocator interface. I'll say up front - * that it is easy to look at this interface and think "wow, this is really - * overcomplicated". The reason for this complexity is well thought out by - * the Zig folks, and it enables a diverse set of allocation strategies - * as shown by the Zig ecosystem. As a consolation, please note that many - * of the arguments are only needed for advanced use cases and can be - * safely ignored in simple implementations. For example, if you look at - * the Zig implementation of the libc allocator in `lib/std/heap.zig` - * (search for CAllocator), you'll see it is very simple. - * - * We chose to align with the Zig allocator interface because: - * - * 1. It is a proven interface that serves a wide variety of use cases - * in the real world via the Zig ecosystem. It's shown to work. - * - * 2. Our core implementation itself is Zig, and this lets us very - * cheaply and easily convert between C and Zig allocators. - * - * NOTE(mitchellh): In the future, we can have default implementations of - * resize/remap and allow those to be null. - */ -typedef struct { - /** - * Return a pointer to `len` bytes with specified `alignment`, or return - * `NULL` indicating the allocation failed. - * - * @param ctx The allocator context - * @param len Number of bytes to allocate - * @param alignment Required alignment for the allocation. Guaranteed to - * be a power of two between 1 and 16 inclusive. - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to allocated memory, or NULL if allocation failed - */ - void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory in place. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to resize - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return true if resize was successful in-place, false if relocation would be required - */ - bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Attempt to expand or shrink memory, allowing relocation. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * A non-`NULL` return value indicates the resize was successful. The - * allocation may have same address, or may have been relocated. In either - * case, the allocation now has size of `new_len`. A `NULL` return value - * indicates that the resize would be equivalent to allocating new memory, - * copying the bytes from the old memory, and then freeing the old memory. - * In such case, it is more efficient for the caller to perform the copy. - * - * `new_len` must be greater than zero. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to remap - * @param memory_len Current size of the memory block - * @param alignment Alignment (must match original allocation) - * @param new_len New requested size - * @param ret_addr First return address of the allocation call stack (0 if not provided) - * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed - */ - void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); - - /** - * Free and invalidate a region of memory. - * - * `memory_len` must equal the length requested from the most recent - * successful call to `alloc`, `resize`, or `remap`. `alignment` must - * equal the same value that was passed as the `alignment` parameter to - * the original `alloc` call. - * - * @param ctx The allocator context - * @param memory Pointer to the memory block to free - * @param memory_len Size of the memory block - * @param alignment Alignment (must match original allocation) - * @param ret_addr First return address of the allocation call stack (0 if not provided) - */ - void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); -} GhosttyAllocatorVtable; - -/** - * Custom memory allocator. - * - * For functions that take an allocator pointer, a NULL pointer indicates - * that the default allocator should be used. The default allocator will - * be libc malloc/free if we're linking to libc. If libc isn't linked, - * a custom allocator is used (currently Zig's SMP allocator). - * - * @ingroup allocator - * - * Usage example: - * @code - * GhosttyAllocator allocator = { - * .vtable = &my_allocator_vtable, - * .ctx = my_allocator_state - * }; - * @endcode - */ -typedef struct { - /** - * Opaque context pointer passed to all vtable functions. - * This allows the allocator implementation to maintain state - * or reference external resources needed for memory management. - */ - void *ctx; - - /** - * Pointer to the allocator's vtable containing function pointers - * for memory operations (alloc, resize, remap, free). - */ - const GhosttyAllocatorVtable *vtable; -} GhosttyAllocator; - -/** @} */ // end of allocator group - -//------------------------------------------------------------------- -// Key Encoding - -/** @defgroup key Key Encoding - * - * Utilities for encoding key events into terminal escape sequences, - * supporting both legacy encoding as well as Kitty Keyboard Protocol. - * - * ## Basic Usage - * - * 1. Create an encoder instance with ghostty_key_encoder_new() - * 2. Configure encoder options with ghostty_key_encoder_setopt(). - * 3. For each key event: - * - Create a key event with ghostty_key_event_new() - * - Set event properties (action, key, modifiers, etc.) - * - Encode with ghostty_key_encoder_encode() - * - Free the event with ghostty_key_event_free() - * - Note: You can also reuse the same key event multiple times by - * changing its properties. - * 4. Free the encoder with ghostty_key_encoder_free() when done - * - * ## Example - * - * @code{.c} - * #include - * #include - * #include - * - * int main() { - * // Create encoder - * GhosttyKeyEncoder encoder; - * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); - * assert(result == GHOSTTY_SUCCESS); - * - * // Enable Kitty keyboard protocol with all features - * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, - * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); - * - * // Create and configure key event for Ctrl+C press - * GhosttyKeyEvent event; - * result = ghostty_key_event_new(NULL, &event); - * assert(result == GHOSTTY_SUCCESS); - * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); - * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); - * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); - * - * // Encode the key event - * char buf[128]; - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence (e.g., write to terminal) - * fwrite(buf, 1, written, stdout); - * - * // Cleanup - * ghostty_key_event_free(event); - * ghostty_key_encoder_free(encoder); - * return 0; - * } - * @endcode - * - * For a complete working example, see example/c-vt-key-encode in the - * repository. - * - * @{ - */ - -/** - * Opaque handle to a key event. - * - * This handle represents a keyboard input event containing information about - * the physical key pressed, modifiers, and generated text. The event can be - * configured using the `ghostty_key_event_set_*` functions. - * - * @ingroup key - */ -typedef struct GhosttyKeyEvent *GhosttyKeyEvent; - -/** - * Keyboard input event types. - * - * @ingroup key - */ -typedef enum { - /** Key was released */ - GHOSTTY_KEY_ACTION_RELEASE = 0, - /** Key was pressed */ - GHOSTTY_KEY_ACTION_PRESS = 1, - /** Key is being repeated (held down) */ - GHOSTTY_KEY_ACTION_REPEAT = 2, -} GhosttyKeyAction; - -/** - * Keyboard modifier keys bitmask. - * - * A bitmask representing all keyboard modifiers. This tracks which modifier keys - * are pressed and, where supported by the platform, which side (left or right) - * of each modifier is active. - * - * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. - * - * Modifier side bits are only meaningful when the corresponding modifier bit is set. - * Not all platforms support distinguishing between left and right modifier - * keys and Ghostty is built to expect that some platforms may not provide this - * information. - * - * @ingroup key - */ -typedef uint16_t GhosttyMods; - -/** Shift key is pressed */ -#define GHOSTTY_MODS_SHIFT (1 << 0) -/** Control key is pressed */ -#define GHOSTTY_MODS_CTRL (1 << 1) -/** Alt/Option key is pressed */ -#define GHOSTTY_MODS_ALT (1 << 2) -/** Super/Command/Windows key is pressed */ -#define GHOSTTY_MODS_SUPER (1 << 3) -/** Caps Lock is active */ -#define GHOSTTY_MODS_CAPS_LOCK (1 << 4) -/** Num Lock is active */ -#define GHOSTTY_MODS_NUM_LOCK (1 << 5) - -/** - * Right shift is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_SHIFT is set. - */ -#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) -/** - * Right ctrl is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_CTRL is set. - */ -#define GHOSTTY_MODS_CTRL_SIDE (1 << 7) -/** - * Right alt is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_ALT is set. - */ -#define GHOSTTY_MODS_ALT_SIDE (1 << 8) -/** - * Right super is pressed (0 = left, 1 = right). - * Only meaningful when GHOSTTY_MODS_SUPER is set. - */ -#define GHOSTTY_MODS_SUPER_SIDE (1 << 9) - -/** - * Physical key codes. - * - * The set of key codes that Ghostty is aware of. These represent physical keys - * on the keyboard and are layout-independent. For example, the "a" key on a US - * keyboard is the same as the "ф" key on a Russian keyboard, but both will - * report the same key_a value. - * - * Layout-dependent strings are provided separately as UTF-8 text and are produced - * by the platform. These values are based on the W3C UI Events KeyboardEvent code - * standard. See: https://www.w3.org/TR/uievents-code - * - * @ingroup key - */ -typedef enum { - GHOSTTY_KEY_UNIDENTIFIED = 0, - - // Writing System Keys (W3C § 3.1.1) - GHOSTTY_KEY_BACKQUOTE, - GHOSTTY_KEY_BACKSLASH, - GHOSTTY_KEY_BRACKET_LEFT, - GHOSTTY_KEY_BRACKET_RIGHT, - GHOSTTY_KEY_COMMA, - GHOSTTY_KEY_DIGIT_0, - GHOSTTY_KEY_DIGIT_1, - GHOSTTY_KEY_DIGIT_2, - GHOSTTY_KEY_DIGIT_3, - GHOSTTY_KEY_DIGIT_4, - GHOSTTY_KEY_DIGIT_5, - GHOSTTY_KEY_DIGIT_6, - GHOSTTY_KEY_DIGIT_7, - GHOSTTY_KEY_DIGIT_8, - GHOSTTY_KEY_DIGIT_9, - GHOSTTY_KEY_EQUAL, - GHOSTTY_KEY_INTL_BACKSLASH, - GHOSTTY_KEY_INTL_RO, - GHOSTTY_KEY_INTL_YEN, - GHOSTTY_KEY_A, - GHOSTTY_KEY_B, - GHOSTTY_KEY_C, - GHOSTTY_KEY_D, - GHOSTTY_KEY_E, - GHOSTTY_KEY_F, - GHOSTTY_KEY_G, - GHOSTTY_KEY_H, - GHOSTTY_KEY_I, - GHOSTTY_KEY_J, - GHOSTTY_KEY_K, - GHOSTTY_KEY_L, - GHOSTTY_KEY_M, - GHOSTTY_KEY_N, - GHOSTTY_KEY_O, - GHOSTTY_KEY_P, - GHOSTTY_KEY_Q, - GHOSTTY_KEY_R, - GHOSTTY_KEY_S, - GHOSTTY_KEY_T, - GHOSTTY_KEY_U, - GHOSTTY_KEY_V, - GHOSTTY_KEY_W, - GHOSTTY_KEY_X, - GHOSTTY_KEY_Y, - GHOSTTY_KEY_Z, - GHOSTTY_KEY_MINUS, - GHOSTTY_KEY_PERIOD, - GHOSTTY_KEY_QUOTE, - GHOSTTY_KEY_SEMICOLON, - GHOSTTY_KEY_SLASH, - - // Functional Keys (W3C § 3.1.2) - GHOSTTY_KEY_ALT_LEFT, - GHOSTTY_KEY_ALT_RIGHT, - GHOSTTY_KEY_BACKSPACE, - GHOSTTY_KEY_CAPS_LOCK, - GHOSTTY_KEY_CONTEXT_MENU, - GHOSTTY_KEY_CONTROL_LEFT, - GHOSTTY_KEY_CONTROL_RIGHT, - GHOSTTY_KEY_ENTER, - GHOSTTY_KEY_META_LEFT, - GHOSTTY_KEY_META_RIGHT, - GHOSTTY_KEY_SHIFT_LEFT, - GHOSTTY_KEY_SHIFT_RIGHT, - GHOSTTY_KEY_SPACE, - GHOSTTY_KEY_TAB, - GHOSTTY_KEY_CONVERT, - GHOSTTY_KEY_KANA_MODE, - GHOSTTY_KEY_NON_CONVERT, - - // Control Pad Section (W3C § 3.2) - GHOSTTY_KEY_DELETE, - GHOSTTY_KEY_END, - GHOSTTY_KEY_HELP, - GHOSTTY_KEY_HOME, - GHOSTTY_KEY_INSERT, - GHOSTTY_KEY_PAGE_DOWN, - GHOSTTY_KEY_PAGE_UP, - - // Arrow Pad Section (W3C § 3.3) - GHOSTTY_KEY_ARROW_DOWN, - GHOSTTY_KEY_ARROW_LEFT, - GHOSTTY_KEY_ARROW_RIGHT, - GHOSTTY_KEY_ARROW_UP, - - // Numpad Section (W3C § 3.4) - GHOSTTY_KEY_NUM_LOCK, - GHOSTTY_KEY_NUMPAD_0, - GHOSTTY_KEY_NUMPAD_1, - GHOSTTY_KEY_NUMPAD_2, - GHOSTTY_KEY_NUMPAD_3, - GHOSTTY_KEY_NUMPAD_4, - GHOSTTY_KEY_NUMPAD_5, - GHOSTTY_KEY_NUMPAD_6, - GHOSTTY_KEY_NUMPAD_7, - GHOSTTY_KEY_NUMPAD_8, - GHOSTTY_KEY_NUMPAD_9, - GHOSTTY_KEY_NUMPAD_ADD, - GHOSTTY_KEY_NUMPAD_BACKSPACE, - GHOSTTY_KEY_NUMPAD_CLEAR, - GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, - GHOSTTY_KEY_NUMPAD_COMMA, - GHOSTTY_KEY_NUMPAD_DECIMAL, - GHOSTTY_KEY_NUMPAD_DIVIDE, - GHOSTTY_KEY_NUMPAD_ENTER, - GHOSTTY_KEY_NUMPAD_EQUAL, - GHOSTTY_KEY_NUMPAD_MEMORY_ADD, - GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, - GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, - GHOSTTY_KEY_NUMPAD_MEMORY_STORE, - GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, - GHOSTTY_KEY_NUMPAD_MULTIPLY, - GHOSTTY_KEY_NUMPAD_PAREN_LEFT, - GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, - GHOSTTY_KEY_NUMPAD_SUBTRACT, - GHOSTTY_KEY_NUMPAD_SEPARATOR, - GHOSTTY_KEY_NUMPAD_UP, - GHOSTTY_KEY_NUMPAD_DOWN, - GHOSTTY_KEY_NUMPAD_RIGHT, - GHOSTTY_KEY_NUMPAD_LEFT, - GHOSTTY_KEY_NUMPAD_BEGIN, - GHOSTTY_KEY_NUMPAD_HOME, - GHOSTTY_KEY_NUMPAD_END, - GHOSTTY_KEY_NUMPAD_INSERT, - GHOSTTY_KEY_NUMPAD_DELETE, - GHOSTTY_KEY_NUMPAD_PAGE_UP, - GHOSTTY_KEY_NUMPAD_PAGE_DOWN, - - // Function Section (W3C § 3.5) - GHOSTTY_KEY_ESCAPE, - GHOSTTY_KEY_F1, - GHOSTTY_KEY_F2, - GHOSTTY_KEY_F3, - GHOSTTY_KEY_F4, - GHOSTTY_KEY_F5, - GHOSTTY_KEY_F6, - GHOSTTY_KEY_F7, - GHOSTTY_KEY_F8, - GHOSTTY_KEY_F9, - GHOSTTY_KEY_F10, - GHOSTTY_KEY_F11, - GHOSTTY_KEY_F12, - GHOSTTY_KEY_F13, - GHOSTTY_KEY_F14, - GHOSTTY_KEY_F15, - GHOSTTY_KEY_F16, - GHOSTTY_KEY_F17, - GHOSTTY_KEY_F18, - GHOSTTY_KEY_F19, - GHOSTTY_KEY_F20, - GHOSTTY_KEY_F21, - GHOSTTY_KEY_F22, - GHOSTTY_KEY_F23, - GHOSTTY_KEY_F24, - GHOSTTY_KEY_F25, - GHOSTTY_KEY_FN, - GHOSTTY_KEY_FN_LOCK, - GHOSTTY_KEY_PRINT_SCREEN, - GHOSTTY_KEY_SCROLL_LOCK, - GHOSTTY_KEY_PAUSE, - - // Media Keys (W3C § 3.6) - GHOSTTY_KEY_BROWSER_BACK, - GHOSTTY_KEY_BROWSER_FAVORITES, - GHOSTTY_KEY_BROWSER_FORWARD, - GHOSTTY_KEY_BROWSER_HOME, - GHOSTTY_KEY_BROWSER_REFRESH, - GHOSTTY_KEY_BROWSER_SEARCH, - GHOSTTY_KEY_BROWSER_STOP, - GHOSTTY_KEY_EJECT, - GHOSTTY_KEY_LAUNCH_APP_1, - GHOSTTY_KEY_LAUNCH_APP_2, - GHOSTTY_KEY_LAUNCH_MAIL, - GHOSTTY_KEY_MEDIA_PLAY_PAUSE, - GHOSTTY_KEY_MEDIA_SELECT, - GHOSTTY_KEY_MEDIA_STOP, - GHOSTTY_KEY_MEDIA_TRACK_NEXT, - GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, - GHOSTTY_KEY_POWER, - GHOSTTY_KEY_SLEEP, - GHOSTTY_KEY_AUDIO_VOLUME_DOWN, - GHOSTTY_KEY_AUDIO_VOLUME_MUTE, - GHOSTTY_KEY_AUDIO_VOLUME_UP, - GHOSTTY_KEY_WAKE_UP, - - // Legacy, Non-standard, and Special Keys (W3C § 3.7) - GHOSTTY_KEY_COPY, - GHOSTTY_KEY_CUT, - GHOSTTY_KEY_PASTE, -} GhosttyKey; - -/** - * Kitty keyboard protocol flags. - * - * Bitflags representing the various modes of the Kitty keyboard protocol. - * These can be combined using bitwise OR operations. Valid values all - * start with `GHOSTTY_KITTY_KEY_`. - * - * @ingroup key - */ -typedef uint8_t GhosttyKittyKeyFlags; - -/** Kitty keyboard protocol disabled (all flags off) */ -#define GHOSTTY_KITTY_KEY_DISABLED 0 - -/** Disambiguate escape codes */ -#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) - -/** Report key press and release events */ -#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) - -/** Report alternate key codes */ -#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) - -/** Report all key events including those normally handled by the terminal */ -#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) - -/** Report associated text with key events */ -#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) - -/** All Kitty keyboard protocol flags enabled */ -#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) - -/** - * macOS option key behavior. - * - * Determines whether the "option" key on macOS is treated as "alt" or not. - * See the Ghostty `macos-option-as-alt` configuration option for more details. - * - * @ingroup key - */ -typedef enum { - /** Option key is not treated as alt */ - GHOSTTY_OPTION_AS_ALT_FALSE = 0, - /** Option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_TRUE = 1, - /** Only left option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_LEFT = 2, - /** Only right option key is treated as alt */ - GHOSTTY_OPTION_AS_ALT_RIGHT = 3, -} GhosttyOptionAsAlt; - -/** - * Create a new key event instance. - * - * Creates a new key event with default values. The event must be freed using - * ghostty_key_event_free() when no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param event Pointer to store the created key event handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup key - */ -GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); - -/** - * Free a key event instance. - * - * Releases all resources associated with the key event. After this call, - * the event handle becomes invalid and must not be used. - * - * @param event The key event handle to free (may be NULL) - * - * @ingroup key - */ -void ghostty_key_event_free(GhosttyKeyEvent event); - -/** - * Set the key action (press, release, repeat). - * - * @param event The key event handle, must not be NULL - * @param action The action to set - * - * @ingroup key - */ -void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); - -/** - * Get the key action (press, release, repeat). - * - * @param event The key event handle, must not be NULL - * @return The key action - * - * @ingroup key - */ -GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); - -/** - * Set the physical key code. - * - * @param event The key event handle, must not be NULL - * @param key The physical key code to set - * - * @ingroup key - */ -void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); - -/** - * Get the physical key code. - * - * @param event The key event handle, must not be NULL - * @return The physical key code - * - * @ingroup key - */ -GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); - -/** - * Set the modifier keys bitmask. - * - * @param event The key event handle, must not be NULL - * @param mods The modifier keys bitmask to set - * - * @ingroup key - */ -void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); - -/** - * Get the modifier keys bitmask. - * - * @param event The key event handle, must not be NULL - * @return The modifier keys bitmask - * - * @ingroup key - */ -GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); - -/** - * Set the consumed modifiers bitmask. - * - * @param event The key event handle, must not be NULL - * @param consumed_mods The consumed modifiers bitmask to set - * - * @ingroup key - */ -void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); - -/** - * Get the consumed modifiers bitmask. - * - * @param event The key event handle, must not be NULL - * @return The consumed modifiers bitmask - * - * @ingroup key - */ -GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); - -/** - * Set whether the key event is part of a composition sequence. - * - * @param event The key event handle, must not be NULL - * @param composing Whether the key event is part of a composition sequence - * - * @ingroup key - */ -void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); - -/** - * Get whether the key event is part of a composition sequence. - * - * @param event The key event handle, must not be NULL - * @return Whether the key event is part of a composition sequence - * - * @ingroup key - */ -bool ghostty_key_event_get_composing(GhosttyKeyEvent event); - -/** - * Set the UTF-8 text generated by the key event. - * - * The key event does NOT take ownership of the text pointer. The caller - * must ensure the string remains valid for the lifetime needed by the event. - * - * @param event The key event handle, must not be NULL - * @param utf8 The UTF-8 text to set (or NULL for empty) - * @param len Length of the UTF-8 text in bytes - * - * @ingroup key - */ -void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); - -/** - * Get the UTF-8 text generated by the key event. - * - * The returned pointer is valid until the event is freed or the UTF-8 text is modified. - * - * @param event The key event handle, must not be NULL - * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) - * @return The UTF-8 text (or NULL for empty) - * - * @ingroup key - */ -const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); - -/** - * Set the unshifted Unicode codepoint. - * - * @param event The key event handle, must not be NULL - * @param codepoint The unshifted Unicode codepoint to set - * - * @ingroup key - */ -void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); - -/** - * Get the unshifted Unicode codepoint. - * - * @param event The key event handle, must not be NULL - * @return The unshifted Unicode codepoint - * - * @ingroup key - */ -uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); - -/** - * Opaque handle to a key encoder instance. - * - * This handle represents a key encoder that converts key events into terminal - * escape sequences. The encoder supports both legacy encoding and the Kitty - * Keyboard Protocol, depending on the options set. - * - * @ingroup key - */ -typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; - -/** - * Key encoder option identifiers. - * - * These values are used with ghostty_key_encoder_setopt() to configure - * the behavior of the key encoder. - * - * @ingroup key - */ -typedef enum { - /** Terminal DEC mode 1: cursor key application mode (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, - - /** Terminal DEC mode 66: keypad key application mode (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, - - /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, - - /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, - - /** xterm modifyOtherKeys mode 2 (value: bool) */ - GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, - - /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ - GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, - - /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ - GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, -} GhosttyKeyEncoderOption; - -/** - * Create a new key encoder instance. - * - * Creates a new key encoder with default options. The encoder can be configured - * using ghostty_key_encoder_setopt() and must be freed using - * ghostty_key_encoder_free() when no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param encoder Pointer to store the created encoder handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - * - * @ingroup key - */ -GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); - -/** - * Free a key encoder instance. - * - * Releases all resources associated with the key encoder. After this call, - * the encoder handle becomes invalid and must not be used. - * - * @param encoder The encoder handle to free (may be NULL) - * - * @ingroup key - */ -void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); - -/** - * Set an option on the key encoder. - * - * Configures the behavior of the key encoder. Options control various aspects - * of encoding such as terminal modes (cursor key application mode, keypad mode), - * protocol selection (Kitty keyboard protocol flags), and platform-specific - * behaviors (macOS option-as-alt). - * - * A null pointer value does nothing. It does not reset the value to the - * default. The setopt call will do nothing. - * - * @param encoder The encoder handle, must not be NULL - * @param option The option to set - * @param value Pointer to the value to set (type depends on the option) - * - * @ingroup key - */ -void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); - -/** - * Encode a key event into a terminal escape sequence. - * - * Converts a key event into the appropriate terminal escape sequence based on - * the encoder's current options. The sequence is written to the provided buffer. - * - * Not all key events produce output. For example, unmodified modifier keys - * typically don't generate escape sequences. Check the out_len parameter to - * determine if any data was written. - * - * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY - * and out_len will contain the required buffer size. The caller can then - * allocate a larger buffer and call the function again. - * - * @param encoder The encoder handle, must not be NULL - * @param event The key event to encode, must not be NULL - * @param out_buf Buffer to write the encoded sequence to - * @param out_buf_size Size of the output buffer in bytes - * @param out_len Pointer to store the number of bytes written (may be NULL) - * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code - * - * ## Example: Calculate required buffer size - * - * @code{.c} - * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) - * size_t required = 0; - * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); - * assert(result == GHOSTTY_OUT_OF_MEMORY); - * - * // Allocate buffer of required size - * char *buf = malloc(required); - * - * // Encode with properly sized buffer - * size_t written = 0; - * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); - * assert(result == GHOSTTY_SUCCESS); - * - * // Use the encoded sequence... - * - * free(buf); - * @endcode - * - * ## Example: Direct encoding with static buffer - * - * @code{.c} - * // Most escape sequences are short, so a static buffer often suffices - * char buf[128]; - * size_t written = 0; - * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); - * - * if (result == GHOSTTY_SUCCESS) { - * // Write the encoded sequence to the terminal - * write(pty_fd, buf, written); - * } else if (result == GHOSTTY_OUT_OF_MEMORY) { - * // Buffer too small, written contains required size - * char *dynamic_buf = malloc(written); - * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); - * assert(result == GHOSTTY_SUCCESS); - * write(pty_fd, dynamic_buf, written); - * free(dynamic_buf); - * } - * @endcode - * - * @ingroup key - */ -GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); - -/** @} */ // end of key group - -//------------------------------------------------------------------- -// OSC Parser - -/** @defgroup osc OSC Parser - * - * OSC (Operating System Command) sequence parser and command handling. - * - * The parser operates in a streaming fashion, processing input byte-by-byte - * to handle OSC sequences that may arrive in fragments across multiple reads. - * This interface makes it easy to integrate into most environments and avoids - * over-allocating buffers. - * - * ## Basic Usage - * - * 1. Create a parser instance with ghostty_osc_new() - * 2. Feed bytes to the parser using ghostty_osc_next() - * 3. Finalize parsing with ghostty_osc_end() to get the command - * 4. Query command type and extract data using ghostty_osc_command_type() - * and ghostty_osc_command_data() - * 5. Free the parser with ghostty_osc_free() when done - * - * @{ - */ - -/** - * Create a new OSC parser instance. - * - * Creates a new OSC (Operating System Command) parser using the provided - * allocator. The parser must be freed using ghostty_vt_osc_free() when - * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator - * @param parser Pointer to store the created parser handle - * @return GHOSTTY_SUCCESS on success, or an error code on failure - */ -GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); - -/** - * Free an OSC parser instance. - * - * Releases all resources associated with the OSC parser. After this call, - * the parser handle becomes invalid and must not be used. - * - * @param parser The parser handle to free (may be NULL) - */ -void ghostty_osc_free(GhosttyOscParser parser); - -/** - * Reset an OSC parser instance to its initial state. - * - * Resets the parser state, clearing any partially parsed OSC sequences - * and returning the parser to its initial state. This is useful for - * reusing a parser instance or recovering from parse errors. - * - * @param parser The parser handle to reset, must not be null. - */ -void ghostty_osc_reset(GhosttyOscParser parser); - -/** - * Parse the next byte in an OSC sequence. - * - * Processes a single byte as part of an OSC sequence. The parser maintains - * internal state to track the progress through the sequence. Call this - * function for each byte in the sequence data. - * - * When finished pumping the parser with bytes, call ghostty_osc_end - * to get the final result. - * - * @param parser The parser handle, must not be null. - * @param byte The next byte to parse - */ -void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); - -/** - * Finalize OSC parsing and retrieve the parsed command. - * - * Call this function after feeding all bytes of an OSC sequence to the parser - * using ghostty_osc_next() with the exception of the terminating character - * (ESC or ST). This function finalizes the parsing process and returns the - * parsed OSC command. - * - * The return value is never NULL. Invalid commands will return a command - * with type GHOSTTY_OSC_COMMAND_INVALID. - * - * The terminator parameter specifies the byte that terminated the OSC sequence - * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is - * preserved in the parsed command so that responses can use the same terminator - * format for better compatibility with the calling program. For commands that - * do not require a response, this parameter is ignored and the resulting - * command will not retain the terminator information. - * - * The returned command handle is valid until the next call to any - * `ghostty_osc_*` function with the same parser instance with the exception - * of command introspection functions such as `ghostty_osc_command_type`. - * - * @param parser The parser handle, must not be null. - * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) - * @return Handle to the parsed OSC command - */ -GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); - -/** - * Get the type of an OSC command. - * - * Returns the type identifier for the given OSC command. This can be used - * to determine what kind of command was parsed and what data might be - * available from it. - * - * @param command The OSC command handle to query (may be NULL) - * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL - */ -GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); - -/** - * Extract data from an OSC command. - * - * Extracts typed data from the given OSC command based on the specified - * data type. The output pointer must be of the appropriate type for the - * requested data kind. Valid command types, output types, and memory - * safety information are documented in the `GhosttyOscCommandData` enum. - * - * @param command The OSC command handle to query (may be NULL) - * @param data The type of data to extract - * @param out Pointer to store the extracted data (type depends on data parameter) - * @return true if data extraction was successful, false otherwise - */ -bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); - -/** @} */ // end of osc group +#include +#include +#include +#include #ifdef __cplusplus } diff --git a/include/ghostty/vt/allocator.h b/include/ghostty/vt/allocator.h new file mode 100644 index 000000000..4cebe91bb --- /dev/null +++ b/include/ghostty/vt/allocator.h @@ -0,0 +1,196 @@ +/** + * @file allocator.h + * + * Memory management interface for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_ALLOCATOR_H +#define GHOSTTY_VT_ALLOCATOR_H + +#include +#include +#include + +/** @defgroup allocator Memory Management + * + * libghostty-vt does require memory allocation for various operations, + * but is resilient to allocation failures and will gracefully handle + * out-of-memory situations by returning error codes. + * + * The exact memory management semantics are documented in the relevant + * functions and data structures. + * + * libghostty-vt uses explicit memory allocation via an allocator + * interface provided by GhosttyAllocator. The interface is based on the + * [Zig](https://ziglang.org) allocator interface, since this has been + * shown to be a flexible and powerful interface in practice and enables + * a wide variety of allocation strategies. + * + * **For the common case, you can pass NULL as the allocator for any + * function that accepts one,** and libghostty will use a default allocator. + * The default allocator will be libc malloc/free if libc is linked. + * Otherwise, a custom allocator is used (currently Zig's SMP allocator) + * that doesn't require any external dependencies. + * + * ## Basic Usage + * + * For simple use cases, you can ignore this interface entirely by passing NULL + * as the allocator parameter to functions that accept one. This will use the + * default allocator (typically libc malloc/free, if libc is linked, but + * we provide our own default allocator if libc isn't linked). + * + * To use a custom allocator: + * 1. Implement the GhosttyAllocatorVtable function pointers + * 2. Create a GhosttyAllocator struct with your vtable and context + * 3. Pass the allocator to functions that accept one + * + * @{ + */ + +/** + * Function table for custom memory allocator operations. + * + * This vtable defines the interface for a custom memory allocator. All + * function pointers must be valid and non-NULL. + * + * @ingroup allocator + * + * If you're not going to use a custom allocator, you can ignore all of + * this. All functions that take an allocator pointer allow NULL to use a + * default allocator. + * + * The interface is based on the Zig allocator interface. I'll say up front + * that it is easy to look at this interface and think "wow, this is really + * overcomplicated". The reason for this complexity is well thought out by + * the Zig folks, and it enables a diverse set of allocation strategies + * as shown by the Zig ecosystem. As a consolation, please note that many + * of the arguments are only needed for advanced use cases and can be + * safely ignored in simple implementations. For example, if you look at + * the Zig implementation of the libc allocator in `lib/std/heap.zig` + * (search for CAllocator), you'll see it is very simple. + * + * We chose to align with the Zig allocator interface because: + * + * 1. It is a proven interface that serves a wide variety of use cases + * in the real world via the Zig ecosystem. It's shown to work. + * + * 2. Our core implementation itself is Zig, and this lets us very + * cheaply and easily convert between C and Zig allocators. + * + * NOTE(mitchellh): In the future, we can have default implementations of + * resize/remap and allow those to be null. + */ +typedef struct { + /** + * Return a pointer to `len` bytes with specified `alignment`, or return + * `NULL` indicating the allocation failed. + * + * @param ctx The allocator context + * @param len Number of bytes to allocate + * @param alignment Required alignment for the allocation. Guaranteed to + * be a power of two between 1 and 16 inclusive. + * @param ret_addr First return address of the allocation call stack (0 if not provided) + * @return Pointer to allocated memory, or NULL if allocation failed + */ + void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); + + /** + * Attempt to expand or shrink memory in place. + * + * `memory_len` must equal the length requested from the most recent + * successful call to `alloc`, `resize`, or `remap`. `alignment` must + * equal the same value that was passed as the `alignment` parameter to + * the original `alloc` call. + * + * `new_len` must be greater than zero. + * + * @param ctx The allocator context + * @param memory Pointer to the memory block to resize + * @param memory_len Current size of the memory block + * @param alignment Alignment (must match original allocation) + * @param new_len New requested size + * @param ret_addr First return address of the allocation call stack (0 if not provided) + * @return true if resize was successful in-place, false if relocation would be required + */ + bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); + + /** + * Attempt to expand or shrink memory, allowing relocation. + * + * `memory_len` must equal the length requested from the most recent + * successful call to `alloc`, `resize`, or `remap`. `alignment` must + * equal the same value that was passed as the `alignment` parameter to + * the original `alloc` call. + * + * A non-`NULL` return value indicates the resize was successful. The + * allocation may have same address, or may have been relocated. In either + * case, the allocation now has size of `new_len`. A `NULL` return value + * indicates that the resize would be equivalent to allocating new memory, + * copying the bytes from the old memory, and then freeing the old memory. + * In such case, it is more efficient for the caller to perform the copy. + * + * `new_len` must be greater than zero. + * + * @param ctx The allocator context + * @param memory Pointer to the memory block to remap + * @param memory_len Current size of the memory block + * @param alignment Alignment (must match original allocation) + * @param new_len New requested size + * @param ret_addr First return address of the allocation call stack (0 if not provided) + * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed + */ + void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); + + /** + * Free and invalidate a region of memory. + * + * `memory_len` must equal the length requested from the most recent + * successful call to `alloc`, `resize`, or `remap`. `alignment` must + * equal the same value that was passed as the `alignment` parameter to + * the original `alloc` call. + * + * @param ctx The allocator context + * @param memory Pointer to the memory block to free + * @param memory_len Size of the memory block + * @param alignment Alignment (must match original allocation) + * @param ret_addr First return address of the allocation call stack (0 if not provided) + */ + void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); +} GhosttyAllocatorVtable; + +/** + * Custom memory allocator. + * + * For functions that take an allocator pointer, a NULL pointer indicates + * that the default allocator should be used. The default allocator will + * be libc malloc/free if we're linking to libc. If libc isn't linked, + * a custom allocator is used (currently Zig's SMP allocator). + * + * @ingroup allocator + * + * Usage example: + * @code + * GhosttyAllocator allocator = { + * .vtable = &my_allocator_vtable, + * .ctx = my_allocator_state + * }; + * @endcode + */ +typedef struct GhosttyAllocator { + /** + * Opaque context pointer passed to all vtable functions. + * This allows the allocator implementation to maintain state + * or reference external resources needed for memory management. + */ + void *ctx; + + /** + * Pointer to the allocator's vtable containing function pointers + * for memory operations (alloc, resize, remap, free). + */ + const GhosttyAllocatorVtable *vtable; +} GhosttyAllocator; + +/** @} */ + +#endif /* GHOSTTY_VT_ALLOCATOR_H */ diff --git a/include/ghostty/vt/key.h b/include/ghostty/vt/key.h new file mode 100644 index 000000000..772b5d43b --- /dev/null +++ b/include/ghostty/vt/key.h @@ -0,0 +1,80 @@ +/** + * @file key.h + * + * Key encoding module - encode key events into terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_KEY_H +#define GHOSTTY_VT_KEY_H + +/** @defgroup key Key Encoding + * + * Utilities for encoding key events into terminal escape sequences, + * supporting both legacy encoding as well as Kitty Keyboard Protocol. + * + * ## Basic Usage + * + * 1. Create an encoder instance with ghostty_key_encoder_new() + * 2. Configure encoder options with ghostty_key_encoder_setopt(). + * 3. For each key event: + * - Create a key event with ghostty_key_event_new() + * - Set event properties (action, key, modifiers, etc.) + * - Encode with ghostty_key_encoder_encode() + * - Free the event with ghostty_key_event_free() + * - Note: You can also reuse the same key event multiple times by + * changing its properties. + * 4. Free the encoder with ghostty_key_encoder_free() when done + * + * ## Example + * + * @code{.c} + * #include + * #include + * #include + * + * int main() { + * // Create encoder + * GhosttyKeyEncoder encoder; + * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); + * assert(result == GHOSTTY_SUCCESS); + * + * // Enable Kitty keyboard protocol with all features + * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, + * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); + * + * // Create and configure key event for Ctrl+C press + * GhosttyKeyEvent event; + * result = ghostty_key_event_new(NULL, &event); + * assert(result == GHOSTTY_SUCCESS); + * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); + * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); + * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); + * + * // Encode the key event + * char buf[128]; + * size_t written = 0; + * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * assert(result == GHOSTTY_SUCCESS); + * + * // Use the encoded sequence (e.g., write to terminal) + * fwrite(buf, 1, written, stdout); + * + * // Cleanup + * ghostty_key_event_free(event); + * ghostty_key_encoder_free(encoder); + * return 0; + * } + * @endcode + * + * For a complete working example, see example/c-vt-key-encode in the + * repository. + * + * @{ + */ + +#include +#include + +/** @} */ + +#endif /* GHOSTTY_VT_KEY_H */ diff --git a/include/ghostty/vt/key/encoder.h b/include/ghostty/vt/key/encoder.h new file mode 100644 index 000000000..766a29427 --- /dev/null +++ b/include/ghostty/vt/key/encoder.h @@ -0,0 +1,221 @@ +/** + * @file encoder.h + * + * Key event encoding to terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_KEY_ENCODER_H +#define GHOSTTY_VT_KEY_ENCODER_H + +#include +#include +#include +#include +#include + +/** + * Opaque handle to a key encoder instance. + * + * This handle represents a key encoder that converts key events into terminal + * escape sequences. + * + * @ingroup key + */ +typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; + +/** + * Kitty keyboard protocol flags. + * + * Bitflags representing the various modes of the Kitty keyboard protocol. + * These can be combined using bitwise OR operations. Valid values all + * start with `GHOSTTY_KITTY_KEY_`. + * + * @ingroup key + */ +typedef uint8_t GhosttyKittyKeyFlags; + +/** Kitty keyboard protocol disabled (all flags off) */ +#define GHOSTTY_KITTY_KEY_DISABLED 0 + +/** Disambiguate escape codes */ +#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) + +/** Report key press and release events */ +#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) + +/** Report alternate key codes */ +#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) + +/** Report all key events including those normally handled by the terminal */ +#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) + +/** Report associated text with key events */ +#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) + +/** All Kitty keyboard protocol flags enabled */ +#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) + +/** + * macOS option key behavior. + * + * Determines whether the "option" key on macOS is treated as "alt" or not. + * See the Ghostty `macos-option-as-alt` configuration option for more details. + * + * @ingroup key + */ +typedef enum { + /** Option key is not treated as alt */ + GHOSTTY_OPTION_AS_ALT_FALSE = 0, + /** Option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_TRUE = 1, + /** Only left option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_LEFT = 2, + /** Only right option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_RIGHT = 3, +} GhosttyOptionAsAlt; + +/** + * Key encoder option identifiers. + * + * These values are used with ghostty_key_encoder_setopt() to configure + * the behavior of the key encoder. + * + * @ingroup key + */ +typedef enum { + /** Terminal DEC mode 1: cursor key application mode (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, + + /** Terminal DEC mode 66: keypad key application mode (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, + + /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, + + /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, + + /** xterm modifyOtherKeys mode 2 (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, + + /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ + GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, + + /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ + GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, +} GhosttyKeyEncoderOption; + +/** + * Create a new key encoder instance. + * + * Creates a new key encoder with default options. The encoder can be configured + * using ghostty_key_encoder_setopt() and must be freed using + * ghostty_key_encoder_free() when no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param encoder Pointer to store the created encoder handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup key + */ +GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); + +/** + * Free a key encoder instance. + * + * Releases all resources associated with the key encoder. After this call, + * the encoder handle becomes invalid and must not be used. + * + * @param encoder The encoder handle to free (may be NULL) + * + * @ingroup key + */ +void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); + +/** + * Set an option on the key encoder. + * + * Configures the behavior of the key encoder. Options control various aspects + * of encoding such as terminal modes (cursor key application mode, keypad mode), + * protocol selection (Kitty keyboard protocol flags), and platform-specific + * behaviors (macOS option-as-alt). + * + * A null pointer value does nothing. It does not reset the value to the + * default. The setopt call will do nothing. + * + * @param encoder The encoder handle, must not be NULL + * @param option The option to set + * @param value Pointer to the value to set (type depends on the option) + * + * @ingroup key + */ +void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); + +/** + * Encode a key event into a terminal escape sequence. + * + * Converts a key event into the appropriate terminal escape sequence based on + * the encoder's current options. The sequence is written to the provided buffer. + * + * Not all key events produce output. For example, unmodified modifier keys + * typically don't generate escape sequences. Check the out_len parameter to + * determine if any data was written. + * + * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY + * and out_len will contain the required buffer size. The caller can then + * allocate a larger buffer and call the function again. + * + * @param encoder The encoder handle, must not be NULL + * @param event The key event to encode, must not be NULL + * @param out_buf Buffer to write the encoded sequence to + * @param out_buf_size Size of the output buffer in bytes + * @param out_len Pointer to store the number of bytes written (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code + * + * ## Example: Calculate required buffer size + * + * @code{.c} + * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) + * size_t required = 0; + * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); + * assert(result == GHOSTTY_OUT_OF_MEMORY); + * + * // Allocate buffer of required size + * char *buf = malloc(required); + * + * // Encode with properly sized buffer + * size_t written = 0; + * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); + * assert(result == GHOSTTY_SUCCESS); + * + * // Use the encoded sequence... + * + * free(buf); + * @endcode + * + * ## Example: Direct encoding with static buffer + * + * @code{.c} + * // Most escape sequences are short, so a static buffer often suffices + * char buf[128]; + * size_t written = 0; + * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * if (result == GHOSTTY_SUCCESS) { + * // Write the encoded sequence to the terminal + * write(pty_fd, buf, written); + * } else if (result == GHOSTTY_OUT_OF_MEMORY) { + * // Buffer too small, written contains required size + * char *dynamic_buf = malloc(written); + * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); + * assert(result == GHOSTTY_SUCCESS); + * write(pty_fd, dynamic_buf, written); + * free(dynamic_buf); + * } + * @endcode + * + * @ingroup key + */ +GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); + +#endif /* GHOSTTY_VT_KEY_ENCODER_H */ diff --git a/include/ghostty/vt/key/event.h b/include/ghostty/vt/key/event.h new file mode 100644 index 000000000..dbd2e9f84 --- /dev/null +++ b/include/ghostty/vt/key/event.h @@ -0,0 +1,474 @@ +/** + * @file event.h + * + * Key event representation and manipulation. + */ + +#ifndef GHOSTTY_VT_KEY_EVENT_H +#define GHOSTTY_VT_KEY_EVENT_H + +#include +#include +#include +#include +#include + +/** + * Opaque handle to a key event. + * + * This handle represents a keyboard input event containing information about + * the physical key pressed, modifiers, and generated text. + * + * @ingroup key + */ +typedef struct GhosttyKeyEvent *GhosttyKeyEvent; + +/** + * Keyboard input event types. + * + * @ingroup key + */ +typedef enum { + /** Key was released */ + GHOSTTY_KEY_ACTION_RELEASE = 0, + /** Key was pressed */ + GHOSTTY_KEY_ACTION_PRESS = 1, + /** Key is being repeated (held down) */ + GHOSTTY_KEY_ACTION_REPEAT = 2, +} GhosttyKeyAction; + +/** + * Keyboard modifier keys bitmask. + * + * A bitmask representing all keyboard modifiers. This tracks which modifier keys + * are pressed and, where supported by the platform, which side (left or right) + * of each modifier is active. + * + * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. + * + * Modifier side bits are only meaningful when the corresponding modifier bit is set. + * Not all platforms support distinguishing between left and right modifier + * keys and Ghostty is built to expect that some platforms may not provide this + * information. + * + * @ingroup key + */ +typedef uint16_t GhosttyMods; + +/** Shift key is pressed */ +#define GHOSTTY_MODS_SHIFT (1 << 0) +/** Control key is pressed */ +#define GHOSTTY_MODS_CTRL (1 << 1) +/** Alt/Option key is pressed */ +#define GHOSTTY_MODS_ALT (1 << 2) +/** Super/Command/Windows key is pressed */ +#define GHOSTTY_MODS_SUPER (1 << 3) +/** Caps Lock is active */ +#define GHOSTTY_MODS_CAPS_LOCK (1 << 4) +/** Num Lock is active */ +#define GHOSTTY_MODS_NUM_LOCK (1 << 5) + +/** + * Right shift is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_SHIFT is set. + */ +#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) +/** + * Right ctrl is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_CTRL is set. + */ +#define GHOSTTY_MODS_CTRL_SIDE (1 << 7) +/** + * Right alt is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_ALT is set. + */ +#define GHOSTTY_MODS_ALT_SIDE (1 << 8) +/** + * Right super is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_SUPER is set. + */ +#define GHOSTTY_MODS_SUPER_SIDE (1 << 9) + +/** + * Physical key codes. + * + * The set of key codes that Ghostty is aware of. These represent physical keys + * on the keyboard and are layout-independent. For example, the "a" key on a US + * keyboard is the same as the "ф" key on a Russian keyboard, but both will + * report the same key_a value. + * + * Layout-dependent strings are provided separately as UTF-8 text and are produced + * by the platform. These values are based on the W3C UI Events KeyboardEvent code + * standard. See: https://www.w3.org/TR/uievents-code + * + * @ingroup key + */ +typedef enum { + GHOSTTY_KEY_UNIDENTIFIED = 0, + + // Writing System Keys (W3C § 3.1.1) + GHOSTTY_KEY_BACKQUOTE, + GHOSTTY_KEY_BACKSLASH, + GHOSTTY_KEY_BRACKET_LEFT, + GHOSTTY_KEY_BRACKET_RIGHT, + GHOSTTY_KEY_COMMA, + GHOSTTY_KEY_DIGIT_0, + GHOSTTY_KEY_DIGIT_1, + GHOSTTY_KEY_DIGIT_2, + GHOSTTY_KEY_DIGIT_3, + GHOSTTY_KEY_DIGIT_4, + GHOSTTY_KEY_DIGIT_5, + GHOSTTY_KEY_DIGIT_6, + GHOSTTY_KEY_DIGIT_7, + GHOSTTY_KEY_DIGIT_8, + GHOSTTY_KEY_DIGIT_9, + GHOSTTY_KEY_EQUAL, + GHOSTTY_KEY_INTL_BACKSLASH, + GHOSTTY_KEY_INTL_RO, + GHOSTTY_KEY_INTL_YEN, + GHOSTTY_KEY_A, + GHOSTTY_KEY_B, + GHOSTTY_KEY_C, + GHOSTTY_KEY_D, + GHOSTTY_KEY_E, + GHOSTTY_KEY_F, + GHOSTTY_KEY_G, + GHOSTTY_KEY_H, + GHOSTTY_KEY_I, + GHOSTTY_KEY_J, + GHOSTTY_KEY_K, + GHOSTTY_KEY_L, + GHOSTTY_KEY_M, + GHOSTTY_KEY_N, + GHOSTTY_KEY_O, + GHOSTTY_KEY_P, + GHOSTTY_KEY_Q, + GHOSTTY_KEY_R, + GHOSTTY_KEY_S, + GHOSTTY_KEY_T, + GHOSTTY_KEY_U, + GHOSTTY_KEY_V, + GHOSTTY_KEY_W, + GHOSTTY_KEY_X, + GHOSTTY_KEY_Y, + GHOSTTY_KEY_Z, + GHOSTTY_KEY_MINUS, + GHOSTTY_KEY_PERIOD, + GHOSTTY_KEY_QUOTE, + GHOSTTY_KEY_SEMICOLON, + GHOSTTY_KEY_SLASH, + + // Functional Keys (W3C § 3.1.2) + GHOSTTY_KEY_ALT_LEFT, + GHOSTTY_KEY_ALT_RIGHT, + GHOSTTY_KEY_BACKSPACE, + GHOSTTY_KEY_CAPS_LOCK, + GHOSTTY_KEY_CONTEXT_MENU, + GHOSTTY_KEY_CONTROL_LEFT, + GHOSTTY_KEY_CONTROL_RIGHT, + GHOSTTY_KEY_ENTER, + GHOSTTY_KEY_META_LEFT, + GHOSTTY_KEY_META_RIGHT, + GHOSTTY_KEY_SHIFT_LEFT, + GHOSTTY_KEY_SHIFT_RIGHT, + GHOSTTY_KEY_SPACE, + GHOSTTY_KEY_TAB, + GHOSTTY_KEY_CONVERT, + GHOSTTY_KEY_KANA_MODE, + GHOSTTY_KEY_NON_CONVERT, + + // Control Pad Section (W3C § 3.2) + GHOSTTY_KEY_DELETE, + GHOSTTY_KEY_END, + GHOSTTY_KEY_HELP, + GHOSTTY_KEY_HOME, + GHOSTTY_KEY_INSERT, + GHOSTTY_KEY_PAGE_DOWN, + GHOSTTY_KEY_PAGE_UP, + + // Arrow Pad Section (W3C § 3.3) + GHOSTTY_KEY_ARROW_DOWN, + GHOSTTY_KEY_ARROW_LEFT, + GHOSTTY_KEY_ARROW_RIGHT, + GHOSTTY_KEY_ARROW_UP, + + // Numpad Section (W3C § 3.4) + GHOSTTY_KEY_NUM_LOCK, + GHOSTTY_KEY_NUMPAD_0, + GHOSTTY_KEY_NUMPAD_1, + GHOSTTY_KEY_NUMPAD_2, + GHOSTTY_KEY_NUMPAD_3, + GHOSTTY_KEY_NUMPAD_4, + GHOSTTY_KEY_NUMPAD_5, + GHOSTTY_KEY_NUMPAD_6, + GHOSTTY_KEY_NUMPAD_7, + GHOSTTY_KEY_NUMPAD_8, + GHOSTTY_KEY_NUMPAD_9, + GHOSTTY_KEY_NUMPAD_ADD, + GHOSTTY_KEY_NUMPAD_BACKSPACE, + GHOSTTY_KEY_NUMPAD_CLEAR, + GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, + GHOSTTY_KEY_NUMPAD_COMMA, + GHOSTTY_KEY_NUMPAD_DECIMAL, + GHOSTTY_KEY_NUMPAD_DIVIDE, + GHOSTTY_KEY_NUMPAD_ENTER, + GHOSTTY_KEY_NUMPAD_EQUAL, + GHOSTTY_KEY_NUMPAD_MEMORY_ADD, + GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, + GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, + GHOSTTY_KEY_NUMPAD_MEMORY_STORE, + GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, + GHOSTTY_KEY_NUMPAD_MULTIPLY, + GHOSTTY_KEY_NUMPAD_PAREN_LEFT, + GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, + GHOSTTY_KEY_NUMPAD_SUBTRACT, + GHOSTTY_KEY_NUMPAD_SEPARATOR, + GHOSTTY_KEY_NUMPAD_UP, + GHOSTTY_KEY_NUMPAD_DOWN, + GHOSTTY_KEY_NUMPAD_RIGHT, + GHOSTTY_KEY_NUMPAD_LEFT, + GHOSTTY_KEY_NUMPAD_BEGIN, + GHOSTTY_KEY_NUMPAD_HOME, + GHOSTTY_KEY_NUMPAD_END, + GHOSTTY_KEY_NUMPAD_INSERT, + GHOSTTY_KEY_NUMPAD_DELETE, + GHOSTTY_KEY_NUMPAD_PAGE_UP, + GHOSTTY_KEY_NUMPAD_PAGE_DOWN, + + // Function Section (W3C § 3.5) + GHOSTTY_KEY_ESCAPE, + GHOSTTY_KEY_F1, + GHOSTTY_KEY_F2, + GHOSTTY_KEY_F3, + GHOSTTY_KEY_F4, + GHOSTTY_KEY_F5, + GHOSTTY_KEY_F6, + GHOSTTY_KEY_F7, + GHOSTTY_KEY_F8, + GHOSTTY_KEY_F9, + GHOSTTY_KEY_F10, + GHOSTTY_KEY_F11, + GHOSTTY_KEY_F12, + GHOSTTY_KEY_F13, + GHOSTTY_KEY_F14, + GHOSTTY_KEY_F15, + GHOSTTY_KEY_F16, + GHOSTTY_KEY_F17, + GHOSTTY_KEY_F18, + GHOSTTY_KEY_F19, + GHOSTTY_KEY_F20, + GHOSTTY_KEY_F21, + GHOSTTY_KEY_F22, + GHOSTTY_KEY_F23, + GHOSTTY_KEY_F24, + GHOSTTY_KEY_F25, + GHOSTTY_KEY_FN, + GHOSTTY_KEY_FN_LOCK, + GHOSTTY_KEY_PRINT_SCREEN, + GHOSTTY_KEY_SCROLL_LOCK, + GHOSTTY_KEY_PAUSE, + + // Media Keys (W3C § 3.6) + GHOSTTY_KEY_BROWSER_BACK, + GHOSTTY_KEY_BROWSER_FAVORITES, + GHOSTTY_KEY_BROWSER_FORWARD, + GHOSTTY_KEY_BROWSER_HOME, + GHOSTTY_KEY_BROWSER_REFRESH, + GHOSTTY_KEY_BROWSER_SEARCH, + GHOSTTY_KEY_BROWSER_STOP, + GHOSTTY_KEY_EJECT, + GHOSTTY_KEY_LAUNCH_APP_1, + GHOSTTY_KEY_LAUNCH_APP_2, + GHOSTTY_KEY_LAUNCH_MAIL, + GHOSTTY_KEY_MEDIA_PLAY_PAUSE, + GHOSTTY_KEY_MEDIA_SELECT, + GHOSTTY_KEY_MEDIA_STOP, + GHOSTTY_KEY_MEDIA_TRACK_NEXT, + GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, + GHOSTTY_KEY_POWER, + GHOSTTY_KEY_SLEEP, + GHOSTTY_KEY_AUDIO_VOLUME_DOWN, + GHOSTTY_KEY_AUDIO_VOLUME_MUTE, + GHOSTTY_KEY_AUDIO_VOLUME_UP, + GHOSTTY_KEY_WAKE_UP, + + // Legacy, Non-standard, and Special Keys (W3C § 3.7) + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, +} GhosttyKey; + +/** + * Create a new key event instance. + * + * Creates a new key event with default values. The event must be freed using + * ghostty_key_event_free() when no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param event Pointer to store the created key event handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup key + */ +GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); + +/** + * Free a key event instance. + * + * Releases all resources associated with the key event. After this call, + * the event handle becomes invalid and must not be used. + * + * @param event The key event handle to free (may be NULL) + * + * @ingroup key + */ +void ghostty_key_event_free(GhosttyKeyEvent event); + +/** + * Set the key action (press, release, repeat). + * + * @param event The key event handle, must not be NULL + * @param action The action to set + * + * @ingroup key + */ +void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); + +/** + * Get the key action (press, release, repeat). + * + * @param event The key event handle, must not be NULL + * @return The key action + * + * @ingroup key + */ +GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); + +/** + * Set the physical key code. + * + * @param event The key event handle, must not be NULL + * @param key The physical key code to set + * + * @ingroup key + */ +void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); + +/** + * Get the physical key code. + * + * @param event The key event handle, must not be NULL + * @return The physical key code + * + * @ingroup key + */ +GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); + +/** + * Set the modifier keys bitmask. + * + * @param event The key event handle, must not be NULL + * @param mods The modifier keys bitmask to set + * + * @ingroup key + */ +void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); + +/** + * Get the modifier keys bitmask. + * + * @param event The key event handle, must not be NULL + * @return The modifier keys bitmask + * + * @ingroup key + */ +GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); + +/** + * Set the consumed modifiers bitmask. + * + * @param event The key event handle, must not be NULL + * @param consumed_mods The consumed modifiers bitmask to set + * + * @ingroup key + */ +void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); + +/** + * Get the consumed modifiers bitmask. + * + * @param event The key event handle, must not be NULL + * @return The consumed modifiers bitmask + * + * @ingroup key + */ +GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); + +/** + * Set whether the key event is part of a composition sequence. + * + * @param event The key event handle, must not be NULL + * @param composing Whether the key event is part of a composition sequence + * + * @ingroup key + */ +void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); + +/** + * Get whether the key event is part of a composition sequence. + * + * @param event The key event handle, must not be NULL + * @return Whether the key event is part of a composition sequence + * + * @ingroup key + */ +bool ghostty_key_event_get_composing(GhosttyKeyEvent event); + +/** + * Set the UTF-8 text generated by the key event. + * + * The key event does NOT take ownership of the text pointer. The caller + * must ensure the string remains valid for the lifetime needed by the event. + * + * @param event The key event handle, must not be NULL + * @param utf8 The UTF-8 text to set (or NULL for empty) + * @param len Length of the UTF-8 text in bytes + * + * @ingroup key + */ +void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); + +/** + * Get the UTF-8 text generated by the key event. + * + * The returned pointer is valid until the event is freed or the UTF-8 text is modified. + * + * @param event The key event handle, must not be NULL + * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) + * @return The UTF-8 text (or NULL for empty) + * + * @ingroup key + */ +const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); + +/** + * Set the unshifted Unicode codepoint. + * + * @param event The key event handle, must not be NULL + * @param codepoint The unshifted Unicode codepoint to set + * + * @ingroup key + */ +void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); + +/** + * Get the unshifted Unicode codepoint. + * + * @param event The key event handle, must not be NULL + * @return The unshifted Unicode codepoint + * + * @ingroup key + */ +uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); + +#endif /* GHOSTTY_VT_KEY_EVENT_H */ diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h new file mode 100644 index 000000000..7e2c8f322 --- /dev/null +++ b/include/ghostty/vt/osc.h @@ -0,0 +1,231 @@ +/** + * @file osc.h + * + * OSC (Operating System Command) sequence parser and command handling. + */ + +#ifndef GHOSTTY_VT_OSC_H +#define GHOSTTY_VT_OSC_H + +#include +#include +#include +#include +#include + +/** + * Opaque handle to an OSC parser instance. + * + * This handle represents an OSC (Operating System Command) parser that can + * be used to parse the contents of OSC sequences. + * + * @ingroup osc + */ +typedef struct GhosttyOscParser *GhosttyOscParser; + +/** + * Opaque handle to a single OSC command. + * + * This handle represents a parsed OSC (Operating System Command) command. + * The command can be queried for its type and associated data. + * + * @ingroup osc + */ +typedef struct GhosttyOscCommand *GhosttyOscCommand; + +/** @defgroup osc OSC Parser + * + * OSC (Operating System Command) sequence parser and command handling. + * + * The parser operates in a streaming fashion, processing input byte-by-byte + * to handle OSC sequences that may arrive in fragments across multiple reads. + * This interface makes it easy to integrate into most environments and avoids + * over-allocating buffers. + * + * ## Basic Usage + * + * 1. Create a parser instance with ghostty_osc_new() + * 2. Feed bytes to the parser using ghostty_osc_next() + * 3. Finalize parsing with ghostty_osc_end() to get the command + * 4. Query command type and extract data using ghostty_osc_command_type() + * and ghostty_osc_command_data() + * 5. Free the parser with ghostty_osc_free() when done + * + * @{ + */ + +/** + * OSC command types. + * + * @ingroup osc + */ +typedef enum { + GHOSTTY_OSC_COMMAND_INVALID = 0, + GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, + GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, + GHOSTTY_OSC_COMMAND_PROMPT_START = 3, + GHOSTTY_OSC_COMMAND_PROMPT_END = 4, + GHOSTTY_OSC_COMMAND_END_OF_INPUT = 5, + GHOSTTY_OSC_COMMAND_END_OF_COMMAND = 6, + GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 7, + GHOSTTY_OSC_COMMAND_REPORT_PWD = 8, + GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 9, + GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 10, + GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 11, + GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 12, + GHOSTTY_OSC_COMMAND_HYPERLINK_START = 13, + GHOSTTY_OSC_COMMAND_HYPERLINK_END = 14, + GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 15, + GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 16, + GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 17, + GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 18, + GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 19, + GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20, +} GhosttyOscCommandType; + +/** + * OSC command data types. + * + * These values specify what type of data to extract from an OSC command + * using `ghostty_osc_command_data`. + * + * @ingroup osc + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_OSC_DATA_INVALID = 0, + + /** + * Window title string data. + * + * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE + * + * Output type: const char ** (pointer to null-terminated string) + * + * Lifetime: Valid until the next call to any ghostty_osc_* function with + * the same parser instance. Memory is owned by the parser. + */ + GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, +} GhosttyOscCommandData; + +/** + * Create a new OSC parser instance. + * + * Creates a new OSC (Operating System Command) parser using the provided + * allocator. The parser must be freed using ghostty_vt_osc_free() when + * no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param parser Pointer to store the created parser handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup osc + */ +GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); + +/** + * Free an OSC parser instance. + * + * Releases all resources associated with the OSC parser. After this call, + * the parser handle becomes invalid and must not be used. + * + * @param parser The parser handle to free (may be NULL) + * + * @ingroup osc + */ +void ghostty_osc_free(GhosttyOscParser parser); + +/** + * Reset an OSC parser instance to its initial state. + * + * Resets the parser state, clearing any partially parsed OSC sequences + * and returning the parser to its initial state. This is useful for + * reusing a parser instance or recovering from parse errors. + * + * @param parser The parser handle to reset, must not be null. + * + * @ingroup osc + */ +void ghostty_osc_reset(GhosttyOscParser parser); + +/** + * Parse the next byte in an OSC sequence. + * + * Processes a single byte as part of an OSC sequence. The parser maintains + * internal state to track the progress through the sequence. Call this + * function for each byte in the sequence data. + * + * When finished pumping the parser with bytes, call ghostty_osc_end + * to get the final result. + * + * @param parser The parser handle, must not be null. + * @param byte The next byte to parse + * + * @ingroup osc + */ +void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); + +/** + * Finalize OSC parsing and retrieve the parsed command. + * + * Call this function after feeding all bytes of an OSC sequence to the parser + * using ghostty_osc_next() with the exception of the terminating character + * (ESC or ST). This function finalizes the parsing process and returns the + * parsed OSC command. + * + * The return value is never NULL. Invalid commands will return a command + * with type GHOSTTY_OSC_COMMAND_INVALID. + * + * The terminator parameter specifies the byte that terminated the OSC sequence + * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is + * preserved in the parsed command so that responses can use the same terminator + * format for better compatibility with the calling program. For commands that + * do not require a response, this parameter is ignored and the resulting + * command will not retain the terminator information. + * + * The returned command handle is valid until the next call to any + * `ghostty_osc_*` function with the same parser instance with the exception + * of command introspection functions such as `ghostty_osc_command_type`. + * + * @param parser The parser handle, must not be null. + * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) + * @return Handle to the parsed OSC command + * + * @ingroup osc + */ +GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); + +/** + * Get the type of an OSC command. + * + * Returns the type identifier for the given OSC command. This can be used + * to determine what kind of command was parsed and what data might be + * available from it. + * + * @param command The OSC command handle to query (may be NULL) + * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL + * + * @ingroup osc + */ +GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); + +/** + * Extract data from an OSC command. + * + * Extracts typed data from the given OSC command based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid command types, output types, and memory + * safety information are documented in the `GhosttyOscCommandData` enum. + * + * @param command The OSC command handle to query (may be NULL) + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return true if data extraction was successful, false otherwise + * + * @ingroup osc + */ +bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); + +/** @} */ + +#endif /* GHOSTTY_VT_OSC_H */ diff --git a/include/ghostty/vt/result.h b/include/ghostty/vt/result.h new file mode 100644 index 000000000..cc382eade --- /dev/null +++ b/include/ghostty/vt/result.h @@ -0,0 +1,20 @@ +/** + * @file result.h + * + * Result codes for libghostty-vt operations. + */ + +#ifndef GHOSTTY_VT_RESULT_H +#define GHOSTTY_VT_RESULT_H + +/** + * Result codes for libghostty-vt operations. + */ +typedef enum { + /** Operation completed successfully */ + GHOSTTY_SUCCESS = 0, + /** Operation failed due to failed allocation */ + GHOSTTY_OUT_OF_MEMORY = -1, +} GhosttyResult; + +#endif /* GHOSTTY_VT_RESULT_H */ diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 1e57da7b1..590792ef3 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -30,9 +30,10 @@ pub fn initShared( .root_module = zig.vt_c, .version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 }, }); - lib.installHeader( - b.path("include/ghostty/vt.h"), - "ghostty/vt.h", + lib.installHeadersDirectory( + b.path("include/ghostty"), + "ghostty", + .{ .include_extensions = &.{".h"} }, ); // Get our debug symbols From bf9f025aec78aedcb9431d503fd6c4b14f579fbb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Oct 2025 21:04:18 -0700 Subject: [PATCH 038/702] lib-vt: begin paste utilities exports starting with safe paste --- .github/workflows/test.yml | 2 +- example/c-vt-paste/README.md | 17 ++++++++ example/c-vt-paste/build.zig | 42 ++++++++++++++++++ example/c-vt-paste/build.zig.zon | 24 ++++++++++ example/c-vt-paste/src/main.c | 31 +++++++++++++ include/ghostty/vt.h | 8 ++++ include/ghostty/vt/paste.h | 75 ++++++++++++++++++++++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 4 ++ src/terminal/c/paste.zig | 36 +++++++++++++++ 10 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 example/c-vt-paste/README.md create mode 100644 example/c-vt-paste/build.zig create mode 100644 example/c-vt-paste/build.zig.zon create mode 100644 example/c-vt-paste/src/main.c create mode 100644 include/ghostty/vt/paste.h create mode 100644 src/terminal/c/paste.zig diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59556f58e..f78855290 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -94,7 +94,7 @@ jobs: strategy: fail-fast: false matrix: - dir: [c-vt, zig-vt] + dir: [c-vt, c-vt-key-encode, c-vt-paste, zig-vt] name: Example ${{ matrix.dir }} runs-on: namespace-profile-ghostty-sm needs: test diff --git a/example/c-vt-paste/README.md b/example/c-vt-paste/README.md new file mode 100644 index 000000000..0f911771f --- /dev/null +++ b/example/c-vt-paste/README.md @@ -0,0 +1,17 @@ +# Example: `ghostty-vt` Paste Safety Check + +This contains a simple example of how to use the `ghostty-vt` paste +utilities to check if paste data is safe. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-paste/build.zig b/example/c-vt-paste/build.zig new file mode 100644 index 000000000..99b7ba771 --- /dev/null +++ b/example/c-vt-paste/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_paste", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-paste/build.zig.zon b/example/c-vt-paste/build.zig.zon new file mode 100644 index 000000000..fb78db9bc --- /dev/null +++ b/example/c-vt-paste/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_paste, + .version = "0.0.0", + .fingerprint = 0xa105002abbc8cf74, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-paste/src/main.c b/example/c-vt-paste/src/main.c new file mode 100644 index 000000000..153861ca9 --- /dev/null +++ b/example/c-vt-paste/src/main.c @@ -0,0 +1,31 @@ +#include +#include +#include + +int main() { + // Test safe paste data + const char *safe_data = "hello world"; + if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { + printf("'%s' is safe to paste\n", safe_data); + } + + // Test unsafe paste data with newline + const char *unsafe_newline = "rm -rf /\n"; + if (!ghostty_paste_is_safe(unsafe_newline, strlen(unsafe_newline))) { + printf("'%s' is UNSAFE - contains newline\n", unsafe_newline); + } + + // Test unsafe paste data with bracketed paste end sequence + const char *unsafe_escape = "evil\x1b[201~code"; + if (!ghostty_paste_is_safe(unsafe_escape, strlen(unsafe_escape))) { + printf("Data with escape sequence is UNSAFE\n"); + } + + // Test empty data + const char *empty_data = ""; + if (ghostty_paste_is_safe(empty_data, 0)) { + printf("Empty data is safe\n"); + } + + return 0; +} diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 489996530..cd357f0fa 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -30,6 +30,7 @@ * The API is organized into the following groups: * - @ref key "Key Encoding" - Encode key events into terminal sequences * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences + * - @ref paste "Paste Utilities" - Validate paste data safety * - @ref allocator "Memory Management" - Memory management and custom allocators * * @section examples_sec Examples @@ -37,6 +38,7 @@ * Complete working examples: * - @ref c-vt/src/main.c - OSC parser example * - @ref c-vt-key-encode/src/main.c - Key encoding example + * - @ref c-vt-paste/src/main.c - Paste safety check example * */ @@ -50,6 +52,11 @@ * into terminal escape sequences using the Kitty keyboard protocol. */ +/** @example c-vt-paste/src/main.c + * This example demonstrates how to use the paste utilities to check if + * paste data is safe before sending it to the terminal. + */ + #ifndef GHOSTTY_VT_H #define GHOSTTY_VT_H @@ -61,6 +68,7 @@ extern "C" { #include #include #include +#include #ifdef __cplusplus } diff --git a/include/ghostty/vt/paste.h b/include/ghostty/vt/paste.h new file mode 100644 index 000000000..d90f303d4 --- /dev/null +++ b/include/ghostty/vt/paste.h @@ -0,0 +1,75 @@ +/** + * @file paste.h + * + * Paste utilities - validate and encode paste data for terminal input. + */ + +#ifndef GHOSTTY_VT_PASTE_H +#define GHOSTTY_VT_PASTE_H + +/** @defgroup paste Paste Utilities + * + * Utilities for validating paste data safety. + * + * ## Basic Usage + * + * Use ghostty_paste_is_safe() to check if paste data contains potentially + * dangerous sequences before sending it to the terminal. + * + * ## Example + * + * @code{.c} + * #include + * #include + * #include + * + * int main() { + * const char* safe_data = "hello world"; + * const char* unsafe_data = "rm -rf /\n"; + * + * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { + * printf("Safe to paste\n"); + * } + * + * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { + * printf("Unsafe! Contains newline\n"); + * } + * + * return 0; + * } + * @endcode + * + * @{ + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Check if paste data is safe to paste into the terminal. + * + * Data is considered unsafe if it contains: + * - Newlines (`\n`) which can inject commands + * - The bracketed paste end sequence (`\x1b[201~`) which can be used + * to exit bracketed paste mode and inject commands + * + * This check is conservative and considers data unsafe regardless of + * current terminal state. + * + * @param data The paste data to check (must not be NULL) + * @param len The length of the data in bytes + * @return true if the data is safe to paste, false otherwise + */ +bool ghostty_paste_is_safe(const char* data, size_t len); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_PASTE_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 73a030333..1df8330ea 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -122,6 +122,7 @@ comptime { @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); + @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); } } diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 500dbf56c..f68333d9b 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -1,6 +1,7 @@ pub const osc = @import("osc.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); +pub const paste = @import("paste.zig"); // The full C API, unexported. pub const osc_new = osc.new; @@ -33,10 +34,13 @@ pub const key_encoder_free = key_encode.free; pub const key_encoder_setopt = key_encode.setopt; pub const key_encoder_encode = key_encode.encode; +pub const paste_is_safe = paste.is_safe; + test { _ = osc; _ = key_event; _ = key_encode; + _ = paste; // We want to make sure we run the tests for the C allocator interface. _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/paste.zig b/src/terminal/c/paste.zig new file mode 100644 index 000000000..eb4117a70 --- /dev/null +++ b/src/terminal/c/paste.zig @@ -0,0 +1,36 @@ +const std = @import("std"); +const paste = @import("../../input/paste.zig"); + +pub fn is_safe(data: ?[*]const u8, len: usize) callconv(.c) bool { + const slice: []const u8 = if (data) |v| v[0..len] else &.{}; + return paste.isSafe(slice); +} + +test "is_safe with safe data" { + const testing = std.testing; + const safe = "hello world"; + try testing.expect(is_safe(safe.ptr, safe.len)); +} + +test "is_safe with newline" { + const testing = std.testing; + const unsafe = "hello\nworld"; + try testing.expect(!is_safe(unsafe.ptr, unsafe.len)); +} + +test "is_safe with bracketed paste end" { + const testing = std.testing; + const unsafe = "hello\x1b[201~world"; + try testing.expect(!is_safe(unsafe.ptr, unsafe.len)); +} + +test "is_safe with empty data" { + const testing = std.testing; + const empty = ""; + try testing.expect(is_safe(empty.ptr, 0)); +} + +test "is_safe with null empty data" { + const testing = std.testing; + try testing.expect(is_safe(null, 0)); +} From e70ae28fa34523bd3a14c634da08d21a0975ec1f Mon Sep 17 00:00:00 2001 From: Ravi Chandra Date: Tue, 7 Oct 2025 18:14:10 +1300 Subject: [PATCH 039/702] terminal: add semi-colon character to word boundary list --- src/terminal/Screen.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index a98407af7..228b87922 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2565,6 +2565,7 @@ pub fn selectWord(self: *Screen, pin: Pin) ?Selection { '`', '|', ':', + ';', ',', '(', ')', @@ -7819,6 +7820,7 @@ test "Screen: selectWord with character boundary" { " `abc` \n123", " |abc| \n123", " :abc: \n123", + " ;abc; \n123", " ,abc, \n123", " (abc( \n123", " )abc) \n123", From b56808f138876281f2fe09b83eb71b5c67258058 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 00:07:23 +0000 Subject: [PATCH 040/702] build(deps): bump softprops/action-gh-release from 2.3.4 to 2.4.0 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.3.4 to 2.4.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/62c96d0c4e8a889135c1f3a25910db8dbe0e85f7...aec2ec56f94eb8180ceec724245f64ef008b89f5) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 2.4.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 eca678244..d3ea88def 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -188,7 +188,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@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 + uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -359,7 +359,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 + uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -590,7 +590,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 + uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -775,7 +775,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4 + uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 with: name: 'Ghostty Tip ("Nightly")' prerelease: true From 09ba5a27a234bfe5c8cad1c51da7b5028f239f1b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 08:43:39 -0700 Subject: [PATCH 041/702] macOS: Unobtrusive update views --- macos/Sources/App/macOS/AppDelegate.swift | 30 +- .../Window Styles/TerminalWindow.swift | 140 ++++++- .../Sources/Features/Update/UpdateBadge.swift | 65 ++++ .../Sources/Features/Update/UpdatePill.swift | 51 +++ .../Features/Update/UpdatePopoverView.swift | 362 ++++++++++++++++++ .../Features/Update/UpdateViewModel.swift | 178 +++++++++ 6 files changed, 814 insertions(+), 12 deletions(-) create mode 100644 macos/Sources/Features/Update/UpdateBadge.swift create mode 100644 macos/Sources/Features/Update/UpdatePill.swift create mode 100644 macos/Sources/Features/Update/UpdatePopoverView.swift create mode 100644 macos/Sources/Features/Update/UpdateViewModel.swift diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 942aecdd4..a893c3877 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI import UserNotifications import OSLog import Sparkle @@ -1004,7 +1005,34 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - updaterController.checkForUpdates(sender) + // Demo mode: simulate update check instead of real Sparkle check + // TODO: Replace with real updaterController.checkForUpdates(sender) when SPUUserDriver is implemented + + guard let terminalWindow = NSApp.keyWindow as? TerminalWindow else { + // Fallback to real update check if no terminal window + updaterController.checkForUpdates(sender) + return + } + + let model = terminalWindow.updateUIModel + + // Simulate the full update check flow + model.state = .checking + model.progress = nil + model.details = nil + model.error = nil + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + // Simulate finding an update + model.state = .updateAvailable + model.details = .init( + version: "1.2.0", + build: "demo", + size: "42 MB", + date: Date(), + notesSummary: "This is a demo of the update UI. New features and bug fixes would be listed here." + ) + } } @IBAction func newWindow(_ sender: Any?) { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 3ab6293dc..248577f4f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -14,6 +14,10 @@ class TerminalWindow: NSWindow { /// Reset split zoom button in titlebar private let resetZoomAccessory = NSTitlebarAccessoryViewController() + + /// Update notification UI in titlebar + private let updateAccessory = NSTitlebarAccessoryViewController() + private(set) var updateUIModel = UpdateViewModel() /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() @@ -85,6 +89,16 @@ class TerminalWindow: NSWindow { })) addTitlebarAccessoryViewController(resetZoomAccessory) resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false + + // Create update notification accessory + updateAccessory.layoutAttribute = .right + updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( + viewModel: viewModel, + model: updateUIModel, + actions: createUpdateActions() + )) + addTitlebarAccessoryViewController(updateAccessory) + updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false } // Setup the accessory view for tabs that shows our keyboard shortcuts, @@ -198,6 +212,9 @@ class TerminalWindow: NSWindow { if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) { removeTitlebarAccessoryViewController(at: idx) } + + // We don't need to do this with the update accessory. I don't know why but + // everything works fine. } private func tabBarDidDisappear() { @@ -436,6 +453,94 @@ class TerminalWindow: NSWindow { standardWindowButton(.miniaturizeButton)?.isHidden = true standardWindowButton(.zoomButton)?.isHidden = true } + + // MARK: Update UI + + private func createUpdateActions() -> UpdateUIActions { + UpdateUIActions( + allowAutoChecks: { [weak self] in + print("Demo: Allow auto checks") + self?.updateUIModel.state = .idle + }, + denyAutoChecks: { [weak self] in + print("Demo: Deny auto checks") + self?.updateUIModel.state = .idle + }, + cancel: { [weak self] in + print("Demo: Cancel") + self?.updateUIModel.state = .idle + }, + install: { [weak self] in + guard let self else { return } + print("Demo: Install - simulating download and install flow") + + // Start downloading + self.updateUIModel.state = .downloading + self.updateUIModel.progress = 0.0 + + // Simulate download progress + for i in 1...10 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { + self.updateUIModel.progress = Double(i) / 10.0 + + if i == 10 { + // Move to extraction + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.updateUIModel.state = .extracting + self.updateUIModel.progress = 0.0 + + // Simulate extraction progress + for j in 1...5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { + self.updateUIModel.progress = Double(j) / 5.0 + + if j == 5 { + // Move to ready to install + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.updateUIModel.state = .readyToInstall + self.updateUIModel.progress = nil + } + } + } + } + } + } + } + } + }, + remindLater: { [weak self] in + print("Demo: Remind later") + self?.updateUIModel.state = .idle + }, + skipThisVersion: { [weak self] in + print("Demo: Skip version") + self?.updateUIModel.state = .idle + }, + showReleaseNotes: { [weak self] in + print("Demo: Show release notes") + guard let url = URL(string: "https://github.com/ghostty-org/ghostty/releases") else { return } + NSWorkspace.shared.open(url) + }, + retry: { [weak self] in + guard let self else { return } + print("Demo: Retry - simulating update check") + self.updateUIModel.state = .checking + self.updateUIModel.progress = nil + self.updateUIModel.error = nil + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.updateUIModel.state = .updateAvailable + self.updateUIModel.details = .init( + version: "1.2.0", + build: "demo", + size: "42 MB", + date: Date(), + notesSummary: "This is a demo of the update UI." + ) + } + } + ) + } // MARK: Config @@ -467,21 +572,20 @@ extension TerminalWindow { class ViewModel: ObservableObject { @Published var isSurfaceZoomed: Bool = false @Published var hasToolbar: Bool = false + + /// Calculates the top padding based on toolbar visibility and macOS version + fileprivate var accessoryTopPadding: CGFloat { + if #available(macOS 26.0, *) { + return hasToolbar ? 10 : 5 + } else { + return hasToolbar ? 9 : 4 + } + } } struct ResetZoomAccessoryView: View { @ObservedObject var viewModel: ViewModel let action: () -> Void - - // The padding from the top that the view appears. This was all just manually - // measured based on the OS. - var topPadding: CGFloat { - if #available(macOS 26.0, *) { - return viewModel.hasToolbar ? 10 : 5 - } else { - return viewModel.hasToolbar ? 9 : 4 - } - } var body: some View { if viewModel.isSurfaceZoomed { @@ -497,10 +601,24 @@ extension TerminalWindow { } // With a toolbar, the window title is taller, so we need more padding // to properly align. - .padding(.top, topPadding) + .padding(.top, viewModel.accessoryTopPadding) // We always need space at the end of the titlebar .padding(.trailing, 10) } } } + + /// A pill-shaped button that displays update status and provides access to update actions. + struct UpdateAccessoryView: View { + @ObservedObject var viewModel: ViewModel + @ObservedObject var model: UpdateViewModel + let actions: UpdateUIActions + + var body: some View { + UpdatePill(model: model, actions: actions) + .padding(.top, viewModel.accessoryTopPadding) + .padding(.trailing, 10) + } + } + } diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift new file mode 100644 index 000000000..a6ffe6cb6 --- /dev/null +++ b/macos/Sources/Features/Update/UpdateBadge.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// A badge view that displays the current state of an update operation. +/// +/// Shows different visual indicators based on the update state: +/// - Progress ring for downloading/extracting with progress +/// - Animated rotating icon for checking/installing +/// - Static icon for other states +struct UpdateBadge: View { + /// The update view model that provides the current state and progress + @ObservedObject var model: UpdateViewModel + + /// Current rotation angle for animated icon states + @State private var rotationAngle: Double = 0 + + var body: some View { + switch model.state { + case .downloading, .extracting: + if let progress = model.progress { + ProgressRingView(progress: progress) + } else { + Image(systemName: "arrow.down.circle") + } + + case .checking, .installing: + Image(systemName: model.iconName) + .rotationEffect(.degrees(rotationAngle)) + .onAppear { + withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) { + rotationAngle = 360 + } + } + .onDisappear { + rotationAngle = 0 + } + + default: + Image(systemName: model.iconName) + } + } +} + +/// A circular progress indicator with a stroke-based ring design. +/// +/// Displays a partially filled circle that represents progress from 0.0 to 1.0. +fileprivate struct ProgressRingView: View { + /// The current progress value, ranging from 0.0 (empty) to 1.0 (complete) + let progress: Double + + /// The width of the progress ring stroke + let lineWidth: CGFloat = 2 + + var body: some View { + ZStack { + Circle() + .stroke(Color.primary.opacity(0.2), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: progress) + .stroke(Color.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress) + } + } +} diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift new file mode 100644 index 000000000..604be0fbc --- /dev/null +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// A pill-shaped button that displays update status and provides access to update actions. +struct UpdatePill: View { + /// The update view model that provides the current state and information + @ObservedObject var model: UpdateViewModel + + /// The actions that can be performed on updates + let actions: UpdateUIActions + + /// Whether the update popover is currently visible + @State private var showPopover = false + + var body: some View { + if model.state != .idle { + VStack { + pillButton + Spacer() + } + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + UpdatePopoverView(model: model, actions: actions) + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } + } + + /// The pill-shaped button view that displays the update badge and text + @ViewBuilder + private var pillButton: some View { + Button(action: { showPopover.toggle() }) { + HStack(spacing: 6) { + UpdateBadge(model: model) + .frame(width: 14, height: 14) + + Text(model.text) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(model.backgroundColor) + ) + .foregroundColor(model.foregroundColor) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .help(model.stateTooltip) + } +} diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift new file mode 100644 index 000000000..af870b4de --- /dev/null +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -0,0 +1,362 @@ +import SwiftUI + +/// A popover view that displays detailed update information and action buttons. +/// +/// The view adapts its content based on the current update state, showing appropriate +/// UI for checking, downloading, installing, or handling errors. +struct UpdatePopoverView: View { + /// The update view model that provides the current state and information + @ObservedObject var model: UpdateViewModel + + /// The actions that can be performed on updates + let actions: UpdateUIActions + + /// Environment value for dismissing the popover + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + switch model.state { + case .idle: + EmptyView() + + case .permissionRequest: + permissionRequestView + + case .checking: + checkingView + + case .updateAvailable: + updateAvailableView + + case .downloading: + downloadingView + + case .extracting: + extractingView + + case .readyToInstall: + readyToInstallView + + case .installing: + installingView + + case .notFound: + notFoundView + + case .error: + errorView + } + } + .frame(width: 300) + } + + /// View shown when requesting permission to enable automatic updates + private var permissionRequestView: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Enable automatic updates?") + .font(.system(size: 13, weight: .semibold)) + + Text("Ghostty can automatically check for and download updates in the background.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 8) { + Button("Not Now") { + actions.denyAutoChecks() + dismiss() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Allow") { + actions.allowAutoChecks() + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + } + .padding(16) + } + + /// View shown while checking for updates + private var checkingView: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Checking for updates…") + .font(.system(size: 13)) + } + + HStack { + Spacer() + Button("Cancel") { + actions.cancel() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + } + } + .padding(16) + } + + /// View shown when an update is available, displaying version and size information + private var updateAvailableView: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text("Update Available") + .font(.system(size: 13, weight: .semibold)) + + if let details = model.details { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("Version:") + .foregroundColor(.secondary) + .frame(width: 50, alignment: .trailing) + Text(details.version) + } + .font(.system(size: 11)) + + if let size = details.size { + HStack(spacing: 6) { + Text("Size:") + .foregroundColor(.secondary) + .frame(width: 50, alignment: .trailing) + Text(size) + } + .font(.system(size: 11)) + } + } + } + } + + HStack(spacing: 8) { + Button("Skip") { + actions.skipThisVersion() + dismiss() + } + .controlSize(.small) + + Button("Later") { + actions.remindLater() + dismiss() + } + .controlSize(.small) + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Install") { + actions.install() + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + + if model.details?.notesSummary != nil { + Divider() + + Button(action: actions.showReleaseNotes) { + HStack { + Text("View Release Notes") + .font(.system(size: 11)) + Spacer() + Image(systemName: "arrow.up.right.square") + .font(.system(size: 11)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color(nsColor: .controlBackgroundColor)) + } + } + } + + /// View shown while downloading an update, with progress indicator + private var downloadingView: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Downloading Update") + .font(.system(size: 13, weight: .semibold)) + + if let progress = model.progress { + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: progress) + Text(String(format: "%.0f%%", progress * 100)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } else { + ProgressView() + .controlSize(.small) + } + } + + HStack { + Spacer() + Button("Cancel") { + actions.cancel() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + } + } + .padding(16) + } + + /// View shown while extracting/preparing the downloaded update + private var extractingView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Preparing Update") + .font(.system(size: 13, weight: .semibold)) + + if let progress = model.progress { + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: progress) + Text(String(format: "%.0f%%", progress * 100)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } else { + ProgressView() + .controlSize(.small) + } + } + .padding(16) + } + + /// View shown when an update is ready to be installed + private var readyToInstallView: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Ready to Install") + .font(.system(size: 13, weight: .semibold)) + + if let details = model.details { + Text("Version \(details.version) is ready to install.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + + HStack(spacing: 8) { + Button("Later") { + actions.remindLater() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + + Spacer() + + Button("Install and Relaunch") { + actions.install() + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + } + + /// View shown during the installation process + private var installingView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Installing…") + .font(.system(size: 13, weight: .semibold)) + } + + Text("The application will relaunch shortly.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .padding(16) + } + + /// View shown when no updates are found (already on latest version) + private var notFoundView: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("No Updates Found") + .font(.system(size: 13, weight: .semibold)) + + Text("You're already running the latest version.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button("OK") { + actions.remindLater() + dismiss() + } + .keyboardShortcut(.defaultAction) + .controlSize(.small) + } + } + .padding(16) + } + + /// View shown when an error occurs during the update process + private var errorView: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 13)) + Text(model.error?.title ?? "Update Failed") + .font(.system(size: 13, weight: .semibold)) + } + + if let message = model.error?.message { + Text(message) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + HStack(spacing: 8) { + Button("OK") { + actions.remindLater() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + + Spacer() + + Button("Retry") { + actions.retry() + dismiss() + } + .keyboardShortcut(.defaultAction) + .controlSize(.small) + } + } + .padding(16) + } +} diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift new file mode 100644 index 000000000..fb477324c --- /dev/null +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -0,0 +1,178 @@ +import Foundation +import SwiftUI + +struct UpdateUIActions { + let allowAutoChecks: () -> Void + let denyAutoChecks: () -> Void + let cancel: () -> Void + let install: () -> Void + let remindLater: () -> Void + let skipThisVersion: () -> Void + let showReleaseNotes: () -> Void + let retry: () -> Void +} + +class UpdateViewModel: ObservableObject { + @Published var state: State = .idle + @Published var progress: Double? = nil + @Published var details: Details? = nil + @Published var error: ErrorInfo? = nil + + enum State: Equatable { + case idle + case permissionRequest + case checking + case updateAvailable + case downloading + case extracting + case readyToInstall + case installing + case notFound + case error + } + + struct ErrorInfo: Equatable { + let title: String + let message: String + } + + struct Details: Equatable { + let version: String + let build: String? + let size: String? + let date: Date? + let notesSummary: String? + } + + var stateTooltip: String { + switch state { + case .idle: + return "" + case .permissionRequest: + return "Update permission required" + case .checking: + return "Checking for updates…" + case .updateAvailable: + if let details { + return "Update available: \(details.version)" + } + return "Update available" + case .downloading: + if let progress { + return String(format: "Downloading %.0f%%…", progress * 100) + } + return "Downloading…" + case .extracting: + if let progress { + return String(format: "Preparing %.0f%%…", progress * 100) + } + return "Preparing…" + case .readyToInstall: + return "Ready to install" + case .installing: + return "Installing…" + case .notFound: + return "No updates found" + case .error: + return error?.title ?? "Update failed" + } + } + + var text: String { + switch state { + case .idle: + return "" + case .permissionRequest: + return "Update Permission" + case .checking: + return "Checking for Updates…" + case .updateAvailable: + if let details { + return "Update Available: \(details.version)" + } + return "Update Available" + case .downloading: + if let progress { + return String(format: "Downloading: %.0f%%", progress * 100) + } + return "Downloading…" + case .extracting: + if let progress { + return String(format: "Preparing: %.0f%%", progress * 100) + } + return "Preparing…" + case .readyToInstall: + return "Install Update" + case .installing: + return "Installing…" + case .notFound: + return "No Updates Available" + case .error: + return error?.title ?? "Update Failed" + } + } + + var iconName: String { + switch state { + case .idle: + return "" + case .permissionRequest: + return "questionmark.circle" + case .checking: + return "arrow.triangle.2.circlepath" + case .updateAvailable: + return "arrow.down.circle.fill" + case .downloading, .extracting: + return "" // Progress ring instead + case .readyToInstall: + return "checkmark.circle.fill" + case .installing: + return "gear" + case .notFound: + return "info.circle" + case .error: + return "exclamationmark.triangle.fill" + } + } + + var iconColor: Color { + switch state { + case .idle: + return .secondary + case .permissionRequest, .checking: + return .secondary + case .updateAvailable, .readyToInstall: + return .accentColor + case .downloading, .extracting, .installing: + return .secondary + case .notFound: + return .secondary + case .error: + return .orange + } + } + + var backgroundColor: Color { + switch state { + case .updateAvailable: + return .accentColor + case .readyToInstall: + return Color(nsColor: NSColor.systemGreen.blended(withFraction: 0.3, of: .black) ?? .systemGreen) + case .error: + return .orange.opacity(0.2) + default: + return Color(nsColor: .controlBackgroundColor) + } + } + + var foregroundColor: Color { + switch state { + case .updateAvailable, .readyToInstall: + return .white + case .error: + return .orange + default: + return .primary + } + } +} From fc347a6040b5e626f630c19afce69f947318e4f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 12:40:42 -0700 Subject: [PATCH 042/702] macOS: Move update view model over to App scope --- macos/Sources/App/macOS/AppDelegate.swift | 23 +++--- .../Window Styles/TerminalWindow.swift | 72 +++++++++++-------- 2 files changed, 50 insertions(+), 45 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a893c3877..898191b1a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -101,6 +101,9 @@ class AppDelegate: NSObject, /// Manages updates let updaterController: SPUStandardUpdaterController let updaterDelegate: UpdaterDelegate = UpdaterDelegate() + + /// Update view model for UI display + @Published private(set) var updateUIModel = UpdateViewModel() /// The elapsed time since the process was started var timeSinceLaunch: TimeInterval { @@ -1008,24 +1011,16 @@ class AppDelegate: NSObject, // Demo mode: simulate update check instead of real Sparkle check // TODO: Replace with real updaterController.checkForUpdates(sender) when SPUUserDriver is implemented - guard let terminalWindow = NSApp.keyWindow as? TerminalWindow else { - // Fallback to real update check if no terminal window - updaterController.checkForUpdates(sender) - return - } - - let model = terminalWindow.updateUIModel - // Simulate the full update check flow - model.state = .checking - model.progress = nil - model.details = nil - model.error = nil + updateUIModel.state = .checking + updateUIModel.progress = nil + updateUIModel.details = nil + updateUIModel.error = nil DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { // Simulate finding an update - model.state = .updateAvailable - model.details = .init( + self.updateUIModel.state = .updateAvailable + self.updateUIModel.details = .init( version: "1.2.0", build: "demo", size: "42 MB", diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 248577f4f..87f2be1ca 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -17,7 +17,6 @@ class TerminalWindow: NSWindow { /// Update notification UI in titlebar private let updateAccessory = NSTitlebarAccessoryViewController() - private(set) var updateUIModel = UpdateViewModel() /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() @@ -94,7 +93,7 @@ class TerminalWindow: NSWindow { updateAccessory.layoutAttribute = .right updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( viewModel: viewModel, - model: updateUIModel, + model: appDelegate.updateUIModel, actions: createUpdateActions() )) addTitlebarAccessoryViewController(updateAccessory) @@ -457,48 +456,60 @@ class TerminalWindow: NSWindow { // MARK: Update UI private func createUpdateActions() -> UpdateUIActions { - UpdateUIActions( - allowAutoChecks: { [weak self] in + guard let appDelegate = NSApp.delegate as? AppDelegate else { + return UpdateUIActions( + allowAutoChecks: {}, + denyAutoChecks: {}, + cancel: {}, + install: {}, + remindLater: {}, + skipThisVersion: {}, + showReleaseNotes: {}, + retry: {} + ) + } + + return UpdateUIActions( + allowAutoChecks: { print("Demo: Allow auto checks") - self?.updateUIModel.state = .idle + appDelegate.updateUIModel.state = .idle }, - denyAutoChecks: { [weak self] in + denyAutoChecks: { print("Demo: Deny auto checks") - self?.updateUIModel.state = .idle + appDelegate.updateUIModel.state = .idle }, - cancel: { [weak self] in + cancel: { print("Demo: Cancel") - self?.updateUIModel.state = .idle + appDelegate.updateUIModel.state = .idle }, - install: { [weak self] in - guard let self else { return } + install: { print("Demo: Install - simulating download and install flow") // Start downloading - self.updateUIModel.state = .downloading - self.updateUIModel.progress = 0.0 + appDelegate.updateUIModel.state = .downloading + appDelegate.updateUIModel.progress = 0.0 // Simulate download progress for i in 1...10 { DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { - self.updateUIModel.progress = Double(i) / 10.0 + appDelegate.updateUIModel.progress = Double(i) / 10.0 if i == 10 { // Move to extraction DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.updateUIModel.state = .extracting - self.updateUIModel.progress = 0.0 + appDelegate.updateUIModel.state = .extracting + appDelegate.updateUIModel.progress = 0.0 // Simulate extraction progress for j in 1...5 { DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { - self.updateUIModel.progress = Double(j) / 5.0 + appDelegate.updateUIModel.progress = Double(j) / 5.0 if j == 5 { // Move to ready to install DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.updateUIModel.state = .readyToInstall - self.updateUIModel.progress = nil + appDelegate.updateUIModel.state = .readyToInstall + appDelegate.updateUIModel.progress = nil } } } @@ -508,29 +519,28 @@ class TerminalWindow: NSWindow { } } }, - remindLater: { [weak self] in + remindLater: { print("Demo: Remind later") - self?.updateUIModel.state = .idle + appDelegate.updateUIModel.state = .idle }, - skipThisVersion: { [weak self] in + skipThisVersion: { print("Demo: Skip version") - self?.updateUIModel.state = .idle + appDelegate.updateUIModel.state = .idle }, - showReleaseNotes: { [weak self] in + showReleaseNotes: { print("Demo: Show release notes") guard let url = URL(string: "https://github.com/ghostty-org/ghostty/releases") else { return } NSWorkspace.shared.open(url) }, - retry: { [weak self] in - guard let self else { return } + retry: { print("Demo: Retry - simulating update check") - self.updateUIModel.state = .checking - self.updateUIModel.progress = nil - self.updateUIModel.error = nil + appDelegate.updateUIModel.state = .checking + appDelegate.updateUIModel.progress = nil + appDelegate.updateUIModel.error = nil DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.updateUIModel.state = .updateAvailable - self.updateUIModel.details = .init( + appDelegate.updateUIModel.state = .updateAvailable + appDelegate.updateUIModel.details = .init( version: "1.2.0", build: "demo", size: "42 MB", From 81e3ff90a35ed75839cea83d909790bf7d807c63 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 13:24:37 -0700 Subject: [PATCH 043/702] macOS: Show update information as an overlay --- macos/Sources/App/macOS/AppDelegate.swift | 84 +++++++++++++++ .../Features/Terminal/TerminalView.swift | 20 ++++ .../Window Styles/TerminalWindow.swift | 101 +----------------- .../Sources/Features/Update/UpdatePill.swift | 13 +-- 4 files changed, 110 insertions(+), 108 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 898191b1a..cfa871b32 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -104,6 +104,11 @@ class AppDelegate: NSObject, /// Update view model for UI display @Published private(set) var updateUIModel = UpdateViewModel() + + /// Update actions for UI interactions + private(set) lazy var updateActions: UpdateUIActions = { + createUpdateActions() + }() /// The elapsed time since the process was started var timeSinceLaunch: TimeInterval { @@ -1029,6 +1034,85 @@ class AppDelegate: NSObject, ) } } + + private func createUpdateActions() -> UpdateUIActions { + return UpdateUIActions( + allowAutoChecks: { + print("Demo: Allow auto checks") + self.updateUIModel.state = .idle + }, + denyAutoChecks: { + print("Demo: Deny auto checks") + self.updateUIModel.state = .idle + }, + cancel: { + print("Demo: Cancel") + self.updateUIModel.state = .idle + }, + install: { + print("Demo: Install - simulating download and install flow") + + self.updateUIModel.state = .downloading + self.updateUIModel.progress = 0.0 + + for i in 1...10 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { + self.updateUIModel.progress = Double(i) / 10.0 + + if i == 10 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.updateUIModel.state = .extracting + self.updateUIModel.progress = 0.0 + + for j in 1...5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { + self.updateUIModel.progress = Double(j) / 5.0 + + if j == 5 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.updateUIModel.state = .readyToInstall + self.updateUIModel.progress = nil + } + } + } + } + } + } + } + } + }, + remindLater: { + print("Demo: Remind later") + self.updateUIModel.state = .idle + }, + skipThisVersion: { + print("Demo: Skip version") + self.updateUIModel.state = .idle + }, + showReleaseNotes: { + print("Demo: Show release notes") + guard let url = URL(string: "https://github.com/ghostty-org/ghostty/releases") else { return } + NSWorkspace.shared.open(url) + }, + retry: { + print("Demo: Retry - simulating update check") + self.updateUIModel.state = .checking + self.updateUIModel.progress = nil + self.updateUIModel.error = nil + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.updateUIModel.state = .updateAvailable + self.updateUIModel.details = .init( + version: "1.2.0", + build: "demo", + size: "42 MB", + date: Date(), + notesSummary: "This is a demo of the update UI." + ) + } + } + ) + } @IBAction func newWindow(_ sender: Any?) { _ = TerminalController.newWindow(ghostty) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index b5be0ae42..54d2011c7 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -109,6 +109,26 @@ struct TerminalView: View { self.delegate?.performAction(action, on: surfaceView) } } + + // Show update information above all else. + UpdateOverlay() + } + } + } +} + +fileprivate struct UpdateOverlay: View { + var body: some View { + if let appDelegate = NSApp.delegate as? AppDelegate { + VStack { + Spacer() + + HStack { + Spacer() + UpdatePill(model: appDelegate.updateUIModel, actions: appDelegate.updateActions) + .padding(.bottom, 12) + .padding(.trailing, 12) + } } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 87f2be1ca..62439f676 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -94,7 +94,7 @@ class TerminalWindow: NSWindow { updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( viewModel: viewModel, model: appDelegate.updateUIModel, - actions: createUpdateActions() + actions: appDelegate.updateActions )) addTitlebarAccessoryViewController(updateAccessory) updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false @@ -453,105 +453,6 @@ class TerminalWindow: NSWindow { standardWindowButton(.zoomButton)?.isHidden = true } - // MARK: Update UI - - private func createUpdateActions() -> UpdateUIActions { - guard let appDelegate = NSApp.delegate as? AppDelegate else { - return UpdateUIActions( - allowAutoChecks: {}, - denyAutoChecks: {}, - cancel: {}, - install: {}, - remindLater: {}, - skipThisVersion: {}, - showReleaseNotes: {}, - retry: {} - ) - } - - return UpdateUIActions( - allowAutoChecks: { - print("Demo: Allow auto checks") - appDelegate.updateUIModel.state = .idle - }, - denyAutoChecks: { - print("Demo: Deny auto checks") - appDelegate.updateUIModel.state = .idle - }, - cancel: { - print("Demo: Cancel") - appDelegate.updateUIModel.state = .idle - }, - install: { - print("Demo: Install - simulating download and install flow") - - // Start downloading - appDelegate.updateUIModel.state = .downloading - appDelegate.updateUIModel.progress = 0.0 - - // Simulate download progress - for i in 1...10 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { - appDelegate.updateUIModel.progress = Double(i) / 10.0 - - if i == 10 { - // Move to extraction - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - appDelegate.updateUIModel.state = .extracting - appDelegate.updateUIModel.progress = 0.0 - - // Simulate extraction progress - for j in 1...5 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { - appDelegate.updateUIModel.progress = Double(j) / 5.0 - - if j == 5 { - // Move to ready to install - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - appDelegate.updateUIModel.state = .readyToInstall - appDelegate.updateUIModel.progress = nil - } - } - } - } - } - } - } - } - }, - remindLater: { - print("Demo: Remind later") - appDelegate.updateUIModel.state = .idle - }, - skipThisVersion: { - print("Demo: Skip version") - appDelegate.updateUIModel.state = .idle - }, - showReleaseNotes: { - print("Demo: Show release notes") - guard let url = URL(string: "https://github.com/ghostty-org/ghostty/releases") else { return } - NSWorkspace.shared.open(url) - }, - retry: { - print("Demo: Retry - simulating update check") - appDelegate.updateUIModel.state = .checking - appDelegate.updateUIModel.progress = nil - appDelegate.updateUIModel.error = nil - - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - appDelegate.updateUIModel.state = .updateAvailable - appDelegate.updateUIModel.details = .init( - version: "1.2.0", - build: "demo", - size: "42 MB", - date: Date(), - notesSummary: "This is a demo of the update UI." - ) - } - } - ) - } - // MARK: Config struct DerivedConfig { diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index 604be0fbc..ea3c093dc 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -13,14 +13,11 @@ struct UpdatePill: View { var body: some View { if model.state != .idle { - VStack { - pillButton - Spacer() - } - .popover(isPresented: $showPopover, arrowEdge: .bottom) { - UpdatePopoverView(model: model, actions: actions) - } - .transition(.opacity.combined(with: .scale(scale: 0.95))) + pillButton + .popover(isPresented: $showPopover, arrowEdge: .bottom) { + UpdatePopoverView(model: model, actions: actions) + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) } } From f975ac8019c2e5854dbc4b8f6fe1f1913b319a72 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 13:54:58 -0700 Subject: [PATCH 044/702] macOS: only show the update overlay if window doesn't support it --- .../QuickTerminalController.swift | 2 +- .../Terminal/BaseTerminalController.swift | 38 ++++++++++++++++++- .../Features/Terminal/TerminalView.swift | 7 +++- .../HiddenTitlebarTerminalWindow.swift | 3 ++ .../Window Styles/TerminalWindow.swift | 27 ++++++++----- .../TitlebarTabsTahoeTerminalWindow.swift | 4 ++ .../TitlebarTabsVenturaTerminalWindow.swift | 4 ++ 7 files changed, 73 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index fcc8c6505..37c9985c9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -37,7 +37,7 @@ class QuickTerminalController: BaseTerminalController { /// Tracks if we're currently handling a manual resize to prevent recursion private var isHandlingResize: Bool = false - + init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index f660ea3ad..b9f9c5a05 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -48,6 +48,9 @@ class BaseTerminalController: NSWindowController, /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false + + /// Set if the terminal view should show the update overlay. + @Published var updateOverlayIsVisible: Bool = false /// Whether the terminal surface should focus when the mouse is over it. var focusFollowsMouse: Bool { @@ -818,7 +821,18 @@ class BaseTerminalController: NSWindowController, } } - func fullscreenDidChange() {} + func fullscreenDidChange() { + guard let fullscreenStyle else { return } + + // When we enter fullscreen, we want to show the update overlay so that it + // is easily visible. For native fullscreen this is visible by showing the + // menubar but we don't want to rely on that. + if fullscreenStyle.isFullscreen { + updateOverlayIsVisible = true + } else { + updateOverlayIsVisible = defaultUpdateOverlayVisibility() + } + } // MARK: Clipboard Confirmation @@ -900,6 +914,28 @@ class BaseTerminalController: NSWindowController, fullscreenStyle = NativeFullscreen(window) fullscreenStyle?.delegate = self } + + // Set our update overlay state + updateOverlayIsVisible = defaultUpdateOverlayVisibility() + } + + func defaultUpdateOverlayVisibility() -> Bool { + guard let window else { return true } + + // No titlebar we always show the update overlay because it can't support + // updates in the titlebar + guard window.styleMask.contains(.titled) else { + return true + } + + // If it's a non terminal window we can't trust it has an update accessory, + // so we always want to show the overlay. + guard let window = window as? TerminalWindow else { + return true + } + + // Show the overlay if the window isn't. + return !window.supportsUpdateAccessory } // MARK: NSWindowDelegate diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 54d2011c7..832cd7966 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -31,6 +31,9 @@ protocol TerminalViewModel: ObservableObject { /// The command palette state. var commandPaletteIsShowing: Bool { get set } + + /// The update overlay should be visible. + var updateOverlayIsVisible: Bool { get } } /// The main terminal view. This terminal view supports splits. @@ -111,7 +114,9 @@ struct TerminalView: View { } // Show update information above all else. - UpdateOverlay() + if viewModel.updateOverlayIsVisible { + UpdateOverlay() + } } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift index dc7dd7633..dd8b258f3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -1,6 +1,9 @@ import AppKit class HiddenTitlebarTerminalWindow: TerminalWindow { + // No titlebar, we don't support accessories. + override var supportsUpdateAccessory: Bool { false } + override func awakeFromNib() { super.awakeFromNib() diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 62439f676..6e657f33e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -20,12 +20,19 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() + + /// Whether this window supports the update accessory. If this is false, then views within this + /// window should determine how to show update notifications. + var supportsUpdateAccessory: Bool { + // Native window supports it. + true + } /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { windowController as? TerminalController } - + // MARK: NSWindow Overrides override var toolbar: NSToolbar? { @@ -90,14 +97,16 @@ class TerminalWindow: NSWindow { resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false // Create update notification accessory - updateAccessory.layoutAttribute = .right - updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( - viewModel: viewModel, - model: appDelegate.updateUIModel, - actions: appDelegate.updateActions - )) - addTitlebarAccessoryViewController(updateAccessory) - updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false + if supportsUpdateAccessory { + updateAccessory.layoutAttribute = .right + updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( + viewModel: viewModel, + model: appDelegate.updateUIModel, + actions: appDelegate.updateActions + )) + addTitlebarAccessoryViewController(updateAccessory) + updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false + } } // Setup the accessory view for tabs that shows our keyboard shortcuts, diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 260fac4cc..855d29f52 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -8,6 +8,10 @@ import SwiftUI class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() + + /// Titlebar tabs can't support the update accessory because of the way we layout + /// the native tabs back into the menu bar. + override var supportsUpdateAccessory: Bool { false } deinit { tabBarObserver = nil diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 8589877d8..0c087faeb 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -2,6 +2,10 @@ import Cocoa /// Titlebar tabs for macOS 13 to 15. class TitlebarTabsVenturaTerminalWindow: TerminalWindow { + /// Titlebar tabs can't support the update accessory because of the way we layout + /// the native tabs back into the menu bar. + override var supportsUpdateAccessory: Bool { false } + /// This is used to determine if certain elements should be drawn light or dark and should /// be updated whenever the window background color or surrounding elements changes. fileprivate var isLightTheme: Bool = false From 5bebd10b7f73d5a6225c4d254be929cd104f9433 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 00:06:42 +0000 Subject: [PATCH 045/702] build(deps): bump hustcer/milestone-action from 2.9 to 2.11 Bumps [hustcer/milestone-action](https://github.com/hustcer/milestone-action) from 2.9 to 2.11. - [Release notes](https://github.com/hustcer/milestone-action/releases) - [Changelog](https://github.com/hustcer/milestone-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/hustcer/milestone-action/compare/b57a7e52e9913b6b0cdefb10add762af0398659d...bff2091b54a91cf1491564659c554742b285442f) --- updated-dependencies: - dependency-name: hustcer/milestone-action dependency-version: '2.11' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/milestone.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index d12418d9c..25d8edaa0 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -15,7 +15,7 @@ jobs: name: Milestone Update steps: - name: Set Milestone for PR - uses: hustcer/milestone-action@b57a7e52e9913b6b0cdefb10add762af0398659d # v2.9 + uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v2.11 if: github.event.pull_request.merged == true with: action: bind-pr # `bind-pr` is the default action @@ -24,7 +24,7 @@ jobs: # Bind milestone to closed issue that has a merged PR fix - name: Set Milestone for Issue - uses: hustcer/milestone-action@b57a7e52e9913b6b0cdefb10add762af0398659d # v2.9 + uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v2.11 if: github.event.issue.state == 'closed' with: action: bind-issue From e8ebc6f40535951af3737c571a0023a683e3bfba Mon Sep 17 00:00:00 2001 From: Mike Akers Date: Wed, 8 Oct 2025 21:05:04 -0400 Subject: [PATCH 046/702] docs: Update build requirements for macOS Adds the Metal Toolchain as a required Xcode component for building Ghostty. Also updates the notes about Xcode 26 now that it and Tahoe are out of Beta. --- HACKING.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/HACKING.md b/HACKING.md index 905a244e8..0a4bbef20 100644 --- a/HACKING.md +++ b/HACKING.md @@ -50,24 +50,22 @@ macOS users don't require any additional dependencies. ## Xcode Version and SDKs Building the Ghostty macOS app requires that Xcode, the macOS SDK, -and the iOS SDK are all installed. +the iOS SDK, and Metal Toolchain are all installed. A common issue is that the incorrect version of Xcode is either installed or selected. Use the `xcode-select` command to ensure that the correct version of Xcode is selected: ```shell-session -sudo xcode-select --switch /Applications/Xcode-beta.app +sudo xcode-select --switch /Applications/Xcode.app ``` > [!IMPORTANT] > -> Main branch development of Ghostty is preparing for the next major -> macOS release, Tahoe (macOS 26). Therefore, the main branch requires -> **Xcode 26 and the macOS 26 SDK**. +> Main branch development of Ghostty requires **Xcode 26 and the macOS 26 SDK**. > > You do not need to be running on macOS 26 to build Ghostty, you can -> still use Xcode 26 beta on macOS 15 stable. +> still use Xcode 26 on macOS 15 stable. ## AI and Agents From dfb32022d46576135412d5892713bdec6078519d Mon Sep 17 00:00:00 2001 From: Zhizhen He Date: Thu, 9 Oct 2025 10:52:52 +0800 Subject: [PATCH 047/702] ci: fix typo --- .github/workflows/publish-tag.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-tag.yml b/.github/workflows/publish-tag.yml index 710d04647..c433e7484 100644 --- a/.github/workflows/publish-tag.yml +++ b/.github/workflows/publish-tag.yml @@ -28,7 +28,7 @@ jobs: echo "Version is valid: ${{ github.event.inputs.version }}" - - name: Exract the Version + - name: Extract the Version id: extract_version run: | VERSION=${{ github.event.inputs.version }} From 59829f53598b48c73137fbfb1d0c7e81375569ac Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 15:52:42 -0700 Subject: [PATCH 048/702] Sparkle user driver, drives updates to the view model. --- macos/Sources/App/macOS/AppDelegate.swift | 160 +++++----- .../Features/Terminal/TerminalView.swift | 2 +- .../Window Styles/TerminalWindow.swift | 6 +- .../Sources/Features/Update/UpdateBadge.swift | 32 +- .../Features/Update/UpdateDriver.swift | 103 +++++++ .../Sources/Features/Update/UpdatePill.swift | 9 +- .../Features/Update/UpdatePopoverView.swift | 276 +++++++++--------- .../Features/Update/UpdateViewModel.swift | 165 +++++------ 8 files changed, 403 insertions(+), 350 deletions(-) create mode 100644 macos/Sources/Features/Update/UpdateDriver.swift diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index cfa871b32..4fd6dfb3f 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -103,12 +103,7 @@ class AppDelegate: NSObject, let updaterDelegate: UpdaterDelegate = UpdaterDelegate() /// Update view model for UI display - @Published private(set) var updateUIModel = UpdateViewModel() - - /// Update actions for UI interactions - private(set) lazy var updateActions: UpdateUIActions = { - createUpdateActions() - }() + @Published private(set) var updateViewModel = UpdateViewModel() /// The elapsed time since the process was started var timeSinceLaunch: TimeInterval { @@ -1013,106 +1008,83 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - // Demo mode: simulate update check instead of real Sparkle check - // TODO: Replace with real updaterController.checkForUpdates(sender) when SPUUserDriver is implemented + // Demo mode: simulate update check with new UpdateState + updateViewModel.state = .checking(.init(cancel: { [weak self] in + self?.updateViewModel.state = .idle + })) - // Simulate the full update check flow - updateUIModel.state = .checking - updateUIModel.progress = nil - updateUIModel.details = nil - updateUIModel.error = nil - - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - // Simulate finding an update - self.updateUIModel.state = .updateAvailable - self.updateUIModel.details = .init( - version: "1.2.0", - build: "demo", - size: "42 MB", - date: Date(), - notesSummary: "This is a demo of the update UI. New features and bug fixes would be listed here." - ) + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self else { return } + + self.updateViewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { [weak self] choice in + if choice == .install { + self?.simulateDownload() + } else { + self?.updateViewModel.state = .idle + } + } + )) } } - private func createUpdateActions() -> UpdateUIActions { - return UpdateUIActions( - allowAutoChecks: { - print("Demo: Allow auto checks") - self.updateUIModel.state = .idle + private func simulateDownload() { + let download = UpdateState.Downloading( + cancel: { [weak self] in + self?.updateViewModel.state = .idle }, - denyAutoChecks: { - print("Demo: Deny auto checks") - self.updateUIModel.state = .idle - }, - cancel: { - print("Demo: Cancel") - self.updateUIModel.state = .idle - }, - install: { - print("Demo: Install - simulating download and install flow") + expectedLength: nil, + progress: 0, + ) + updateViewModel.state = .downloading(download) + + for i in 1...10 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { [weak self] in + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 1000, + progress: UInt64(i * 100) + ) + self?.updateViewModel.state = .downloading(updatedDownload) - self.updateUIModel.state = .downloading - self.updateUIModel.progress = 0.0 - - for i in 1...10 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { - self.updateUIModel.progress = Double(i) / 10.0 - - if i == 10 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.updateUIModel.state = .extracting - self.updateUIModel.progress = 0.0 - - for j in 1...5 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { - self.updateUIModel.progress = Double(j) / 5.0 - - if j == 5 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.updateUIModel.state = .readyToInstall - self.updateUIModel.progress = nil - } - } - } - } - } - } + if i == 10 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.simulateExtract() } } - }, - remindLater: { - print("Demo: Remind later") - self.updateUIModel.state = .idle - }, - skipThisVersion: { - print("Demo: Skip version") - self.updateUIModel.state = .idle - }, - showReleaseNotes: { - print("Demo: Show release notes") - guard let url = URL(string: "https://github.com/ghostty-org/ghostty/releases") else { return } - NSWorkspace.shared.open(url) - }, - retry: { - print("Demo: Retry - simulating update check") - self.updateUIModel.state = .checking - self.updateUIModel.progress = nil - self.updateUIModel.error = nil + } + } + } + + private func simulateExtract() { + updateViewModel.state = .extracting(.init(progress: 0.0)) + + for j in 1...5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { [weak self] in + self?.updateViewModel.state = .extracting(.init(progress: Double(j) / 5.0)) - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.updateUIModel.state = .updateAvailable - self.updateUIModel.details = .init( - version: "1.2.0", - build: "demo", - size: "42 MB", - date: Date(), - notesSummary: "This is a demo of the update UI." - ) + if j == 5 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.updateViewModel.state = .readyToInstall(.init( + reply: { [weak self] choice in + if choice == .install { + self?.updateViewModel.state = .installing + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.updateViewModel.state = .idle + } + } else { + self?.updateViewModel.state = .idle + } + } + )) + } } } - ) + } } + + @IBAction func newWindow(_ sender: Any?) { _ = TerminalController.newWindow(ghostty) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 832cd7966..51c4f6ddd 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -130,7 +130,7 @@ fileprivate struct UpdateOverlay: View { HStack { Spacer() - UpdatePill(model: appDelegate.updateUIModel, actions: appDelegate.updateActions) + UpdatePill(model: appDelegate.updateViewModel) .padding(.bottom, 12) .padding(.trailing, 12) } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 6e657f33e..4737bacaf 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -101,8 +101,7 @@ class TerminalWindow: NSWindow { updateAccessory.layoutAttribute = .right updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( viewModel: viewModel, - model: appDelegate.updateUIModel, - actions: appDelegate.updateActions + model: appDelegate.updateViewModel )) addTitlebarAccessoryViewController(updateAccessory) updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false @@ -532,10 +531,9 @@ extension TerminalWindow { struct UpdateAccessoryView: View { @ObservedObject var viewModel: ViewModel @ObservedObject var model: UpdateViewModel - let actions: UpdateUIActions var body: some View { - UpdatePill(model: model, actions: actions) + UpdatePill(model: model) .padding(.top, viewModel.accessoryTopPadding) .padding(.trailing, 10) } diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift index a6ffe6cb6..fd1eb3498 100644 --- a/macos/Sources/Features/Update/UpdateBadge.swift +++ b/macos/Sources/Features/Update/UpdateBadge.swift @@ -15,27 +15,35 @@ struct UpdateBadge: View { var body: some View { switch model.state { - case .downloading, .extracting: - if let progress = model.progress { + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = Double(download.progress) / Double(expectedLength) ProgressRingView(progress: progress) } else { Image(systemName: "arrow.down.circle") } + case .extracting(let extracting): + ProgressRingView(progress: extracting.progress) + case .checking, .installing: - Image(systemName: model.iconName) - .rotationEffect(.degrees(rotationAngle)) - .onAppear { - withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) { - rotationAngle = 360 + if let iconName = model.iconName { + Image(systemName: iconName) + .rotationEffect(.degrees(rotationAngle)) + .onAppear { + withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) { + rotationAngle = 360 + } } - } - .onDisappear { - rotationAngle = 0 - } + .onDisappear { + rotationAngle = 0 + } + } default: - Image(systemName: model.iconName) + if let iconName = model.iconName { + Image(systemName: iconName) + } } } } diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift new file mode 100644 index 000000000..00f74e9ed --- /dev/null +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -0,0 +1,103 @@ +import Sparkle + +/// Implement the SPUUserDriver to modify our UpdateViewModel for custom presentation. +class UpdateDriver: NSObject, SPUUserDriver { + let viewModel: UpdateViewModel + let retryHandler: () -> Void + + init(viewModel: UpdateViewModel, retryHandler: @escaping () -> Void) { + self.viewModel = viewModel + self.retryHandler = retryHandler + super.init() + } + + func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { + viewModel.state = .permissionRequest(.init(request: request, reply: reply)) + } + + func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { + viewModel.state = .checking(.init(cancel: cancellation)) + } + + func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { + viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply)) + } + + func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { + // We don't do anything with the release notes here because Ghostty + // doesn't use the release notes feature of Sparkle currently. + } + + func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) { + // We don't do anything with release notes. See `showUpdateReleaseNotes` + } + + func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) { + viewModel.state = .notFound + // TODO: Do we need to acknowledge? + } + + func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { + viewModel.state = .error(.init(error: error, retry: retryHandler)) + } + + func showDownloadInitiated(cancellation: @escaping () -> Void) { + viewModel.state = .downloading(.init( + cancel: cancellation, + expectedLength: nil, + progress: 0)) + } + + func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { + guard case let .downloading(downloading) = viewModel.state else { + return + } + + viewModel.state = .downloading(.init( + cancel: downloading.cancel, + expectedLength: expectedContentLength, + progress: 0)) + } + + func showDownloadDidReceiveData(ofLength length: UInt64) { + guard case let .downloading(downloading) = viewModel.state else { + return + } + + viewModel.state = .downloading(.init( + cancel: downloading.cancel, + expectedLength: downloading.expectedLength, + progress: downloading.progress + length)) + } + + func showDownloadDidStartExtractingUpdate() { + viewModel.state = .extracting(.init(progress: 0)) + } + + func showExtractionReceivedProgress(_ progress: Double) { + viewModel.state = .extracting(.init(progress: progress)) + } + + func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { + viewModel.state = .readyToInstall(.init(reply: reply)) + } + + func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { + viewModel.state = .installing + } + + func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { + // We don't do anything here. + viewModel.state = .idle + } + + func showUpdateInFocus() { + // We don't currently implement this because our update state is + // shown in a terminal window. We may want to implement this at some + // point to handle the case that no windows are open, though. + } + + func dismissUpdateInstallation() { + viewModel.state = .idle + } +} diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index ea3c093dc..dda9ad607 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -5,17 +5,14 @@ struct UpdatePill: View { /// The update view model that provides the current state and information @ObservedObject var model: UpdateViewModel - /// The actions that can be performed on updates - let actions: UpdateUIActions - /// Whether the update popover is currently visible @State private var showPopover = false var body: some View { - if model.state != .idle { + if !model.state.isIdle { pillButton .popover(isPresented: $showPopover, arrowEdge: .bottom) { - UpdatePopoverView(model: model, actions: actions) + UpdatePopoverView(model: model) } .transition(.opacity.combined(with: .scale(scale: 0.95))) } @@ -43,6 +40,6 @@ struct UpdatePill: View { .contentShape(Capsule()) } .buttonStyle(.plain) - .help(model.stateTooltip) + .help(model.text) } } diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index af870b4de..39c4ac5c9 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Sparkle /// A popover view that displays detailed update information and action buttons. /// @@ -8,9 +9,6 @@ struct UpdatePopoverView: View { /// The update view model that provides the current state and information @ObservedObject var model: UpdateViewModel - /// The actions that can be performed on updates - let actions: UpdateUIActions - /// Environment value for dismissing the popover @Environment(\.dismiss) private var dismiss @@ -18,41 +16,47 @@ struct UpdatePopoverView: View { VStack(alignment: .leading, spacing: 0) { switch model.state { case .idle: + // Shouldn't happen in a well-formed view stack. Higher levels + // should not call the popover for idles. EmptyView() - case .permissionRequest: - permissionRequestView + case .permissionRequest(let request): + PermissionRequestView(request: request, dismiss: dismiss) - case .checking: - checkingView + case .checking(let checking): + CheckingView(checking: checking, dismiss: dismiss) - case .updateAvailable: - updateAvailableView + case .updateAvailable(let update): + UpdateAvailableView(update: update, dismiss: dismiss) - case .downloading: - downloadingView + case .downloading(let download): + DownloadingView(download: download, dismiss: dismiss) - case .extracting: - extractingView + case .extracting(let extracting): + ExtractingView(extracting: extracting) - case .readyToInstall: - readyToInstallView + case .readyToInstall(let ready): + ReadyToInstallView(ready: ready, dismiss: dismiss) case .installing: - installingView + InstallingView() case .notFound: - notFoundView + NotFoundView(dismiss: dismiss) - case .error: - errorView + case .error(let error): + UpdateErrorView(error: error, dismiss: dismiss) } } .frame(width: 300) } +} + +fileprivate struct PermissionRequestView: View { + let request: UpdateState.PermissionRequest + let dismiss: DismissAction - /// View shown when requesting permission to enable automatic updates - private var permissionRequestView: some View { + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Enable automatic updates?") @@ -66,7 +70,9 @@ struct UpdatePopoverView: View { HStack(spacing: 8) { Button("Not Now") { - actions.denyAutoChecks() + request.reply(SUUpdatePermissionResponse( + automaticUpdateChecks: false, + sendSystemProfile: false)) dismiss() } .keyboardShortcut(.cancelAction) @@ -74,7 +80,9 @@ struct UpdatePopoverView: View { Spacer() Button("Allow") { - actions.allowAutoChecks() + request.reply(SUUpdatePermissionResponse( + automaticUpdateChecks: true, + sendSystemProfile: false)) dismiss() } .keyboardShortcut(.defaultAction) @@ -83,9 +91,13 @@ struct UpdatePopoverView: View { } .padding(16) } +} + +fileprivate struct CheckingView: View { + let checking: UpdateState.Checking + let dismiss: DismissAction - /// View shown while checking for updates - private var checkingView: some View { + var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(spacing: 10) { ProgressView() @@ -97,7 +109,7 @@ struct UpdatePopoverView: View { HStack { Spacer() Button("Cancel") { - actions.cancel() + checking.cancel() dismiss() } .keyboardShortcut(.cancelAction) @@ -106,47 +118,39 @@ struct UpdatePopoverView: View { } .padding(16) } +} + +fileprivate struct UpdateAvailableView: View { + let update: UpdateState.UpdateAvailable + let dismiss: DismissAction - /// View shown when an update is available, displaying version and size information - private var updateAvailableView: some View { + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) { Text("Update Available") .font(.system(size: 13, weight: .semibold)) - if let details = model.details { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Text("Version:") - .foregroundColor(.secondary) - .frame(width: 50, alignment: .trailing) - Text(details.version) - } - .font(.system(size: 11)) - - if let size = details.size { - HStack(spacing: 6) { - Text("Size:") - .foregroundColor(.secondary) - .frame(width: 50, alignment: .trailing) - Text(size) - } - .font(.system(size: 11)) - } + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("Version:") + .foregroundColor(.secondary) + .frame(width: 50, alignment: .trailing) + Text(update.appcastItem.displayVersionString) } + .font(.system(size: 11)) } } HStack(spacing: 8) { Button("Skip") { - actions.skipThisVersion() + update.reply(.skip) dismiss() } .controlSize(.small) Button("Later") { - actions.remindLater() + update.reply(.dismiss) dismiss() } .controlSize(.small) @@ -155,7 +159,7 @@ struct UpdatePopoverView: View { Spacer() Button("Install") { - actions.install() + update.reply(.install) dismiss() } .keyboardShortcut(.defaultAction) @@ -164,36 +168,22 @@ struct UpdatePopoverView: View { } } .padding(16) - - if model.details?.notesSummary != nil { - Divider() - - Button(action: actions.showReleaseNotes) { - HStack { - Text("View Release Notes") - .font(.system(size: 11)) - Spacer() - Image(systemName: "arrow.up.right.square") - .font(.system(size: 11)) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color(nsColor: .controlBackgroundColor)) - } } } +} + +fileprivate struct DownloadingView: View { + let download: UpdateState.Downloading + let dismiss: DismissAction - /// View shown while downloading an update, with progress indicator - private var downloadingView: some View { + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Downloading Update") .font(.system(size: 13, weight: .semibold)) - if let progress = model.progress { + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = Double(download.progress) / Double(expectedLength) VStack(alignment: .leading, spacing: 6) { ProgressView(value: progress) Text(String(format: "%.0f%%", progress * 100)) @@ -209,7 +199,7 @@ struct UpdatePopoverView: View { HStack { Spacer() Button("Cancel") { - actions.cancel() + download.cancel() dismiss() } .keyboardShortcut(.cancelAction) @@ -218,45 +208,45 @@ struct UpdatePopoverView: View { } .padding(16) } +} + +fileprivate struct ExtractingView: View { + let extracting: UpdateState.Extracting - /// View shown while extracting/preparing the downloaded update - private var extractingView: some View { + var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Preparing Update") .font(.system(size: 13, weight: .semibold)) - if let progress = model.progress { - VStack(alignment: .leading, spacing: 6) { - ProgressView(value: progress) - Text(String(format: "%.0f%%", progress * 100)) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - } else { - ProgressView() - .controlSize(.small) + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: extracting.progress, total: 1.0) + Text(String(format: "%.0f%%", extracting.progress * 100)) + .font(.system(size: 11)) + .foregroundColor(.secondary) } } .padding(16) } +} + +fileprivate struct ReadyToInstallView: View { + let ready: UpdateState.ReadyToInstall + let dismiss: DismissAction - /// View shown when an update is ready to be installed - private var readyToInstallView: some View { + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Ready to Install") .font(.system(size: 13, weight: .semibold)) - if let details = model.details { - Text("Version \(details.version) is ready to install.") - .font(.system(size: 11)) - .foregroundColor(.secondary) - } + Text("The update is ready to install.") + .font(.system(size: 11)) + .foregroundColor(.secondary) } HStack(spacing: 8) { Button("Later") { - actions.remindLater() + ready.reply(.dismiss) dismiss() } .keyboardShortcut(.cancelAction) @@ -265,7 +255,7 @@ struct UpdatePopoverView: View { Spacer() Button("Install and Relaunch") { - actions.install() + ready.reply(.install) dismiss() } .keyboardShortcut(.defaultAction) @@ -275,9 +265,10 @@ struct UpdatePopoverView: View { } .padding(16) } - - /// View shown during the installation process - private var installingView: some View { +} + +fileprivate struct InstallingView: View { + var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { ProgressView() @@ -292,9 +283,12 @@ struct UpdatePopoverView: View { } .padding(16) } +} + +fileprivate struct NotFoundView: View { + let dismiss: DismissAction - /// View shown when no updates are found (already on latest version) - private var notFoundView: some View { + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("No Updates Found") @@ -309,48 +303,48 @@ struct UpdatePopoverView: View { HStack { Spacer() Button("OK") { - actions.remindLater() - dismiss() - } - .keyboardShortcut(.defaultAction) - .controlSize(.small) - } - } - .padding(16) - } - - /// View shown when an error occurs during the update process - private var errorView: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - .font(.system(size: 13)) - Text(model.error?.title ?? "Update Failed") - .font(.system(size: 13, weight: .semibold)) - } - - if let message = model.error?.message { - Text(message) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - HStack(spacing: 8) { - Button("OK") { - actions.remindLater() - dismiss() - } - .keyboardShortcut(.cancelAction) - .controlSize(.small) - - Spacer() - - Button("Retry") { - actions.retry() + dismiss() + } + .keyboardShortcut(.defaultAction) + .controlSize(.small) + } + } + .padding(16) + } +} + +fileprivate struct UpdateErrorView: View { + let error: UpdateState.Error + let dismiss: DismissAction + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.system(size: 13)) + Text("Update Failed") + .font(.system(size: 13, weight: .semibold)) + } + + Text(error.error.localizedDescription) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 8) { + Button("OK") { + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + + Spacer() + + Button("Retry") { + error.retry() dismiss() } .keyboardShortcut(.defaultAction) diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index fb477324c..57e438bcd 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -1,83 +1,13 @@ import Foundation import SwiftUI - -struct UpdateUIActions { - let allowAutoChecks: () -> Void - let denyAutoChecks: () -> Void - let cancel: () -> Void - let install: () -> Void - let remindLater: () -> Void - let skipThisVersion: () -> Void - let showReleaseNotes: () -> Void - let retry: () -> Void -} +import Sparkle class UpdateViewModel: ObservableObject { - @Published var state: State = .idle - @Published var progress: Double? = nil - @Published var details: Details? = nil - @Published var error: ErrorInfo? = nil - - enum State: Equatable { - case idle - case permissionRequest - case checking - case updateAvailable - case downloading - case extracting - case readyToInstall - case installing - case notFound - case error - } - - struct ErrorInfo: Equatable { - let title: String - let message: String - } - - struct Details: Equatable { - let version: String - let build: String? - let size: String? - let date: Date? - let notesSummary: String? - } - - var stateTooltip: String { - switch state { - case .idle: - return "" - case .permissionRequest: - return "Update permission required" - case .checking: - return "Checking for updates…" - case .updateAvailable: - if let details { - return "Update available: \(details.version)" - } - return "Update available" - case .downloading: - if let progress { - return String(format: "Downloading %.0f%%…", progress * 100) - } - return "Downloading…" - case .extracting: - if let progress { - return String(format: "Preparing %.0f%%…", progress * 100) - } - return "Preparing…" - case .readyToInstall: - return "Ready to install" - case .installing: - return "Installing…" - case .notFound: - return "No updates found" - case .error: - return error?.title ?? "Update failed" - } - } + @Published var state: UpdateState = .idle + /// The text to display for the current update state. + /// Returns an empty string for idle state, progress percentages for downloading/extracting, + /// or descriptive text for other states. var text: String { switch state { case .idle: @@ -86,36 +16,33 @@ class UpdateViewModel: ObservableObject { return "Update Permission" case .checking: return "Checking for Updates…" - case .updateAvailable: - if let details { - return "Update Available: \(details.version)" - } - return "Update Available" - case .downloading: - if let progress { + case .updateAvailable(let update): + return "Update Available: \(update.appcastItem.displayVersionString)" + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let progress = Double(download.progress) / Double(expectedLength) return String(format: "Downloading: %.0f%%", progress * 100) } return "Downloading…" - case .extracting: - if let progress { - return String(format: "Preparing: %.0f%%", progress * 100) - } - return "Preparing…" + case .extracting(let extracting): + return String(format: "Preparing: %.0f%%", extracting.progress * 100) case .readyToInstall: return "Install Update" case .installing: return "Installing…" case .notFound: return "No Updates Available" - case .error: - return error?.title ?? "Update Failed" + case .error(let err): + return err.error.localizedDescription } } - var iconName: String { + /// The SF Symbol icon name for the current update state. + /// Returns nil for idle, downloading, and extracting states. + var iconName: String? { switch state { case .idle: - return "" + return nil case .permissionRequest: return "questionmark.circle" case .checking: @@ -123,7 +50,7 @@ class UpdateViewModel: ObservableObject { case .updateAvailable: return "arrow.down.circle.fill" case .downloading, .extracting: - return "" // Progress ring instead + return nil case .readyToInstall: return "checkmark.circle.fill" case .installing: @@ -135,6 +62,7 @@ class UpdateViewModel: ObservableObject { } } + /// The color to apply to the icon for the current update state. var iconColor: Color { switch state { case .idle: @@ -152,6 +80,7 @@ class UpdateViewModel: ObservableObject { } } + /// The background color for the update pill. var backgroundColor: Color { switch state { case .updateAvailable: @@ -165,6 +94,7 @@ class UpdateViewModel: ObservableObject { } } + /// The foreground (text) color for the update pill. var foregroundColor: Color { switch state { case .updateAvailable, .readyToInstall: @@ -176,3 +106,54 @@ class UpdateViewModel: ObservableObject { } } } + +enum UpdateState { + case idle + case permissionRequest(PermissionRequest) + case checking(Checking) + case updateAvailable(UpdateAvailable) + case notFound + case error(Error) + case downloading(Downloading) + case extracting(Extracting) + case readyToInstall(ReadyToInstall) + case installing + + var isIdle: Bool { + if case .idle = self { return true } + return false + } + + struct PermissionRequest { + let request: SPUUpdatePermissionRequest + let reply: @Sendable (SUUpdatePermissionResponse) -> Void + } + + struct Checking { + let cancel: () -> Void + } + + struct UpdateAvailable { + let appcastItem: SUAppcastItem + let reply: @Sendable (SPUUserUpdateChoice) -> Void + } + + struct Error { + let error: any Swift.Error + let retry: () -> Void + } + + struct Downloading { + let cancel: () -> Void + let expectedLength: UInt64? + let progress: UInt64 + } + + struct Extracting { + let progress: Double + } + + struct ReadyToInstall { + let reply: @Sendable (SPUUserUpdateChoice) -> Void + } +} From a55de09944865044d2f44a37a177bf0d5f882cdb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:09:06 -0700 Subject: [PATCH 049/702] macos: update simulator to test various scenarios in UI --- macos/Sources/App/macOS/AppDelegate.swift | 76 +---- .../Features/Update/UpdateSimulator.swift | 272 ++++++++++++++++++ 2 files changed, 273 insertions(+), 75 deletions(-) create mode 100644 macos/Sources/Features/Update/UpdateSimulator.swift diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 4fd6dfb3f..a9f72b58b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1008,82 +1008,8 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - // Demo mode: simulate update check with new UpdateState - updateViewModel.state = .checking(.init(cancel: { [weak self] in - self?.updateViewModel.state = .idle - })) - - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - guard let self else { return } - - self.updateViewModel.state = .updateAvailable(.init( - appcastItem: SUAppcastItem.empty(), - reply: { [weak self] choice in - if choice == .install { - self?.simulateDownload() - } else { - self?.updateViewModel.state = .idle - } - } - )) - } + UpdateSimulator.notFound.simulate(with: updateViewModel) } - - private func simulateDownload() { - let download = UpdateState.Downloading( - cancel: { [weak self] in - self?.updateViewModel.state = .idle - }, - expectedLength: nil, - progress: 0, - ) - updateViewModel.state = .downloading(download) - - for i in 1...10 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { [weak self] in - let updatedDownload = UpdateState.Downloading( - cancel: download.cancel, - expectedLength: 1000, - progress: UInt64(i * 100) - ) - self?.updateViewModel.state = .downloading(updatedDownload) - - if i == 10 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.simulateExtract() - } - } - } - } - } - - private func simulateExtract() { - updateViewModel.state = .extracting(.init(progress: 0.0)) - - for j in 1...5 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { [weak self] in - self?.updateViewModel.state = .extracting(.init(progress: Double(j) / 5.0)) - - if j == 5 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.updateViewModel.state = .readyToInstall(.init( - reply: { [weak self] choice in - if choice == .install { - self?.updateViewModel.state = .installing - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.updateViewModel.state = .idle - } - } else { - self?.updateViewModel.state = .idle - } - } - )) - } - } - } - } - } - @IBAction func newWindow(_ sender: Any?) { diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift new file mode 100644 index 000000000..0cf2d221b --- /dev/null +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -0,0 +1,272 @@ +import Foundation +import Sparkle + +/// Simulates various update scenarios for testing the update UI. +/// +/// The expected usage is by overriding the `checkForUpdates` function in AppDelegate and +/// calling one of these instead. This will allow us to test the update flows without having to use +/// real updates. +enum UpdateSimulator { + /// Complete successful update flow: checking → available → download → extract → ready → install → idle + case happyPath + + /// No updates available: checking (2s) → "No Updates Available" (3s) → idle + case notFound + + /// Error during check: checking (2s) → error with retry callback + case error + + /// Slower download for testing progress UI: checking → available → download (20 steps, ~10s) → extract → install + case slowDownload + + /// Initial permission request flow: shows permission dialog → proceeds with happy path if accepted + case permissionRequest + + /// User cancels during download: checking → available → download (5 steps) → cancels → idle + case cancelDuringDownload + + /// User cancels while checking: checking (1s) → cancels → idle + case cancelDuringChecking + + func simulate(with viewModel: UpdateViewModel) { + switch self { + case .happyPath: + simulateHappyPath(viewModel) + case .notFound: + simulateNotFound(viewModel) + case .error: + simulateError(viewModel) + case .slowDownload: + simulateSlowDownload(viewModel) + case .permissionRequest: + simulatePermissionRequest(viewModel) + case .cancelDuringDownload: + simulateCancelDuringDownload(viewModel) + case .cancelDuringChecking: + simulateCancelDuringChecking(viewModel) + } + } + + private func simulateHappyPath(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { choice in + if choice == .install { + simulateDownload(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + } + + private func simulateNotFound(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .notFound + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + viewModel.state = .idle + } + } + } + + private func simulateError(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .error(.init( + error: NSError(domain: "UpdateError", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to check for updates" + ]), + retry: { + simulateHappyPath(viewModel) + } + )) + } + } + + private func simulateSlowDownload(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { choice in + if choice == .install { + simulateSlowDownloadProgress(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + } + + private func simulateSlowDownloadProgress(_ viewModel: UpdateViewModel) { + let download = UpdateState.Downloading( + cancel: { + viewModel.state = .idle + }, + expectedLength: nil, + progress: 0 + ) + viewModel.state = .downloading(download) + + for i in 1...20 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.5) { + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 2000, + progress: UInt64(i * 100) + ) + viewModel.state = .downloading(updatedDownload) + + if i == 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + simulateExtract(viewModel) + } + } + } + } + } + + private func simulatePermissionRequest(_ viewModel: UpdateViewModel) { + let request = SPUUpdatePermissionRequest(systemProfile: []) + viewModel.state = .permissionRequest(.init( + request: request, + reply: { response in + if response.automaticUpdateChecks { + simulateHappyPath(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + + private func simulateCancelDuringDownload(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .updateAvailable(.init( + appcastItem: SUAppcastItem.empty(), + reply: { choice in + if choice == .install { + simulateDownloadThenCancel(viewModel) + } else { + viewModel.state = .idle + } + } + )) + } + } + + private func simulateDownloadThenCancel(_ viewModel: UpdateViewModel) { + let download = UpdateState.Downloading( + cancel: { + viewModel.state = .idle + }, + expectedLength: nil, + progress: 0 + ) + viewModel.state = .downloading(download) + + for i in 1...5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 1000, + progress: UInt64(i * 100) + ) + viewModel.state = .downloading(updatedDownload) + + if i == 5 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + viewModel.state = .idle + } + } + } + } + } + + private func simulateCancelDuringChecking(_ viewModel: UpdateViewModel) { + viewModel.state = .checking(.init(cancel: { + viewModel.state = .idle + })) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + viewModel.state = .idle + } + } + + private func simulateDownload(_ viewModel: UpdateViewModel) { + let download = UpdateState.Downloading( + cancel: { + viewModel.state = .idle + }, + expectedLength: nil, + progress: 0 + ) + viewModel.state = .downloading(download) + + for i in 1...10 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { + let updatedDownload = UpdateState.Downloading( + cancel: download.cancel, + expectedLength: 1000, + progress: UInt64(i * 100) + ) + viewModel.state = .downloading(updatedDownload) + + if i == 10 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + simulateExtract(viewModel) + } + } + } + } + } + + private func simulateExtract(_ viewModel: UpdateViewModel) { + viewModel.state = .extracting(.init(progress: 0.0)) + + for j in 1...5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { + viewModel.state = .extracting(.init(progress: Double(j) / 5.0)) + + if j == 5 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + viewModel.state = .readyToInstall(.init( + reply: { choice in + if choice == .install { + viewModel.state = .installing + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .idle + } + } else { + viewModel.state = .idle + } + } + )) + } + } + } + } + } +} From 95a9e6340134cafc084fe560b8f2c94e8e7baac6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:13:34 -0700 Subject: [PATCH 050/702] macos: not found state dismisses on click, after 5s --- .../Sources/Features/Update/UpdatePill.swift | 18 +++++++++- .../Features/Update/UpdateViewModel.swift | 33 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index dda9ad607..1dc29e250 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -15,13 +15,29 @@ struct UpdatePill: View { UpdatePopoverView(model: model) } .transition(.opacity.combined(with: .scale(scale: 0.95))) + .onChange(of: model.state) { newState in + if case .notFound = newState { + Task { + try? await Task.sleep(for: .seconds(5)) + if case .notFound = model.state { + model.state = .idle + } + } + } + } } } /// The pill-shaped button view that displays the update badge and text @ViewBuilder private var pillButton: some View { - Button(action: { showPopover.toggle() }) { + Button(action: { + if case .notFound = model.state { + model.state = .idle + } else { + showPopover.toggle() + } + }) { HStack(spacing: 6) { UpdateBadge(model: model) .frame(width: 14, height: 14) diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 57e438bcd..05f7eef9a 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -87,6 +87,8 @@ class UpdateViewModel: ObservableObject { return .accentColor case .readyToInstall: return Color(nsColor: NSColor.systemGreen.blended(withFraction: 0.3, of: .black) ?? .systemGreen) + case .notFound: + return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue) case .error: return .orange.opacity(0.2) default: @@ -99,6 +101,8 @@ class UpdateViewModel: ObservableObject { switch state { case .updateAvailable, .readyToInstall: return .white + case .notFound: + return .white case .error: return .orange default: @@ -107,7 +111,7 @@ class UpdateViewModel: ObservableObject { } } -enum UpdateState { +enum UpdateState: Equatable { case idle case permissionRequest(PermissionRequest) case checking(Checking) @@ -124,6 +128,33 @@ enum UpdateState { return false } + static func == (lhs: UpdateState, rhs: UpdateState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle): + return true + case (.permissionRequest, .permissionRequest): + return true + case (.checking, .checking): + return true + case (.updateAvailable(let lUpdate), .updateAvailable(let rUpdate)): + return lUpdate.appcastItem.displayVersionString == rUpdate.appcastItem.displayVersionString + case (.notFound, .notFound): + return true + case (.error(let lErr), .error(let rErr)): + return lErr.error.localizedDescription == rErr.error.localizedDescription + case (.downloading(let lDown), .downloading(let rDown)): + return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength + case (.extracting(let lExt), .extracting(let rExt)): + return lExt.progress == rExt.progress + case (.readyToInstall, .readyToInstall): + return true + case (.installing, .installing): + return true + default: + return false + } + } + struct PermissionRequest { let request: SPUUpdatePermissionRequest let reply: @Sendable (SUUpdatePermissionResponse) -> Void From 9e17255ca9f097ebcccb0025ce32c798360f31a0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:16:07 -0700 Subject: [PATCH 051/702] macos: "OK" should dismiss error --- macos/Sources/App/macOS/AppDelegate.swift | 2 +- macos/Sources/Features/Update/UpdateDriver.swift | 4 +++- macos/Sources/Features/Update/UpdatePopoverView.swift | 1 + macos/Sources/Features/Update/UpdateSimulator.swift | 3 +++ macos/Sources/Features/Update/UpdateViewModel.swift | 1 + 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a9f72b58b..9e1425062 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1008,7 +1008,7 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - UpdateSimulator.notFound.simulate(with: updateViewModel) + UpdateSimulator.error.simulate(with: updateViewModel) } diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 00f74e9ed..6627559e8 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -38,7 +38,9 @@ class UpdateDriver: NSObject, SPUUserDriver { } func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { - viewModel.state = .error(.init(error: error, retry: retryHandler)) + viewModel.state = .error(.init(error: error, retry: retryHandler, dismiss: { [weak viewModel] in + viewModel?.state = .idle + })) } func showDownloadInitiated(cancellation: @escaping () -> Void) { diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 39c4ac5c9..cbe517f74 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -336,6 +336,7 @@ fileprivate struct UpdateErrorView: View { HStack(spacing: 8) { Button("OK") { + error.dismiss() dismiss() } .keyboardShortcut(.cancelAction) diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift index 0cf2d221b..96fab4835 100644 --- a/macos/Sources/Features/Update/UpdateSimulator.swift +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -92,6 +92,9 @@ enum UpdateSimulator { ]), retry: { simulateHappyPath(viewModel) + }, + dismiss: { + viewModel.state = .idle } )) } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 05f7eef9a..f0b779d60 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -172,6 +172,7 @@ enum UpdateState: Equatable { struct Error { let error: any Swift.Error let retry: () -> Void + let dismiss: () -> Void } struct Downloading { From b4ab1cc1edd273577449c9ced8f713c4293134b7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:21:26 -0700 Subject: [PATCH 052/702] macos: clean up the permission request --- macos/Sources/App/macOS/AppDelegate.swift | 2 +- macos/Sources/Features/Update/UpdatePopoverView.swift | 2 +- macos/Sources/Features/Update/UpdateViewModel.swift | 10 ++++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 9e1425062..62c5c0316 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1008,7 +1008,7 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - UpdateSimulator.error.simulate(with: updateViewModel) + UpdateSimulator.permissionRequest.simulate(with: updateViewModel) } diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index cbe517f74..7f1886d60 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -62,7 +62,7 @@ fileprivate struct PermissionRequestView: View { Text("Enable automatic updates?") .font(.system(size: 13, weight: .semibold)) - Text("Ghostty can automatically check for and download updates in the background.") + Text("Ghostty can automatically check for updates in the background.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index f0b779d60..674888bb5 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -13,7 +13,7 @@ class UpdateViewModel: ObservableObject { case .idle: return "" case .permissionRequest: - return "Update Permission" + return "Enable Automatic Updates?" case .checking: return "Checking for Updates…" case .updateAvailable(let update): @@ -67,7 +67,9 @@ class UpdateViewModel: ObservableObject { switch state { case .idle: return .secondary - case .permissionRequest, .checking: + case .permissionRequest: + return .white + case .checking: return .secondary case .updateAvailable, .readyToInstall: return .accentColor @@ -83,6 +85,8 @@ class UpdateViewModel: ObservableObject { /// The background color for the update pill. var backgroundColor: Color { switch state { + case .permissionRequest: + return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue) case .updateAvailable: return .accentColor case .readyToInstall: @@ -99,6 +103,8 @@ class UpdateViewModel: ObservableObject { /// The foreground (text) color for the update pill. var foregroundColor: Color { switch state { + case .permissionRequest: + return .white case .updateAvailable, .readyToInstall: return .white case .notFound: From bce49a08438b39bbc699348d8308e724f6334f75 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:29:14 -0700 Subject: [PATCH 053/702] macos: hook up our new update controller --- macos/Sources/App/macOS/AppDelegate.swift | 31 ++++------- .../Features/Update/UpdateController.swift | 55 +++++++++++++++++++ .../Features/Update/UpdateDriver.swift | 20 +++++-- 3 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 macos/Sources/Features/Update/UpdateController.swift diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 62c5c0316..216373e7e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -99,11 +99,10 @@ class AppDelegate: NSObject, ) /// Manages updates - let updaterController: SPUStandardUpdaterController - let updaterDelegate: UpdaterDelegate = UpdaterDelegate() - - /// Update view model for UI display - @Published private(set) var updateViewModel = UpdateViewModel() + let updateController = UpdateController() + var updateViewModel: UpdateViewModel { + updateController.viewModel + } /// The elapsed time since the process was started var timeSinceLaunch: TimeInterval { @@ -130,15 +129,6 @@ class AppDelegate: NSObject, } override init() { - updaterController = SPUStandardUpdaterController( - // Important: we must not start the updater here because we need to read our configuration - // first to determine whether we're automatically checking, downloading, etc. The updater - // is started later in applicationDidFinishLaunching - startingUpdater: false, - updaterDelegate: updaterDelegate, - userDriverDelegate: nil - ) - super.init() ghostty.delegate = self @@ -183,7 +173,7 @@ class AppDelegate: NSObject, ghosttyConfigDidChange(config: ghostty.config) // Start our update checker. - updaterController.startUpdater() + updateController.startUpdater() // Register our service provider. This must happen after everything is initialized. NSApp.servicesProvider = ServiceProvider() @@ -810,12 +800,12 @@ class AppDelegate: NSObject, // defined by our "auto-update" configuration (if set) or fall back to Sparkle // user-based defaults. if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false { - updaterController.updater.automaticallyChecksForUpdates = false - updaterController.updater.automaticallyDownloadsUpdates = false + updateController.updater.automaticallyChecksForUpdates = false + updateController.updater.automaticallyDownloadsUpdates = false } else if let autoUpdate = config.autoUpdate { - updaterController.updater.automaticallyChecksForUpdates = + updateController.updater.automaticallyChecksForUpdates = autoUpdate == .check || autoUpdate == .download - updaterController.updater.automaticallyDownloadsUpdates = + updateController.updater.automaticallyDownloadsUpdates = autoUpdate == .download } @@ -1008,7 +998,8 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - UpdateSimulator.permissionRequest.simulate(with: updateViewModel) + updateController.checkForUpdates() + //UpdateSimulator.permissionRequest.simulate(with: updateViewModel) } diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift new file mode 100644 index 000000000..47e6c8def --- /dev/null +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -0,0 +1,55 @@ +import Sparkle +import Cocoa + +/// Standard controller for managing Sparkle updates in Ghostty. +/// +/// This controller wraps SPUStandardUpdaterController to provide a simpler interface +/// for managing updates with Ghostty's custom driver and delegate. It handles +/// initialization, starting the updater, and provides the check for updates action. +class UpdateController { + private(set) var updater: SPUUpdater + private let userDriver: UpdateDriver + private let updaterDelegate = UpdaterDelegate() + + var viewModel: UpdateViewModel { + userDriver.viewModel + } + + /// Initialize a new update controller. + init() { + let hostBundle = Bundle.main + self.userDriver = UpdateDriver(viewModel: .init()) + self.updater = SPUUpdater( + hostBundle: hostBundle, + applicationBundle: hostBundle, + userDriver: userDriver, + delegate: updaterDelegate + ) + } + + /// Start the updater. + /// + /// This must be called before the updater can check for updates. If starting fails, + /// an error alert will be shown after a short delay. + func startUpdater() { + try? updater.start() + } + + /// Check for updates. + /// + /// This is typically connected to a menu item action. + @objc func checkForUpdates() { + updater.checkForUpdates() + } + + /// Validate the check for updates menu item. + /// + /// - Parameter item: The menu item to validate + /// - Returns: Whether the menu item should be enabled + func validateMenuItem(_ item: NSMenuItem) -> Bool { + if item.action == #selector(checkForUpdates) { + return updater.canCheckForUpdates + } + return true + } +} diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 6627559e8..70f9341a6 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -1,13 +1,12 @@ +import Cocoa import Sparkle /// Implement the SPUUserDriver to modify our UpdateViewModel for custom presentation. class UpdateDriver: NSObject, SPUUserDriver { let viewModel: UpdateViewModel - let retryHandler: () -> Void - init(viewModel: UpdateViewModel, retryHandler: @escaping () -> Void) { + init(viewModel: UpdateViewModel) { self.viewModel = viewModel - self.retryHandler = retryHandler super.init() } @@ -38,9 +37,18 @@ class UpdateDriver: NSObject, SPUUserDriver { } func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { - viewModel.state = .error(.init(error: error, retry: retryHandler, dismiss: { [weak viewModel] in - viewModel?.state = .idle - })) + viewModel.state = .error(.init( + error: error, + retry: { + guard let delegate = NSApp.delegate as? AppDelegate else { + return + } + + // TODO fill this in + }, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + })) } func showDownloadInitiated(cancellation: @escaping () -> Void) { From abab6899f93acc461f84a08cd333a6c93069dab1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:45:46 -0700 Subject: [PATCH 054/702] macos: better update descriptions --- .../Features/Update/UpdatePopoverView.swift | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 7f1886d60..ae1dc9c28 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -124,6 +124,8 @@ fileprivate struct UpdateAvailableView: View { let update: UpdateState.UpdateAvailable let dismiss: DismissAction + private let labelWidth: CGFloat = 60 + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { @@ -135,11 +137,32 @@ fileprivate struct UpdateAvailableView: View { HStack(spacing: 6) { Text("Version:") .foregroundColor(.secondary) - .frame(width: 50, alignment: .trailing) + .frame(width: labelWidth, alignment: .trailing) Text(update.appcastItem.displayVersionString) } .font(.system(size: 11)) + + if update.appcastItem.contentLength > 0 { + HStack(spacing: 6) { + Text("Size:") + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + Text(ByteCountFormatter.string(fromByteCount: Int64(update.appcastItem.contentLength), countStyle: .file)) + } + .font(.system(size: 11)) + } + + if let date = update.appcastItem.date { + HStack(spacing: 6) { + Text("Released:") + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + Text(date.formatted(date: .abbreviated, time: .omitted)) + } + .font(.system(size: 11)) + } } + .textSelection(.enabled) } HStack(spacing: 8) { From 49eb65df77c16baaf74e78fd99373266b15e5b98 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 21:51:21 -0700 Subject: [PATCH 055/702] macos: show release notes link --- .../Features/Update/UpdatePopoverView.swift | 22 +++ .../Features/Update/UpdateViewModel.swift | 70 ++++++++++ macos/Tests/Update/ReleaseNotesTests.swift | 130 ++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 macos/Tests/Update/ReleaseNotesTests.swift diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index ae1dc9c28..a73116ca0 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -191,6 +191,28 @@ fileprivate struct UpdateAvailableView: View { } } .padding(16) + + if let notes = update.releaseNotes { + Divider() + + Link(destination: notes.url) { + HStack { + Image(systemName: "doc.text") + .font(.system(size: 11)) + Text(notes.label) + .font(.system(size: 11, weight: .medium)) + Spacer() + Image(systemName: "arrow.up.right") + .font(.system(size: 10)) + } + .foregroundColor(.primary) + .padding(12) + .frame(maxWidth: .infinity) + .background(Color(nsColor: .controlBackgroundColor)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } } } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 674888bb5..7b6119771 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -173,6 +173,76 @@ enum UpdateState: Equatable { struct UpdateAvailable { let appcastItem: SUAppcastItem let reply: @Sendable (SPUUserUpdateChoice) -> Void + + var releaseNotes: ReleaseNotes? { + let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String + return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit) + } + } + + enum ReleaseNotes { + case commit(URL) + case compareTip(URL) + case tagged(URL) + + init?(displayVersionString: String, currentCommit: String?) { + let version = displayVersionString + + // Check for semantic version (x.y.z) + if let semver = Self.extractSemanticVersion(from: version) { + let slug = semver.replacingOccurrences(of: ".", with: "-") + if let url = URL(string: "https://ghostty.org/docs/install/release-notes/\(slug)") { + self = .tagged(url) + return + } + } + + // Fall back to git hash detection + guard let newHash = Self.extractGitHash(from: version) else { + return nil + } + + if let currentHash = currentCommit, !currentHash.isEmpty, + let url = URL(string: "https://github.com/ghostty-org/ghostty/compare/\(currentHash)...\(newHash)") { + self = .compareTip(url) + } else if let url = URL(string: "https://github.com/ghostty-org/ghostty/commit/\(newHash)") { + self = .commit(url) + } else { + return nil + } + } + + private static func extractSemanticVersion(from version: String) -> String? { + let pattern = #"^\d+\.\d+\.\d+$"# + if version.range(of: pattern, options: .regularExpression) != nil { + return version + } + return nil + } + + private static func extractGitHash(from version: String) -> String? { + let pattern = #"[0-9a-f]{7,40}"# + if let range = version.range(of: pattern, options: .regularExpression) { + return String(version[range]) + } + return nil + } + + var url: URL { + switch self { + case .commit(let url): return url + case .compareTip(let url): return url + case .tagged(let url): return url + } + } + + var label: String { + switch (self) { + case .commit: return "View GitHub Commit" + case .compareTip: return "Changes Since This Tip Release" + case .tagged: return "View Release Notes" + } + } } struct Error { diff --git a/macos/Tests/Update/ReleaseNotesTests.swift b/macos/Tests/Update/ReleaseNotesTests.swift new file mode 100644 index 000000000..b029fa6bc --- /dev/null +++ b/macos/Tests/Update/ReleaseNotesTests.swift @@ -0,0 +1,130 @@ +import Testing +import Foundation +@testable import Ghostty + +struct ReleaseNotesTests { + /// Test tagged release (semantic version) + @Test func testTaggedRelease() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "1.2.3", + currentCommit: nil + ) + + #expect(notes != nil) + if case .tagged(let url) = notes { + #expect(url.absoluteString == "https://ghostty.org/docs/install/release-notes/1-2-3") + #expect(notes?.label == "View Release Notes") + } else { + Issue.record("Expected tagged case") + } + } + + /// Test tip release comparison with current commit + @Test func testTipReleaseComparison() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-abc1234", + currentCommit: "def5678" + ) + + #expect(notes != nil) + if case .compareTip(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234") + #expect(notes?.label == "Changes Since This Tip Release") + } else { + Issue.record("Expected compareTip case") + } + } + + /// Test tip release without current commit + @Test func testTipReleaseWithoutCurrentCommit() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-abc1234", + currentCommit: nil + ) + + #expect(notes != nil) + if case .commit(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234") + #expect(notes?.label == "View GitHub Commit") + } else { + Issue.record("Expected commit case") + } + } + + /// Test tip release with empty current commit + @Test func testTipReleaseWithEmptyCurrentCommit() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-abc1234", + currentCommit: "" + ) + + #expect(notes != nil) + if case .commit(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234") + } else { + Issue.record("Expected commit case") + } + } + + /// Test version with full 40-character hash + @Test func testFullGitHash() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "tip-1234567890abcdef1234567890abcdef12345678", + currentCommit: nil + ) + + #expect(notes != nil) + if case .commit(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/1234567890abcdef1234567890abcdef12345678") + } else { + Issue.record("Expected commit case") + } + } + + /// Test version with no recognizable pattern + @Test func testInvalidVersion() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "unknown-version", + currentCommit: nil + ) + + #expect(notes == nil) + } + + /// Test semantic version with prerelease suffix should not match + @Test func testSemanticVersionWithSuffix() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "1.2.3-beta", + currentCommit: nil + ) + + // Should not match semantic version pattern, falls back to hash detection + #expect(notes == nil) + } + + /// Test semantic version with 4 components should not match + @Test func testSemanticVersionFourComponents() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "1.2.3.4", + currentCommit: nil + ) + + // Should not match pattern + #expect(notes == nil) + } + + /// Test version string with git hash embedded + @Test func testVersionWithEmbeddedHash() async throws { + let notes = UpdateState.ReleaseNotes( + displayVersionString: "v2024.01.15-abc1234", + currentCommit: "def5678" + ) + + #expect(notes != nil) + if case .compareTip(let url) = notes { + #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234") + } else { + Issue.record("Expected compareTip case") + } + } +} From a2fbaec6136b6c58f21565c81475386dec55bb5c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Oct 2025 22:18:33 -0700 Subject: [PATCH 056/702] macos: do not build updaters into iOS --- macos/Ghostty.xcodeproj/project.pbxproj | 7 +++++++ macos/Sources/Features/Update/UpdateDelegate.swift | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index e53f6d468..558937582 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -125,7 +125,14 @@ "Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift", "Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift", "Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift", + Features/Update/UpdateBadge.swift, + Features/Update/UpdateController.swift, Features/Update/UpdateDelegate.swift, + Features/Update/UpdateDriver.swift, + Features/Update/UpdatePill.swift, + Features/Update/UpdatePopoverView.swift, + Features/Update/UpdateSimulator.swift, + Features/Update/UpdateViewModel.swift, "Ghostty/FullscreenMode+Extension.swift", Ghostty/Ghostty.Command.swift, Ghostty/Ghostty.Error.swift, diff --git a/macos/Sources/Features/Update/UpdateDelegate.swift b/macos/Sources/Features/Update/UpdateDelegate.swift index 4699ba14a..1112c1f44 100644 --- a/macos/Sources/Features/Update/UpdateDelegate.swift +++ b/macos/Sources/Features/Update/UpdateDelegate.swift @@ -6,7 +6,7 @@ class UpdaterDelegate: NSObject, SPUUpdaterDelegate { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } - + // Sparkle supports a native concept of "channels" but it requires that // you share a single appcast file. We don't want to do that so we // do this instead. From f4b051a84c225a1aadc2fe16fcebd3fce24f2eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=92riks=20Remess?= Date: Thu, 9 Oct 2025 16:02:40 +0300 Subject: [PATCH 057/702] use app_version from build.zig.zon --- build.zig | 4 +++- src/build/Config.zig | 10 ++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/build.zig b/build.zig index 7b66af81a..d9d2272c5 100644 --- a/build.zig +++ b/build.zig @@ -2,6 +2,7 @@ const std = @import("std"); const assert = std.debug.assert; const builtin = @import("builtin"); const buildpkg = @import("src/build/main.zig"); +const appVersion = @import("build.zig.zon").version; comptime { buildpkg.requireZig("0.15.1"); @@ -15,7 +16,8 @@ pub fn build(b: *std.Build) !void { // This defines all the available build options (e.g. `-D`). If you // want to know what options are available, you can run `--help` or // you can read `src/build/Config.zig`. - const config = try buildpkg.Config.init(b); + + const config = try buildpkg.Config.init(b, appVersion); const test_filters = b.option( [][]const u8, "test-filter", diff --git a/src/build/Config.zig b/src/build/Config.zig index 643dfe928..745fc926f 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -16,13 +16,6 @@ const expandPath = @import("../os/path.zig").expand; const gtk = @import("gtk.zig"); const GitVersion = @import("GitVersion.zig"); -/// The version of the next release. -/// -/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly. -/// Until then this MUST match build.zig.zon and should always be the -/// _next_ version to release. -const app_version: std.SemanticVersion = .{ .major = 1, .minor = 2, .patch = 1 }; - /// Standard build configuration options. optimize: std.builtin.OptimizeMode, target: std.Build.ResolvedTarget, @@ -69,7 +62,7 @@ emit_unicode_table_gen: bool = false, /// Environmental properties env: std.process.EnvMap, -pub fn init(b: *std.Build) !Config { +pub fn init(b: *std.Build, appVersion: []const u8) !Config { // Setup our standard Zig target and optimize options, i.e. // `-Doptimize` and `-Dtarget`. const optimize = b.standardOptimizeOption(.{}); @@ -217,6 +210,7 @@ pub fn init(b: *std.Build) !Config { // If an explicit version is given, we always use it. try std.SemanticVersion.parse(v) else version: { + const app_version = try std.SemanticVersion.parse(appVersion); // If no explicit version is given, we try to detect it from git. const vsn = GitVersion.detect(b) catch |err| switch (err) { // If Git isn't available we just make an unknown dev version. From ea5ea5f98ed90e482e12da35c8374fdbf9a5b163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=92riks=20Remess?= Date: Thu, 9 Oct 2025 16:47:27 +0300 Subject: [PATCH 058/702] set minimum required zig version from build.zig.zon --- build.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.zig b/build.zig index d9d2272c5..205896390 100644 --- a/build.zig +++ b/build.zig @@ -3,9 +3,10 @@ const assert = std.debug.assert; const builtin = @import("builtin"); const buildpkg = @import("src/build/main.zig"); const appVersion = @import("build.zig.zon").version; +const minimumZigVersion = @import("build.zig.zon").minimum_zig_version; comptime { - buildpkg.requireZig("0.15.1"); + buildpkg.requireZig(minimumZigVersion); } pub fn build(b: *std.Build) !void { From 402c492d9467e0dfac1c28be3df6aec9fbac10dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=92riks=20Remess?= Date: Thu, 9 Oct 2025 17:07:58 +0300 Subject: [PATCH 059/702] set minimum required zig version from build.zig.zon in tests and dockerfile --- .github/workflows/test.yml | 8 ++++---- src/build/docker/debian/Dockerfile | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f78855290..6df4975b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -508,9 +508,9 @@ jobs: - name: Install zig shell: pwsh run: | - # Get the zig version from build.zig so that it only needs to be updated - $fileContent = Get-Content -Path "build.zig" -Raw - $pattern = 'buildpkg\.requireZig\("(.*?)"\);' + # Get the zig version from build.zig.zon so that it only needs to be updated + $fileContent = Get-Content -Path "build.zig.zon" -Raw + $pattern = 'minimum_zig_version\s*=\s*"([^"]+)"' $zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value $version = "zig-x86_64-windows-$zigVersion" Write-Output $version @@ -575,7 +575,7 @@ jobs: - name: Get required Zig version id: zig run: | - echo "version=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig)" >> $GITHUB_OUTPUT + echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT - name: Setup Cache uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 diff --git a/src/build/docker/debian/Dockerfile b/src/build/docker/debian/Dockerfile index 815d395cd..ffeef3d6a 100644 --- a/src/build/docker/debian/Dockerfile +++ b/src/build/docker/debian/Dockerfile @@ -24,12 +24,12 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \ WORKDIR /src -COPY ./build.zig /src +COPY ./build.zig ./build.zig.zon /src/ # Install zig # https://ziglang.org/download/ -RUN export ZIG_VERSION=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$(uname -m)-linux-$ZIG_VERSION.tar.xz" && \ +RUN export ZIG_VERSION=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$(uname -m)-linux-$ZIG_VERSION.tar.xz" && \ tar -xf /tmp/zig.tar.xz -C /opt && \ rm /tmp/zig.tar.xz && \ ln -s "/opt/zig-$(uname -m)-linux-$ZIG_VERSION/zig" /usr/local/bin/zig @@ -41,4 +41,3 @@ RUN zig build \ -Dcpu=baseline RUN ./zig-out/bin/ghostty +version - From bbf875216f1d771daeb4fcc28a1a8c19fe67b43e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Oct 2025 08:51:26 -0700 Subject: [PATCH 060/702] macos: fix driver for retry to trigger update check again --- .../Features/Update/UpdateDriver.swift | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 70f9341a6..5ff29ef75 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -10,7 +10,8 @@ class UpdateDriver: NSObject, SPUUserDriver { super.init() } - func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { + func show(_ request: SPUUpdatePermissionRequest, + reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { viewModel.state = .permissionRequest(.init(request: request, reply: reply)) } @@ -18,7 +19,9 @@ class UpdateDriver: NSObject, SPUUserDriver { viewModel.state = .checking(.init(cancel: cancellation)) } - func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { + func showUpdateFound(with appcastItem: SUAppcastItem, + state: SPUUserUpdateState, + reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply)) } @@ -31,20 +34,22 @@ class UpdateDriver: NSObject, SPUUserDriver { // We don't do anything with release notes. See `showUpdateReleaseNotes` } - func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) { + func showUpdateNotFoundWithError(_ error: any Error, + acknowledgement: @escaping () -> Void) { viewModel.state = .notFound - // TODO: Do we need to acknowledge? + acknowledgement() } - func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { + func showUpdaterError(_ error: any Error, + acknowledgement: @escaping () -> Void) { viewModel.state = .error(.init( error: error, - retry: { - guard let delegate = NSApp.delegate as? AppDelegate else { - return + retry: { [weak viewModel] in + viewModel?.state = .idle + DispatchQueue.main.async { + guard let delegate = NSApp.delegate as? AppDelegate else { return } + delegate.checkForUpdates(self) } - - // TODO fill this in }, dismiss: { [weak viewModel] in viewModel?.state = .idle From f2e5b8fb2dd0de1136c208654a6100dfdbd3187c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Oct 2025 08:57:48 -0700 Subject: [PATCH 061/702] macos: setup the standard sparkle driver for no-window scenario If there are no windows, we use the standard sparkle driver to drive the standard window-based update UI. --- macos/Sources/Features/Update/UpdateController.swift | 4 +++- macos/Sources/Features/Update/UpdateDriver.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift index 47e6c8def..8dc24698b 100644 --- a/macos/Sources/Features/Update/UpdateController.swift +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -18,7 +18,9 @@ class UpdateController { /// Initialize a new update controller. init() { let hostBundle = Bundle.main - self.userDriver = UpdateDriver(viewModel: .init()) + self.userDriver = UpdateDriver( + viewModel: .init(), + hostBundle: hostBundle) self.updater = SPUUpdater( hostBundle: hostBundle, applicationBundle: hostBundle, diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 5ff29ef75..80064854c 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -4,9 +4,11 @@ import Sparkle /// Implement the SPUUserDriver to modify our UpdateViewModel for custom presentation. class UpdateDriver: NSObject, SPUUserDriver { let viewModel: UpdateViewModel + let standard: SPUStandardUserDriver - init(viewModel: UpdateViewModel) { + init(viewModel: UpdateViewModel, hostBundle: Bundle) { self.viewModel = viewModel + self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) super.init() } From 36b3c1fa47a2c300a60f1473f32d6983119f6421 Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Thu, 9 Oct 2025 12:36:17 -0700 Subject: [PATCH 062/702] deps: update z2d to v0.9.0 Release notes at: https://github.com/vancluever/z2d/blob/v0.9.0/CHANGELOG.md This release brings our Zig 0.15.x branch into main, now that Ghostty is on it too. Additionally, this adds major speedups to the default path (filling with a solid color using the default operator). --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 71e7cd1b4..8fe8daefb 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -21,8 +21,8 @@ }, .z2d = .{ // vancluever/z2d - .url = "https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz", - .hash = "z2d-0.8.2-pre-j5P_HlVRFgCsBTQ3EgUoKbYHx5JMnyH1mHsOSPiafnef", + .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", .lazy = true, }, .zig_objc = .{ diff --git a/build.zig.zon.json b/build.zig.zon.json index b587ee5ef..c62a0ed85 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -139,10 +139,10 @@ "url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz", "hash": "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM=" }, - "z2d-0.8.2-pre-j5P_HlVRFgCsBTQ3EgUoKbYHx5JMnyH1mHsOSPiafnef": { + "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": { "name": "z2d", - "url": "https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz", - "hash": "sha256-5/qRZAIh1U42v7jql9W0jr2zzQZtu39DxJPLVrSybJg=" + "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + "hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=" }, "zf-0.10.3-OIRy8euIAACn1wj5gnZbqxbAdAKI5JGMeex8zrdPqORg": { "name": "zf", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index dd59e709a..d699b454b 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -307,11 +307,11 @@ in }; } { - name = "z2d-0.8.2-pre-j5P_HlVRFgCsBTQ3EgUoKbYHx5JMnyH1mHsOSPiafnef"; + name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T"; path = fetchZigArtifact { name = "z2d"; - url = "https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz"; - hash = "sha256-5/qRZAIh1U42v7jql9W0jr2zzQZtu39DxJPLVrSybJg="; + url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz"; + hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 9db796546..d8a390c40 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -26,7 +26,6 @@ https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.ta https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz -https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz @@ -34,3 +33,4 @@ https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90 https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.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 eb8f57028..3f573456a 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -169,9 +169,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/z2d-a1237f6881d99b75abd8a20a934e62e34b44a005.tar.gz", - "dest": "vendor/p/z2d-0.8.2-pre-j5P_HlVRFgCsBTQ3EgUoKbYHx5JMnyH1mHsOSPiafnef", - "sha256": "e7fa91640221d54e36bfb8ea97d5b48ebdb3cd066dbb7f43c493cb56b4b26c98" + "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + "dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", + "sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10" }, { "type": "archive", From f124bb4975efaad430ff09aa4243075824cab359 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Oct 2025 17:08:21 -0700 Subject: [PATCH 063/702] macos: Fallback to standard driver when no unobtrusive targets exist --- .../Window Styles/TerminalWindow.swift | 14 +++ .../Features/Update/UpdateDriver.swift | 95 ++++++++++++++++++- .../Features/Update/UpdateViewModel.swift | 17 ++++ 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 4737bacaf..8ffdc3e35 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -5,6 +5,12 @@ import GhosttyKit /// The base class for all standalone, "normal" terminal windows. This sets the basic /// style and configuration of the window based on the app configuration. class TerminalWindow: NSWindow { + /// Posted when a terminal window awakes from nib. + static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake") + + /// Posted when a terminal window will close + static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose") + /// This is the key in UserDefaults to use for the default `level` value. This is /// used by the manual float on top menu item feature. static let defaultLevelKey: String = "TerminalDefaultLevel" @@ -45,6 +51,9 @@ class TerminalWindow: NSWindow { } override func awakeFromNib() { + // Notify that this terminal window has loaded + NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then // tabs restore as separate windows. @@ -124,6 +133,11 @@ class TerminalWindow: NSWindow { // still become key/main and receive events. override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } + + override func close() { + NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) + super.close() + } override func becomeKey() { super.becomeKey() diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 80064854c..cd1d051e2 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -10,21 +10,56 @@ class UpdateDriver: NSObject, SPUUserDriver { self.viewModel = viewModel self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTerminalWindowWillClose), + name: TerminalWindow.terminalWillCloseNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func handleTerminalWindowWillClose() { + // If we lost the ability to show unobtrusive states, cancel whatever + // update state we're in. This will allow the manual `check for updates` + // call to initialize the standard driver. + // + // We have to do this after a short delay so that the window can fully + // close. + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in + guard let self else { return } + guard !hasUnobtrusiveTarget else { return } + viewModel.state.cancel() + viewModel.state = .idle + } } func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { viewModel.state = .permissionRequest(.init(request: request, reply: reply)) + if !hasUnobtrusiveTarget { + standard.show(request, reply: reply) + } } func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { viewModel.state = .checking(.init(cancel: cancellation)) + + if !hasUnobtrusiveTarget { + standard.showUserInitiatedUpdateCheck(cancellation: cancellation) + } } func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply)) + if !hasUnobtrusiveTarget { + standard.showUpdateFound(with: appcastItem, state: state, reply: reply) + } } func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { @@ -39,7 +74,12 @@ class UpdateDriver: NSObject, SPUUserDriver { func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) { viewModel.state = .notFound - acknowledgement() + + if !hasUnobtrusiveTarget { + standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) + } else { + acknowledgement() + } } func showUpdaterError(_ error: any Error, @@ -56,6 +96,12 @@ class UpdateDriver: NSObject, SPUUserDriver { dismiss: { [weak viewModel] in viewModel?.state = .idle })) + + if !hasUnobtrusiveTarget { + standard.showUpdaterError(error, acknowledgement: acknowledgement) + } else { + acknowledgement() + } } func showDownloadInitiated(cancellation: @escaping () -> Void) { @@ -63,6 +109,10 @@ class UpdateDriver: NSObject, SPUUserDriver { cancel: cancellation, expectedLength: nil, progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadInitiated(cancellation: cancellation) + } } func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { @@ -74,6 +124,10 @@ class UpdateDriver: NSObject, SPUUserDriver { cancel: downloading.cancel, expectedLength: expectedContentLength, progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength) + } } func showDownloadDidReceiveData(ofLength length: UInt64) { @@ -85,36 +139,67 @@ class UpdateDriver: NSObject, SPUUserDriver { cancel: downloading.cancel, expectedLength: downloading.expectedLength, progress: downloading.progress + length)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidReceiveData(ofLength: length) + } } func showDownloadDidStartExtractingUpdate() { viewModel.state = .extracting(.init(progress: 0)) + + if !hasUnobtrusiveTarget { + standard.showDownloadDidStartExtractingUpdate() + } } func showExtractionReceivedProgress(_ progress: Double) { viewModel.state = .extracting(.init(progress: progress)) + + if !hasUnobtrusiveTarget { + standard.showExtractionReceivedProgress(progress) + } } func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { viewModel.state = .readyToInstall(.init(reply: reply)) + + if !hasUnobtrusiveTarget { + standard.showReady(toInstallAndRelaunch: reply) + } } func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { viewModel.state = .installing + + if !hasUnobtrusiveTarget { + standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication) + } } func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { - // We don't do anything here. + standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement) viewModel.state = .idle } func showUpdateInFocus() { - // We don't currently implement this because our update state is - // shown in a terminal window. We may want to implement this at some - // point to handle the case that no windows are open, though. + if !hasUnobtrusiveTarget { + standard.showUpdateInFocus() + } } func dismissUpdateInstallation() { viewModel.state = .idle + standard.dismissUpdateInstallation() + } + + // MARK: No-Window Fallback + + /// True if there is a target that can render our unobtrusive update checker. + var hasUnobtrusiveTarget: Bool { + NSApp.windows.contains { window in + window is TerminalWindow && + window.isVisible + } } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 7b6119771..0678997d7 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -134,6 +134,23 @@ enum UpdateState: Equatable { return false } + func cancel() { + switch self { + case .checking(let checking): + checking.cancel() + case .updateAvailable(let available): + available.reply(.dismiss) + case .downloading(let downloading): + downloading.cancel() + case .readyToInstall(let ready): + ready.reply(.dismiss) + case .error(let err): + err.dismiss() + default: + break + } + } + static func == (lhs: UpdateState, rhs: UpdateState) -> Bool { switch (lhs, rhs) { case (.idle, .idle): From ce47a85bf7fc5550cc6083583bf6e2549f1824d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=92riks=20Remess?= Date: Fri, 10 Oct 2025 14:40:42 +0300 Subject: [PATCH 064/702] gtk4-layer-shell: version from build.zig.zon --- pkg/gtk4-layer-shell/build.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/gtk4-layer-shell/build.zig b/pkg/gtk4-layer-shell/build.zig index b9cf78a23..451ffe9a7 100644 --- a/pkg/gtk4-layer-shell/build.zig +++ b/pkg/gtk4-layer-shell/build.zig @@ -1,7 +1,6 @@ const std = @import("std"); -// TODO: Import this from build.zig.zon when possible -const version: std.SemanticVersion = .{ .major = 1, .minor = 1, .patch = 0 }; +const version = @import("build.zig.zon").version; const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ .preferred_link_mode = .dynamic, @@ -32,6 +31,7 @@ pub fn build(b: *std.Build) !void { } fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const lib_version = try std.SemanticVersion.parse(version); const target = options.target; const optimize = options.optimize; @@ -117,9 +117,9 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu .root = upstream.path("src"), .files = srcs, .flags = &.{ - b.fmt("-DGTK_LAYER_SHELL_MAJOR={}", .{version.major}), - b.fmt("-DGTK_LAYER_SHELL_MINOR={}", .{version.minor}), - b.fmt("-DGTK_LAYER_SHELL_MICRO={}", .{version.patch}), + b.fmt("-DGTK_LAYER_SHELL_MAJOR={}", .{lib_version.major}), + b.fmt("-DGTK_LAYER_SHELL_MINOR={}", .{lib_version.minor}), + b.fmt("-DGTK_LAYER_SHELL_MICRO={}", .{lib_version.patch}), // Zig 0.14 regression: this is required because building with // ubsan results in unknown symbols. Bundling the ubsan/compiler From 82a5c177fed33a5c2f01ee3bc862388c5722a551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=92riks=20Remess?= Date: Fri, 10 Oct 2025 14:40:56 +0300 Subject: [PATCH 065/702] gtk4-layer-shell: reenable ubsan --- pkg/gtk4-layer-shell/build.zig | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pkg/gtk4-layer-shell/build.zig b/pkg/gtk4-layer-shell/build.zig index 451ffe9a7..818b48f45 100644 --- a/pkg/gtk4-layer-shell/build.zig +++ b/pkg/gtk4-layer-shell/build.zig @@ -120,16 +120,6 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu b.fmt("-DGTK_LAYER_SHELL_MAJOR={}", .{lib_version.major}), b.fmt("-DGTK_LAYER_SHELL_MINOR={}", .{lib_version.minor}), b.fmt("-DGTK_LAYER_SHELL_MICRO={}", .{lib_version.patch}), - - // Zig 0.14 regression: this is required because building with - // ubsan results in unknown symbols. Bundling the ubsan/compiler - // RT doesn't help. I'm not sure what the root cause is but I - // suspect its related to this: - // https://github.com/ziglang/zig/issues/23052 - // - // We can remove this in the future for Zig updates and see - // if our binaries run in debug on NixOS. - "-fno-sanitize=undefined", }, }); From ba8eae027e7f5496df37bdd6acf30bf8f8b72854 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 07:18:54 -0700 Subject: [PATCH 066/702] macos: fixed width for downloading/extracting, better padding --- macos/Sources/Features/Terminal/TerminalView.swift | 4 ++-- .../Terminal/Window Styles/TerminalWindow.swift | 3 ++- macos/Sources/Features/Update/UpdatePill.swift | 13 ++++++++++++- macos/Sources/Features/Update/UpdateViewModel.swift | 13 +++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 51c4f6ddd..54cf9a02a 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -131,8 +131,8 @@ fileprivate struct UpdateOverlay: View { HStack { Spacer() UpdatePill(model: appDelegate.updateViewModel) - .padding(.bottom, 12) - .padding(.trailing, 12) + .padding(.bottom, 9) + .padding(.trailing, 9) } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 8ffdc3e35..661c89121 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -547,9 +547,10 @@ extension TerminalWindow { @ObservedObject var model: UpdateViewModel var body: some View { + // We use the same top/trailing padding so that it hugs the same. UpdatePill(model: model) .padding(.top, viewModel.accessoryTopPadding) - .padding(.trailing, 10) + .padding(.trailing, viewModel.accessoryTopPadding) } } diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index 1dc29e250..b975e81c9 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -8,6 +8,9 @@ struct UpdatePill: View { /// Whether the update popover is currently visible @State private var showPopover = false + /// The font used for the pill text + private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium) + var body: some View { if !model.state.isIdle { pillButton @@ -43,8 +46,9 @@ struct UpdatePill: View { .frame(width: 14, height: 14) Text(model.text) - .font(.system(size: 11, weight: .medium)) + .font(Font(textFont)) .lineLimit(1) + .frame(width: textWidth) } .padding(.horizontal, 8) .padding(.vertical, 4) @@ -58,4 +62,11 @@ struct UpdatePill: View { .buttonStyle(.plain) .help(model.text) } + + /// Calculated width for the text to prevent resizing during progress updates + private var textWidth: CGFloat? { + let attributes: [NSAttributedString.Key: Any] = [.font: textFont] + let size = (model.maxWidthText as NSString).size(withAttributes: attributes) + return size.width + } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 0678997d7..6341b3b42 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -37,6 +37,19 @@ class UpdateViewModel: ObservableObject { } } + /// The maximum width text for states that show progress. + /// Used to prevent the pill from resizing as percentages change. + var maxWidthText: String { + switch state { + case .downloading: + return "Downloading: 100%" + case .extracting: + return "Preparing: 100%" + default: + return text + } + } + /// The SF Symbol icon name for the current update state. /// Returns nil for idle, downloading, and extracting states. var iconName: String? { From 6993947a3a8a8c92d849fa1fa23a9e9fa4016ea8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 07:11:22 -0700 Subject: [PATCH 067/702] macOS: Make a lot of things more robust --- macos/Sources/App/macOS/AppDelegate.swift | 4 ++-- macos/Sources/Features/Update/UpdateBadge.swift | 4 ++-- .../Features/Update/UpdateController.swift | 17 +++++++++++++++-- .../Sources/Features/Update/UpdateDriver.swift | 5 +++-- macos/Sources/Features/Update/UpdatePill.swift | 13 +++++++++---- .../Features/Update/UpdatePopoverView.swift | 6 +++--- 6 files changed, 34 insertions(+), 15 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 216373e7e..cf717993a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -998,8 +998,8 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - updateController.checkForUpdates() - //UpdateSimulator.permissionRequest.simulate(with: updateViewModel) + //updateController.checkForUpdates() + UpdateSimulator.happyPath.simulate(with: updateViewModel) } diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift index fd1eb3498..afd0849be 100644 --- a/macos/Sources/Features/Update/UpdateBadge.swift +++ b/macos/Sources/Features/Update/UpdateBadge.swift @@ -17,14 +17,14 @@ struct UpdateBadge: View { switch model.state { case .downloading(let download): if let expectedLength = download.expectedLength, expectedLength > 0 { - let progress = Double(download.progress) / Double(expectedLength) + let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) ProgressRingView(progress: progress) } else { Image(systemName: "arrow.down.circle") } case .extracting(let extracting): - ProgressRingView(progress: extracting.progress) + ProgressRingView(progress: min(1, max(0, extracting.progress))) case .checking, .installing: if let iconName = model.iconName { diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift index 8dc24698b..446b82ebc 100644 --- a/macos/Sources/Features/Update/UpdateController.swift +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -32,9 +32,22 @@ class UpdateController { /// Start the updater. /// /// This must be called before the updater can check for updates. If starting fails, - /// an error alert will be shown after a short delay. + /// the error will be shown to the user. func startUpdater() { - try? updater.start() + do { + try updater.start() + } catch { + userDriver.viewModel.state = .error(.init( + error: error, + retry: { [weak self] in + self?.userDriver.viewModel.state = .idle + self?.startUpdater() + }, + dismiss: { [weak self] in + self?.userDriver.viewModel.state = .idle + } + )) + } } /// Check for updates. diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index cd1d051e2..9196d9ad9 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -86,9 +86,10 @@ class UpdateDriver: NSObject, SPUUserDriver { acknowledgement: @escaping () -> Void) { viewModel.state = .error(.init( error: error, - retry: { [weak viewModel] in + retry: { [weak self, weak viewModel] in viewModel?.state = .idle - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + guard let self else { return } guard let delegate = NSApp.delegate as? AppDelegate else { return } delegate.checkForUpdates(self) } diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index b975e81c9..3b48ac218 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -8,6 +8,9 @@ struct UpdatePill: View { /// Whether the update popover is currently visible @State private var showPopover = false + /// Task for auto-dismissing the "No Updates" state + @State private var resetTask: Task? + /// The font used for the pill text private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium) @@ -19,13 +22,15 @@ struct UpdatePill: View { } .transition(.opacity.combined(with: .scale(scale: 0.95))) .onChange(of: model.state) { newState in + resetTask?.cancel() if case .notFound = newState { - Task { + resetTask = Task { [weak model] in try? await Task.sleep(for: .seconds(5)) - if case .notFound = model.state { - model.state = .idle - } + guard !Task.isCancelled, case .notFound? = model?.state else { return } + model?.state = .idle } + } else { + resetTask = nil } } } diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index a73116ca0..7634d27de 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -228,7 +228,7 @@ fileprivate struct DownloadingView: View { .font(.system(size: 13, weight: .semibold)) if let expectedLength = download.expectedLength, expectedLength > 0 { - let progress = Double(download.progress) / Double(expectedLength) + let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) VStack(alignment: .leading, spacing: 6) { ProgressView(value: progress) Text(String(format: "%.0f%%", progress * 100)) @@ -264,8 +264,8 @@ fileprivate struct ExtractingView: View { .font(.system(size: 13, weight: .semibold)) VStack(alignment: .leading, spacing: 6) { - ProgressView(value: extracting.progress, total: 1.0) - Text(String(format: "%.0f%%", extracting.progress * 100)) + ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0) + Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100)) .font(.system(size: 11)) .foregroundColor(.secondary) } From 47f3c946401529ec7b2c90405be46ab7a4123629 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 08:34:42 -0700 Subject: [PATCH 068/702] macos: many more unit tests for update work --- AGENTS.md | 1 + macos/Tests/Update/UpdateStateTests.swift | 116 ++++++++++++++++++ macos/Tests/Update/UpdateViewModelTests.swift | 97 +++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 macos/Tests/Update/UpdateStateTests.swift create mode 100644 macos/Tests/Update/UpdateViewModelTests.swift diff --git a/AGENTS.md b/AGENTS.md index afa0fd1f2..5a885923e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,3 +29,4 @@ A file for [guiding coding agents](https://agents.md/). - Do not use `xcodebuild` - Use `zig build` to build the macOS app and any shared Zig code +- Run Xcode tests using `zig build test` diff --git a/macos/Tests/Update/UpdateStateTests.swift b/macos/Tests/Update/UpdateStateTests.swift new file mode 100644 index 000000000..5a0832a5a --- /dev/null +++ b/macos/Tests/Update/UpdateStateTests.swift @@ -0,0 +1,116 @@ +import Testing +import Foundation +import Sparkle +@testable import Ghostty + +struct UpdateStateTests { + // MARK: - Equatable Tests + + @Test func testIdleEquality() { + let state1: UpdateState = .idle + let state2: UpdateState = .idle + #expect(state1 == state2) + } + + @Test func testCheckingEquality() { + let state1: UpdateState = .checking(.init(cancel: {})) + let state2: UpdateState = .checking(.init(cancel: {})) + #expect(state1 == state2) + } + + @Test func testNotFoundEquality() { + let state1: UpdateState = .notFound + let state2: UpdateState = .notFound + #expect(state1 == state2) + } + + @Test func testInstallingEquality() { + let state1: UpdateState = .installing + let state2: UpdateState = .installing + #expect(state1 == state2) + } + + @Test func testPermissionRequestEquality() { + let request1 = SPUUpdatePermissionRequest(systemProfile: []) + let request2 = SPUUpdatePermissionRequest(systemProfile: []) + let state1: UpdateState = .permissionRequest(.init(request: request1, reply: { _ in })) + let state2: UpdateState = .permissionRequest(.init(request: request2, reply: { _ in })) + #expect(state1 == state2) + } + + @Test func testReadyToInstallEquality() { + let state1: UpdateState = .readyToInstall(.init(reply: { _ in })) + let state2: UpdateState = .readyToInstall(.init(reply: { _ in })) + #expect(state1 == state2) + } + + @Test func testDownloadingEqualityWithSameProgress() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + #expect(state1 == state2) + } + + @Test func testDownloadingInequalityWithDifferentProgress() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 600)) + #expect(state1 != state2) + } + + @Test func testDownloadingInequalityWithDifferentExpectedLength() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 2000, progress: 500)) + #expect(state1 != state2) + } + + @Test func testDownloadingEqualityWithNilExpectedLength() { + let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) + let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) + #expect(state1 == state2) + } + + @Test func testExtractingEqualityWithSameProgress() { + let state1: UpdateState = .extracting(.init(progress: 0.5)) + let state2: UpdateState = .extracting(.init(progress: 0.5)) + #expect(state1 == state2) + } + + @Test func testExtractingInequalityWithDifferentProgress() { + let state1: UpdateState = .extracting(.init(progress: 0.5)) + let state2: UpdateState = .extracting(.init(progress: 0.6)) + #expect(state1 != state2) + } + + @Test func testErrorEqualityWithSameDescription() { + let error1 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error message"]) + let error2 = NSError(domain: "Test", code: 2, userInfo: [NSLocalizedDescriptionKey: "Error message"]) + let state1: UpdateState = .error(.init(error: error1, retry: {}, dismiss: {})) + let state2: UpdateState = .error(.init(error: error2, retry: {}, dismiss: {})) + #expect(state1 == state2) + } + + @Test func testErrorInequalityWithDifferentDescription() { + let error1 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error 1"]) + let error2 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error 2"]) + let state1: UpdateState = .error(.init(error: error1, retry: {}, dismiss: {})) + let state2: UpdateState = .error(.init(error: error2, retry: {}, dismiss: {})) + #expect(state1 != state2) + } + + @Test func testDifferentStatesAreNotEqual() { + let state1: UpdateState = .idle + let state2: UpdateState = .checking(.init(cancel: {})) + #expect(state1 != state2) + } + + // MARK: - isIdle Tests + + @Test func testIsIdleTrue() { + let state: UpdateState = .idle + #expect(state.isIdle == true) + } + + @Test func testIsIdleFalse() { + let state: UpdateState = .checking(.init(cancel: {})) + #expect(state.isIdle == false) + } +} diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift new file mode 100644 index 000000000..dd88cbe83 --- /dev/null +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -0,0 +1,97 @@ +import Testing +import Foundation +import SwiftUI +import Sparkle +@testable import Ghostty + +struct UpdateViewModelTests { + // MARK: - Text Formatting Tests + + @Test func testIdleText() { + let viewModel = UpdateViewModel() + viewModel.state = .idle + #expect(viewModel.text == "") + } + + @Test func testPermissionRequestText() { + let viewModel = UpdateViewModel() + let request = SPUUpdatePermissionRequest(systemProfile: []) + viewModel.state = .permissionRequest(.init(request: request, reply: { _ in })) + #expect(viewModel.text == "Enable Automatic Updates?") + } + + @Test func testCheckingText() { + let viewModel = UpdateViewModel() + viewModel.state = .checking(.init(cancel: {})) + #expect(viewModel.text == "Checking for Updates…") + } + + @Test func testDownloadingTextWithKnownLength() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) + #expect(viewModel.text == "Downloading: 50%") + } + + @Test func testDownloadingTextWithUnknownLength() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) + #expect(viewModel.text == "Downloading…") + } + + @Test func testDownloadingTextWithZeroExpectedLength() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: 0, progress: 500)) + #expect(viewModel.text == "Downloading…") + } + + @Test func testExtractingText() { + let viewModel = UpdateViewModel() + viewModel.state = .extracting(.init(progress: 0.75)) + #expect(viewModel.text == "Preparing: 75%") + } + + @Test func testReadyToInstallText() { + let viewModel = UpdateViewModel() + viewModel.state = .readyToInstall(.init(reply: { _ in })) + #expect(viewModel.text == "Install Update") + } + + @Test func testInstallingText() { + let viewModel = UpdateViewModel() + viewModel.state = .installing + #expect(viewModel.text == "Installing…") + } + + @Test func testNotFoundText() { + let viewModel = UpdateViewModel() + viewModel.state = .notFound + #expect(viewModel.text == "No Updates Available") + } + + @Test func testErrorText() { + let viewModel = UpdateViewModel() + let error = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Network error"]) + viewModel.state = .error(.init(error: error, retry: {}, dismiss: {})) + #expect(viewModel.text == "Network error") + } + + // MARK: - Max Width Text Tests + + @Test func testMaxWidthTextForDownloading() { + let viewModel = UpdateViewModel() + viewModel.state = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 50)) + #expect(viewModel.maxWidthText == "Downloading: 100%") + } + + @Test func testMaxWidthTextForExtracting() { + let viewModel = UpdateViewModel() + viewModel.state = .extracting(.init(progress: 0.5)) + #expect(viewModel.maxWidthText == "Preparing: 100%") + } + + @Test func testMaxWidthTextForNonProgressState() { + let viewModel = UpdateViewModel() + viewModel.state = .checking(.init(cancel: {})) + #expect(viewModel.maxWidthText == viewModel.text) + } +} From 9dac88248f9761ef69ecb522ce8249b27b513d1c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 08:38:08 -0700 Subject: [PATCH 069/702] macos: ax for update info --- macos/Sources/Features/Update/UpdateBadge.swift | 10 ++++++++++ macos/Sources/Features/Update/UpdatePill.swift | 2 ++ 2 files changed, 12 insertions(+) diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift index afd0849be..a4a95f411 100644 --- a/macos/Sources/Features/Update/UpdateBadge.swift +++ b/macos/Sources/Features/Update/UpdateBadge.swift @@ -14,6 +14,12 @@ struct UpdateBadge: View { @State private var rotationAngle: Double = 0 var body: some View { + badgeContent + .accessibilityLabel(model.text) + } + + @ViewBuilder + private var badgeContent: some View { switch model.state { case .downloading(let download): if let expectedLength = download.expectedLength, expectedLength > 0 { @@ -38,11 +44,15 @@ struct UpdateBadge: View { .onDisappear { rotationAngle = 0 } + } else { + EmptyView() } default: if let iconName = model.iconName { Image(systemName: iconName) + } else { + EmptyView() } } } diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index 3b48ac218..ff4af97dd 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -53,6 +53,7 @@ struct UpdatePill: View { Text(model.text) .font(Font(textFont)) .lineLimit(1) + .truncationMode(.tail) .frame(width: textWidth) } .padding(.horizontal, 8) @@ -66,6 +67,7 @@ struct UpdatePill: View { } .buttonStyle(.plain) .help(model.text) + .accessibilityLabel(model.text) } /// Calculated width for the text to prevent resizing during progress updates From e0ee10e9025614c8a6c35768e51c1659eed11b85 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 08:44:24 -0700 Subject: [PATCH 070/702] macos: re-enable real update check --- macos/Sources/App/macOS/AppDelegate.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index cf717993a..3eff7b3e4 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -998,8 +998,8 @@ class AppDelegate: NSObject, } @IBAction func checkForUpdates(_ sender: Any?) { - //updateController.checkForUpdates() - UpdateSimulator.happyPath.simulate(with: updateViewModel) + updateController.checkForUpdates() + //UpdateSimulator.happyPath.simulate(with: updateViewModel) } From f0da093bdca19eb8087a43f6dd73ec90240b56a4 Mon Sep 17 00:00:00 2001 From: tlj Date: Fri, 10 Oct 2025 07:58:01 +0200 Subject: [PATCH 071/702] apprt/gtk: use configured title as fallback for closureComputedTitle --- src/apprt/gtk/class/tab.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index 941fa00a9..c9928be8b 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -389,8 +389,14 @@ pub const Tab = extern struct { // the terminal title if it exists, otherwise a default string. const plain = plain: { const default = "Ghostty"; + const config_title: ?[*:0]const u8 = title: { + const config = config_ orelse break :title null; + break :title config.get().title orelse null; + }; + const plain = override_ orelse terminal_ orelse + config_title orelse break :plain default; break :plain std.mem.span(plain); }; From 2bf9c777d769e08596c62c7064fd8af086d70229 Mon Sep 17 00:00:00 2001 From: Xiangbao Meng <134181853+bo2themax@users.noreply.github.com> Date: Fri, 10 Oct 2025 18:21:29 +0200 Subject: [PATCH 072/702] Fix `macos-titlebar-tabs` related issues (#9090) ### This pr fixes multiple issues related to `macos-titlebar-tabs` - [Window title clipping **on Tahoe**](https://github.com/ghostty-org/ghostty/discussions/9027#discussion-8981483) - Clipped tab bar **on Tahoe** when creating new ones in fullscreen > Sequoia doesn't seem to have this issue (at least I didn't reproduce myself) - [Title missing **on Tahoe** after dragging a tab into a separate window](https://github.com/ghostty-org/ghostty/discussions/9027#discussioncomment-14617088) - [Clipped tab bar **on Tahoe** after dragging from one tab group to another](https://github.com/ghostty-org/ghostty/discussions/9027#discussioncomment-14626078) - [Stretched tab bar after switching system appearance](https://github.com/ghostty-org/ghostty/discussions/9027#discussioncomment-14626918) ### Related issues I checked all of the open sub-issues in #2349 , most of them should be fixed in latest main branch (I didn't reproduce) - [#1692](https://github.com/ghostty-org/ghostty/issues/1692) @peteschaffner - [#1945](https://github.com/ghostty-org/ghostty/issues/1945) this one I reproduce only on Tahoe, and fixed in this pr, @injust - [#1813](https://github.com/ghostty-org/ghostty/issues/1813) @jacakira - [#1787](https://github.com/ghostty-org/ghostty/issues/1787) @roguesherlock - [#1691](https://github.com/ghostty-org/ghostty/issues/1691) ~**haven't found a solution yet**(building zig on VM is a pain**)~ > Tried commenting out `isOpaque` check in `TitlebarTabsVenturaTerminalWindow`, which would fix the issue, but I see the note there about transparency issue. > > After commenting it out, it worked fine for me with blur and opacity config, so **I might need some more background on this**. Didn't include this change yet. > > [See screenshot here](https://github.com/user-attachments/assets/eb17642d-b0de-46b2-b42a-19fb38a2c7f0) ### Minor improvements - Match window title style with `window-title-font-family` and focus state image --------- Co-authored-by: Mitchell Hashimoto --- .../TitlebarTabsTahoeTerminalWindow.swift | 74 ++++++++++++++++--- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 855d29f52..4e067eddc 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -19,9 +19,21 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // MARK: NSWindow + override var titlebarFont: NSFont? { + didSet { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.viewModel.titleFont = self.titlebarFont + } + } + } + override var title: String { didSet { - viewModel.title = title + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.viewModel.title = self.title + } } } @@ -46,17 +58,33 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Check if we have a tab bar and set it up if we have to. See the comment // on this function to learn why we need to check this here. setupTabBar() + + viewModel.isMainWindow = true } + override func resignMain() { + super.resignMain() + + viewModel.isMainWindow = false + } // This is called by macOS for native tabbing in order to add the tab bar. We hook into // this, detect the tab bar being added, and override its behavior. override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { // If this is the tab bar then we need to set it up for the titlebar guard isTabBar(childViewController) else { + // After dragging a tab into a new window, `hasTabBar` needs to be + // updated to properly review window title + viewModel.hasTabBar = false + super.addTitlebarAccessoryViewController(childViewController) return } + // When an existing tab is being dragged in to another tab group, + // system will also try to add tab bar to this window, so we want to reset observer, + // to put tab bar where we want again + tabBarObserver = nil + // Some setup needs to happen BEFORE it is added, such as layout. If // we don't do this before the call below, we'll trigger an AppKit // assertion. @@ -116,18 +144,33 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool guard tabBarObserver == nil else { return } // Find our tab bar. If it doesn't exist we don't do anything. - guard let tabBar = contentView?.rootView.firstDescendant(withClassName: "NSTabBar") else { return } + // + // In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`. + // In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes; + // in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`. + // ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205 + guard let themeFrameView = contentView?.rootView else { return } + let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) { + themeFrameView.value(forKey: "titlebarView") as? NSView + } else { + NSView?.none + } + guard let tabBar = titlebarView?.firstDescendant(withClassName: "NSTabBar") else { return } // View model updates must happen on their own ticks. - DispatchQueue.main.async { - self.viewModel.hasTabBar = true + DispatchQueue.main.async { [weak self] in + self?.viewModel.hasTabBar = true } // Find our clip view guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } guard let accessoryView = clipView.subviews[safe: 0] else { return } - guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return } + guard let titlebarView else { return } guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } + + // Make sure tabBar's height won't be stretched + guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return } + tabBar.frame.size.height = newTabButton.frame.width // The container is the view that we'll constrain our tab bar within. let container = toolbarView @@ -209,6 +252,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool case .title: let item = NSToolbarItem(itemIdentifier: .title) item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel)) + // Fix: https://github.com/ghostty-org/ghostty/discussions/9027 + item.view?.setContentCompressionResistancePriority(.required, for: .horizontal) item.visibilityPriority = .user item.isEnabled = true @@ -225,8 +270,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // MARK: SwiftUI class ViewModel: ObservableObject { + @Published var titleFont: NSFont? @Published var title: String = "👻 Ghostty" @Published var hasTabBar: Bool = false + @Published var isMainWindow: Bool = true } } @@ -249,15 +296,24 @@ extension TitlebarTabsTahoeTerminalWindow { var body: some View { if !viewModel.hasTabBar { - Text(title) - .lineLimit(1) - .truncationMode(.tail) + titleText } else { // 1x1.gif strikes again! For real: if we render a zero-sized // view here then the toolbar just disappears our view. I don't - // know. + // know. This appears fixed in 26.1 Beta but keep it safe for 26.0. Color.clear.frame(width: 1, height: 1) } } + + @ViewBuilder + var titleText: some View { + Text(title) + .font(viewModel.titleFont.flatMap(Font.init(_:))) + .foregroundStyle(viewModel.isMainWindow ? .primary : .secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .greatestFiniteMagnitude, alignment: .center) + .opacity(viewModel.hasTabBar ? 0 : 1) // hide when in fullscreen mode, where title bar will appear in the leading area under window buttons + } } } From 207eccffda019f11e87d2af7c0a98c9223b228b7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 09:30:12 -0700 Subject: [PATCH 073/702] macos: Sparkle notFound acknowledgement should only be called on dismiss (#9126) This was causing the "no update found" message to never really appear in the real world. --- macos/Sources/Features/Update/UpdateDriver.swift | 4 +--- macos/Sources/Features/Update/UpdatePill.swift | 6 ++++-- macos/Sources/Features/Update/UpdatePopoverView.swift | 6 ++++-- macos/Sources/Features/Update/UpdateSimulator.swift | 4 +++- macos/Sources/Features/Update/UpdateViewModel.swift | 8 +++++++- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 9196d9ad9..81477ef67 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -73,12 +73,10 @@ class UpdateDriver: NSObject, SPUUserDriver { func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) { - viewModel.state = .notFound + viewModel.state = .notFound(.init(acknowledgement: acknowledgement)) if !hasUnobtrusiveTarget { standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) - } else { - acknowledgement() } } diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index ff4af97dd..29d1669e1 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -23,11 +23,12 @@ struct UpdatePill: View { .transition(.opacity.combined(with: .scale(scale: 0.95))) .onChange(of: model.state) { newState in resetTask?.cancel() - if case .notFound = newState { + if case .notFound(let notFound) = newState { resetTask = Task { [weak model] in try? await Task.sleep(for: .seconds(5)) guard !Task.isCancelled, case .notFound? = model?.state else { return } model?.state = .idle + notFound.acknowledgement() } } else { resetTask = nil @@ -40,8 +41,9 @@ struct UpdatePill: View { @ViewBuilder private var pillButton: some View { Button(action: { - if case .notFound = model.state { + if case .notFound(let notFound) = model.state { model.state = .idle + notFound.acknowledgement() } else { showPopover.toggle() } diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 7634d27de..236649c21 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -41,8 +41,8 @@ struct UpdatePopoverView: View { case .installing: InstallingView() - case .notFound: - NotFoundView(dismiss: dismiss) + case .notFound(let notFound): + NotFoundView(notFound: notFound, dismiss: dismiss) case .error(let error): UpdateErrorView(error: error, dismiss: dismiss) @@ -331,6 +331,7 @@ fileprivate struct InstallingView: View { } fileprivate struct NotFoundView: View { + let notFound: UpdateState.NotFound let dismiss: DismissAction var body: some View { @@ -348,6 +349,7 @@ fileprivate struct NotFoundView: View { HStack { Spacer() Button("OK") { + notFound.acknowledgement() dismiss() } .keyboardShortcut(.defaultAction) diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift index 96fab4835..f40bbee1b 100644 --- a/macos/Sources/Features/Update/UpdateSimulator.swift +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -72,7 +72,9 @@ enum UpdateSimulator { })) DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - viewModel.state = .notFound + viewModel.state = .notFound(.init(acknowledgement: { + // Acknowledgement called when dismissed + })) DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { viewModel.state = .idle diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 6341b3b42..b0c6650c4 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -135,7 +135,7 @@ enum UpdateState: Equatable { case permissionRequest(PermissionRequest) case checking(Checking) case updateAvailable(UpdateAvailable) - case notFound + case notFound(NotFound) case error(Error) case downloading(Downloading) case extracting(Extracting) @@ -157,6 +157,8 @@ enum UpdateState: Equatable { downloading.cancel() case .readyToInstall(let ready): ready.reply(.dismiss) + case .notFound(let notFound): + notFound.acknowledgement() case .error(let err): err.dismiss() default: @@ -191,6 +193,10 @@ enum UpdateState: Equatable { } } + struct NotFound { + let acknowledgement: () -> Void + } + struct PermissionRequest { let request: SPUUpdatePermissionRequest let reply: @Sendable (SUUpdatePermissionResponse) -> Void From c8ea42b8943e2fb49aa68bdeaaf695eb493b95be Mon Sep 17 00:00:00 2001 From: Xiangbao Meng <134181853+bo2themax@users.noreply.github.com> Date: Fri, 10 Oct 2025 18:31:55 +0200 Subject: [PATCH 074/702] macOS: Fix New Tab behaviours (#9124) - Fix `macos-dock-drop-behavior = new-tab` not working, which also affects `open /path/to/directory -a Ghostty.app` - Fix 'New Tab' in dock icon not working **when Ghostty's not active** ### Issue preview with `1.2.2(12187)` https://github.com/user-attachments/assets/18068cc2-c25d-4360-97ab-cec22d5d3ff4 --- macos/Sources/App/macOS/AppDelegate.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 3eff7b3e4..6f387f4ae 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -465,7 +465,12 @@ class AppDelegate: NSObject, } switch ghostty.config.macosDockDropBehavior { - case .new_tab: _ = TerminalController.newTab(ghostty, withBaseConfig: config) + case .new_tab: + _ = TerminalController.newTab( + ghostty, + from: TerminalController.preferredParent?.window, + withBaseConfig: config + ) case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config) } @@ -1002,13 +1007,15 @@ class AppDelegate: NSObject, //UpdateSimulator.happyPath.simulate(with: updateViewModel) } - @IBAction func newWindow(_ sender: Any?) { _ = TerminalController.newWindow(ghostty) } @IBAction func newTab(_ sender: Any?) { - _ = TerminalController.newTab(ghostty) + _ = TerminalController.newTab( + ghostty, + from: TerminalController.preferredParent?.window + ) } @IBAction func closeAllWindows(_ sender: Any?) { From bac2419343e921f57ff56a6f2cd5e5d9ded8d6c2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 09:45:32 -0700 Subject: [PATCH 075/702] macos: fix unit tests for notFound change --- macos/Tests/Update/UpdateStateTests.swift | 4 ++-- macos/Tests/Update/UpdateViewModelTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Tests/Update/UpdateStateTests.swift b/macos/Tests/Update/UpdateStateTests.swift index 5a0832a5a..01819bb25 100644 --- a/macos/Tests/Update/UpdateStateTests.swift +++ b/macos/Tests/Update/UpdateStateTests.swift @@ -19,8 +19,8 @@ struct UpdateStateTests { } @Test func testNotFoundEquality() { - let state1: UpdateState = .notFound - let state2: UpdateState = .notFound + let state1: UpdateState = .notFound(.init(acknowledgement: {})) + let state2: UpdateState = .notFound(.init(acknowledgement: {})) #expect(state1 == state2) } diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift index dd88cbe83..d3b2e060b 100644 --- a/macos/Tests/Update/UpdateViewModelTests.swift +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -64,7 +64,7 @@ struct UpdateViewModelTests { @Test func testNotFoundText() { let viewModel = UpdateViewModel() - viewModel.state = .notFound + viewModel.state = .notFound(.init(acknowledgement: {})) #expect(viewModel.text == "No Updates Available") } From 7767a4577989b788baa8ebe74b9e6c9cd37e5cbb Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 10 Oct 2025 12:00:50 -0500 Subject: [PATCH 076/702] osc: do inplace decoding of cmdline passed in OSC 133;C (#9127) --- src/os/string_encoding.zig | 267 +++++++++++++++++++++++++++++++++++++ src/terminal/osc.zig | 255 ++++++++++++++++++++++++++++++++--- 2 files changed, 504 insertions(+), 18 deletions(-) create mode 100644 src/os/string_encoding.zig diff --git a/src/os/string_encoding.zig b/src/os/string_encoding.zig new file mode 100644 index 000000000..162023ad2 --- /dev/null +++ b/src/os/string_encoding.zig @@ -0,0 +1,267 @@ +const std = @import("std"); + +/// Do an in-place decode of a string that has been encoded in the same way +/// that `bash`'s `printf %q` encodes a string. This is safe because a string +/// can only get shorter after decoding. This destructively modifies the buffer +/// given to it. If an error is returned the buffer may be in an unusable state. +pub fn printfQDecode(buf: [:0]u8) error{DecodeError}![:0]const u8 { + const data: [:0]u8 = data: { + // Strip off `$''` quoting. + if (std.mem.startsWith(u8, buf, "$'")) { + if (buf.len < 3 or !std.mem.endsWith(u8, buf, "'")) return error.DecodeError; + buf[buf.len - 1] = 0; + break :data buf[2 .. buf.len - 1 :0]; + } + // Strip off `''` quoting. + if (std.mem.startsWith(u8, buf, "'")) { + if (buf.len < 2 or !std.mem.endsWith(u8, buf, "'")) return error.DecodeError; + buf[buf.len - 1] = 0; + break :data buf[1 .. buf.len - 1 :0]; + } + break :data buf; + }; + + var src: usize = 0; + var dst: usize = 0; + + while (src < data.len) { + switch (data[src]) { + else => { + data[dst] = data[src]; + src += 1; + dst += 1; + }, + '\\' => { + if (src + 1 >= data.len) return error.DecodeError; + switch (data[src + 1]) { + ' ', + '\\', + '"', + '\'', + '$', + => |c| { + data[dst] = c; + src += 2; + dst += 1; + }, + 'e' => { + data[dst] = std.ascii.control_code.esc; + src += 2; + dst += 1; + }, + 'n' => { + data[dst] = std.ascii.control_code.lf; + src += 2; + dst += 1; + }, + 'r' => { + data[dst] = std.ascii.control_code.cr; + src += 2; + dst += 1; + }, + 't' => { + data[dst] = std.ascii.control_code.ht; + src += 2; + dst += 1; + }, + 'v' => { + data[dst] = std.ascii.control_code.vt; + src += 2; + dst += 1; + }, + else => return error.DecodeError, + } + }, + } + } + + data[dst] = 0; + return data[0..dst :0]; +} + +test "printf_q 1" { + const s: [:0]const u8 = "bobr\\ kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "printf_q 2" { + const s: [:0]const u8 = "bobr\\nkurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr\nkurwa", dst); +} + +test "printf_q 3" { + const s: [:0]const u8 = "bobr\\dkurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 4" { + const s: [:0]const u8 = "bobr kurwa\\"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 5" { + const s: [:0]const u8 = "$'bobr kurwa'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "printf_q 6" { + const s: [:0]const u8 = "'bobr kurwa'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try printfQDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "printf_q 7" { + const s: [:0]const u8 = "$'bobr kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 8" { + const s: [:0]const u8 = "$'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 9" { + const s: [:0]const u8 = "'bobr kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +test "printf_q 10" { + const s: [:0]const u8 = "'"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, printfQDecode(&src)); +} + +/// Do an in-place decode of a string that has been URL percent encoded. +/// This is safe because a string can only get shorter after decoding. This +/// destructively modifies the buffer given to it. If an error is returned the +/// buffer may be in an unusable state. +pub fn urlPercentDecode(buf: [:0]u8) error{DecodeError}![:0]const u8 { + var src: usize = 0; + var dst: usize = 0; + while (src < buf.len) { + switch (buf[src]) { + else => { + buf[dst] = buf[src]; + src += 1; + dst += 1; + }, + '%' => { + if (src + 2 >= buf.len) return error.DecodeError; + switch (buf[src + 1]) { + '0'...'9', 'a'...'f', 'A'...'F' => { + switch (buf[src + 2]) { + '0'...'9', 'a'...'f', 'A'...'F' => { + buf[dst] = std.math.shl(u8, hex(buf[src + 1]), 4) | hex(buf[src + 2]); + src += 3; + dst += 1; + }, + else => return error.DecodeError, + } + }, + else => return error.DecodeError, + } + }, + } + } + buf[dst] = 0; + return buf[0..dst :0]; +} + +inline fn hex(c: u8) u4 { + switch (c) { + '0'...'9' => return @truncate(c - '0'), + 'a'...'f' => return @truncate(c - 'a' + 10), + 'A'...'F' => return @truncate(c - 'A' + 10), + else => unreachable, + } +} + +test "singles percent" { + for (0..255) |c| { + var buf_: [4]u8 = undefined; + const buf = try std.fmt.bufPrintZ(&buf_, "%{x:0>2}", .{c}); + const decoded = try urlPercentDecode(buf); + try std.testing.expectEqual(1, decoded.len); + try std.testing.expectEqual(c, decoded[0]); + } + for (0..255) |c| { + var buf_: [4]u8 = undefined; + const buf = try std.fmt.bufPrintZ(&buf_, "%{X:0>2}", .{c}); + const decoded = try urlPercentDecode(buf); + try std.testing.expectEqual(1, decoded.len); + try std.testing.expectEqual(c, decoded[0]); + } +} + +test "percent 1" { + const s: [:0]const u8 = "bobr%20kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try urlPercentDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa", dst); +} + +test "percent 2" { + const s: [:0]const u8 = "bobr%2kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 3" { + const s: [:0]const u8 = "bobr%kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 4" { + const s: [:0]const u8 = "bobr%%kurwa"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 5" { + const s: [:0]const u8 = "bobr%20kurwa%20"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + const dst = try urlPercentDecode(&src); + try std.testing.expectEqualStrings("bobr kurwa ", dst); +} + +test "percent 6" { + const s: [:0]const u8 = "bobr%20kurwa%2"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} + +test "percent 7" { + const s: [:0]const u8 = "bobr%20kurwa%"; + var src: [s.len:0]u8 = undefined; + @memcpy(&src, s); + try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); +} diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 4b0f9553c..12a6d1f5c 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -15,6 +15,7 @@ const LibEnum = @import("../lib/enum.zig").Enum; const RGB = @import("color.zig").RGB; const kitty_color = @import("kitty/color.zig"); const osc_color = @import("osc/color.zig"); +const string_encoding = @import("../os/string_encoding.zig"); pub const color = osc_color; const log = std.log.scoped(.osc); @@ -89,12 +90,7 @@ pub const Command = union(Key) { end_of_input: struct { /// The command line that the user entered. /// See: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - cmdline: ?union(enum) { - /// The command line has been encoded with bash's 'printf "%q"'. - printf_q_encoded: [:0]const u8, - /// The command line has been encoded with URL percent encoding. - percent_encoded: [:0]const u8, - } = null, + cmdline: ?[:0]const u8 = null, }, /// End of current command. @@ -1482,17 +1478,13 @@ pub const Parser = struct { } else if (mem.eql(u8, self.temp_state.key, "cmdline")) { // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers switch (self.command) { - .end_of_input => |*v| v.cmdline = .{ - .printf_q_encoded = value, - }, + .end_of_input => |*v| v.cmdline = string_encoding.printfQDecode(value) catch null, else => {}, } } else if (mem.eql(u8, self.temp_state.key, "cmdline_url")) { // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers switch (self.command) { - .end_of_input => |*v| v.cmdline = .{ - .percent_encoded = value, - }, + .end_of_input => |*v| v.cmdline = string_encoding.urlPercentDecode(value) catch null, else => {}, } } else if (mem.eql(u8, self.temp_state.key, "redraw")) { @@ -3063,7 +3055,7 @@ test "OSC 133: end_of_input" { try testing.expect(cmd == .end_of_input); } -test "OSC 133: end_of_input with cmdline" { +test "OSC 133: end_of_input with cmdline 1" { const testing = std.testing; var p: Parser = .init(); @@ -3074,11 +3066,132 @@ test "OSC 133: end_of_input with cmdline" { const cmd = p.end(null).?.*; try testing.expect(cmd == .end_of_input); try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expect(cmd.end_of_input.cmdline.? == .printf_q_encoded); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?.printf_q_encoded); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); } -test "OSC 133: end_of_input with cmdline_url" { +test "OSC 133: end_of_input with cmdline 2" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=echo bobr\\ kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 3" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=echo bobr\\nkurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr\nkurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 4" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=$'echo bobr kurwa'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 5" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline='echo bobr kurwa'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline 6" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline='echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 7" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=$'echo bobr kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 8" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=$'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 9" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline=$'"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline 10" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline="; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 1" { const testing = std.testing; var p: Parser = .init(); @@ -3089,8 +3202,114 @@ test "OSC 133: end_of_input with cmdline_url" { const cmd = p.end(null).?.*; try testing.expect(cmd == .end_of_input); try testing.expect(cmd.end_of_input.cmdline != null); - try testing.expect(cmd.end_of_input.cmdline.? == .percent_encoded); - try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?.percent_encoded); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 2" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr%20kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 3" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr%3bkurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr;kurwa", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 4" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr%3kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 5" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr%kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 6" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr%kurwa"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 7" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr kurwa%20"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline != null); + try testing.expectEqualStrings("echo bobr kurwa ", cmd.end_of_input.cmdline.?); +} + +test "OSC 133: end_of_input with cmdline_url 8" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr kurwa%2"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); +} + +test "OSC 133: end_of_input with cmdline_url 9" { + const testing = std.testing; + + var p: Parser = .init(); + + const input = "133;C;cmdline_url=echo bobr kurwa%2"; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .end_of_input); + try testing.expect(cmd.end_of_input.cmdline == null); } test "OSC: OSC 777 show desktop notification with title" { From c28104e62fe552db6846c58b75c0e0e682c660d0 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 10 Oct 2025 12:01:06 -0500 Subject: [PATCH 077/702] gtk: properly check for amount of time elapsed before notifying about command finish (#9128) --- src/apprt/gtk/class/surface.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index cc8359b7e..cc17e3470 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -838,6 +838,8 @@ pub const Surface = extern struct { if (cfg.@"notify-on-command-finish" == .unfocused and self.getFocused()) return true; } + if (value.duration.lte(cfg.@"notify-on-command-finish-after")) return true; + const action = cfg.@"notify-on-command-finish-action"; if (action.bell) self.setBellRinging(true); From cd7621167fb9be3f3d7b3f547283f71771941a86 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 10:19:33 -0700 Subject: [PATCH 078/702] macos: update accessory in the titlebar should not move the window This is annoyingly easy to trigger, just disable this. --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Terminal/Window Styles/TerminalWindow.swift | 2 +- macos/Sources/Helpers/NonDraggableHostingView.swift | 13 +++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Helpers/NonDraggableHostingView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 558937582..c2baf834d 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -167,6 +167,7 @@ Helpers/KeyboardLayout.swift, Helpers/LastWindowPosition.swift, Helpers/MetalView.swift, + Helpers/NonDraggableHostingView.swift, Helpers/PermissionRequest.swift, Helpers/Private/CGS.swift, Helpers/Private/Dock.swift, diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 661c89121..95126e188 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -108,7 +108,7 @@ class TerminalWindow: NSWindow { // Create update notification accessory if supportsUpdateAccessory { updateAccessory.layoutAttribute = .right - updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView( + updateAccessory.view = NonDraggableHostingView(rootView: UpdateAccessoryView( viewModel: viewModel, model: appDelegate.updateViewModel )) diff --git a/macos/Sources/Helpers/NonDraggableHostingView.swift b/macos/Sources/Helpers/NonDraggableHostingView.swift new file mode 100644 index 000000000..26238182f --- /dev/null +++ b/macos/Sources/Helpers/NonDraggableHostingView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +/// An NSHostingView subclass that prevents window dragging when clicking on the view. +/// +/// By default, NSHostingViews in the titlebar allow the window to be dragged when +/// clicked. This subclass overrides `mouseDownCanMoveWindow` to return false, +/// preventing the window from being dragged when the user clicks on this view. +/// +/// This is useful for titlebar accessories that contain interactive elements +/// (buttons, links, etc.) where you don't want accidental window dragging. +class NonDraggableHostingView: NSHostingView { + override var mouseDownCanMoveWindow: Bool { false } +} From ac2f040b3140f4a77353d0c5f63909b36835a337 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Oct 2025 13:40:35 -0700 Subject: [PATCH 079/702] macos: Show "Update and Restart" in the Command Palette (#9131) If an update is available, you can now trigger the full download, install, and restart from a single command palette action. This allows for a fully keyboard-driven update process. While an update is being installed, an option to cancel or skip the current update is also shown as an option, so that can also be keyboard-driven. This currently can't be bound to a keyboard action, but that may be added in the future if there's demand for it. **AI Disclosure:** Amp was used considerably. I reviewed all the code and understand it. ## Demo https://github.com/user-attachments/assets/df6307f8-9967-40d4-9a62-04feddf00ac2 --- .../Command Palette/CommandPalette.swift | 50 +++++++++- .../TerminalCommandPalette.swift | 48 +++++++++- .../Features/Terminal/TerminalView.swift | 3 +- .../Features/Update/UpdateController.swift | 34 +++++++ .../Features/Update/UpdateViewModel.swift | 92 ++++++++++++++++++- 5 files changed, 216 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 8d15cbf9a..537137fe6 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -5,7 +5,28 @@ struct CommandOption: Identifiable, Hashable { let title: String let description: String? let symbols: [String]? + let leadingIcon: String? + let badge: String? + let emphasis: Bool let action: () -> Void + + init( + title: String, + description: String? = nil, + symbols: [String]? = nil, + leadingIcon: String? = nil, + badge: String? = nil, + emphasis: Bool = false, + action: @escaping () -> Void + ) { + self.title = title + self.description = description + self.symbols = symbols + self.leadingIcon = leadingIcon + self.badge = badge + self.emphasis = emphasis + self.action = action + } static func == (lhs: CommandOption, rhs: CommandOption) -> Bool { lhs.id == rhs.id @@ -198,7 +219,7 @@ fileprivate struct CommandTable: View { } else { ScrollViewReader { proxy in ScrollView { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 4) { ForEach(Array(options.enumerated()), id: \.1.id) { index, option in CommandRow( option: option, @@ -240,15 +261,36 @@ fileprivate struct CommandRow: View { var body: some View { Button(action: action) { - HStack { + HStack(spacing: 8) { + if let icon = option.leadingIcon { + Image(systemName: icon) + .foregroundStyle(option.emphasis ? Color.accentColor : .secondary) + .font(.system(size: 14, weight: .medium)) + } + Text(option.title) + .fontWeight(option.emphasis ? .medium : .regular) + Spacer() + + if let badge = option.badge, !badge.isEmpty { + Text(badge) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + Capsule().fill(Color.accentColor.opacity(0.15)) + ) + .foregroundStyle(Color.accentColor) + } + if let symbols = option.symbols { ShortcutSymbolsView(symbols: symbols) .foregroundStyle(.secondary) } } .padding(8) + .contentShape(Rectangle()) .background( isSelected ? Color.accentColor.opacity(0.2) @@ -256,6 +298,10 @@ fileprivate struct CommandRow: View { ? Color.secondary.opacity(0.2) : Color.clear) ) + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder(Color.accentColor.opacity(option.emphasis && !isSelected ? 0.3 : 0), lineWidth: 1.5) + ) .cornerRadius(5) } .help(option.description ?? "") diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index d02828494..673f5dd78 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -11,15 +11,54 @@ struct TerminalCommandPaletteView: View { /// The configuration so we can lookup keyboard shortcuts. @ObservedObject var ghosttyConfig: Ghostty.Config + + /// The update view model for showing update commands. + var updateViewModel: UpdateViewModel? /// The callback when an action is submitted. var onAction: ((String) -> Void) // The commands available to the command palette. private var commandOptions: [CommandOption] { - guard let surface = surfaceView.surfaceModel else { return [] } + var options: [CommandOption] = [] + + // Add update command if an update is installable. This must always be the first so + // it is at the top. + if let updateViewModel, updateViewModel.state.isInstallable { + // We override the update available one only because we want to properly + // convey it'll go all the way through. + let title: String + if case .updateAvailable = updateViewModel.state { + title = "Update Ghostty and Restart" + } else { + title = updateViewModel.text + } + + options.append(CommandOption( + title: title, + description: updateViewModel.description, + leadingIcon: updateViewModel.iconName ?? "shippingbox.fill", + badge: updateViewModel.badge, + emphasis: true + ) { + (NSApp.delegate as? AppDelegate)?.updateController.installUpdate() + }) + } + + // Add cancel/skip update command if the update is installable + if let updateViewModel, updateViewModel.state.isInstallable { + options.append(CommandOption( + title: "Cancel or Skip Update", + description: "Dismiss the current update process" + ) { + updateViewModel.state.cancel() + }) + } + + // Add terminal commands + guard let surface = surfaceView.surfaceModel else { return options } do { - return try surface.commands().map { c in + let terminalCommands = try surface.commands().map { c in return CommandOption( title: c.title, description: c.description, @@ -28,9 +67,12 @@ struct TerminalCommandPaletteView: View { onAction(c.action) } } + options.append(contentsOf: terminalCommands) } catch { - return [] + return options } + + return options } var body: some View { diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 54cf9a02a..0cdff7c1f 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -108,7 +108,8 @@ struct TerminalView: View { TerminalCommandPaletteView( surfaceView: surfaceView, isPresented: $viewModel.commandPaletteIsShowing, - ghosttyConfig: ghostty.config) { action in + ghosttyConfig: ghostty.config, + updateViewModel: (NSApp.delegate as? AppDelegate)?.updateViewModel) { action in self.delegate?.performAction(action, on: surfaceView) } } diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift index 446b82ebc..aa875567c 100644 --- a/macos/Sources/Features/Update/UpdateController.swift +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -1,5 +1,6 @@ import Sparkle import Cocoa +import Combine /// Standard controller for managing Sparkle updates in Ghostty. /// @@ -10,6 +11,7 @@ class UpdateController { private(set) var updater: SPUUpdater private let userDriver: UpdateDriver private let updaterDelegate = UpdaterDelegate() + private var installCancellable: AnyCancellable? var viewModel: UpdateViewModel { userDriver.viewModel @@ -29,6 +31,10 @@ class UpdateController { ) } + deinit { + installCancellable?.cancel() + } + /// Start the updater. /// /// This must be called before the updater can check for updates. If starting fails, @@ -50,6 +56,34 @@ class UpdateController { } } + /// Force install the current update. As long as we're in some "update available" state this will + /// trigger all the steps necessary to complete the update. + func installUpdate() { + // Must be in an installable state + guard viewModel.state.isInstallable else { return } + + // If we're already force installing then do nothing. + guard installCancellable == nil else { return } + + // Setup a combine listener to listen for state changes and to always + // confirm them. If we go to a non-installable state, cancel the listener. + // The sink runs immediately with the current state, so we don't need to + // manually confirm the first state. + installCancellable = viewModel.$state.sink { [weak self] state in + guard let self else { return } + + // If we move to a non-installable state (error, idle, etc.) then we + // stop force installing. + guard state.isInstallable else { + self.installCancellable = nil + return + } + + // Continue the `yes` chain! + state.confirm() + } + } + /// Check for updates. /// /// This is typically connected to a menu item action. diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index b0c6650c4..ccb03e731 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -17,7 +17,11 @@ class UpdateViewModel: ObservableObject { case .checking: return "Checking for Updates…" case .updateAvailable(let update): - return "Update Available: \(update.appcastItem.displayVersionString)" + let version = update.appcastItem.displayVersionString + if !version.isEmpty { + return "Update Available: \(version)" + } + return "Update Available" case .downloading(let download): if let expectedLength = download.expectedLength, expectedLength > 0 { let progress = Double(download.progress) / Double(expectedLength) @@ -51,7 +55,6 @@ class UpdateViewModel: ObservableObject { } /// The SF Symbol icon name for the current update state. - /// Returns nil for idle, downloading, and extracting states. var iconName: String? { switch state { case .idle: @@ -61,9 +64,11 @@ class UpdateViewModel: ObservableObject { case .checking: return "arrow.triangle.2.circlepath" case .updateAvailable: - return "arrow.down.circle.fill" - case .downloading, .extracting: - return nil + return "shippingbox.fill" + case .downloading: + return "arrow.down.circle" + case .extracting: + return "shippingbox" case .readyToInstall: return "checkmark.circle.fill" case .installing: @@ -75,6 +80,53 @@ class UpdateViewModel: ObservableObject { } } + /// A longer description for the current update state. + /// Used in contexts like the command palette where more detail is helpful. + var description: String { + switch state { + case .idle: + return "" + case .permissionRequest: + return "Configure automatic update preferences" + case .checking: + return "Please wait while we check for available updates" + case .updateAvailable(let update): + return update.releaseNotes?.label ?? "Download and install the latest version" + case .downloading: + return "Downloading the update package" + case .extracting: + return "Extracting and preparing the update" + case .readyToInstall: + return "Update is ready to install" + case .installing: + return "Installing update and preparing to restart" + case .notFound: + return "You are running the latest version" + case .error: + return "An error occurred during the update process" + } + } + + /// A badge to display for the current update state. + /// Returns version numbers, progress percentages, or nil. + var badge: String? { + switch state { + case .updateAvailable(let update): + let version = update.appcastItem.displayVersionString + return version.isEmpty ? nil : version + case .downloading(let download): + if let expectedLength = download.expectedLength, expectedLength > 0 { + let percentage = Double(download.progress) / Double(expectedLength) * 100 + return String(format: "%.0f%%", percentage) + } + return nil + case .extracting(let extracting): + return String(format: "%.0f%%", extracting.progress * 100) + default: + return nil + } + } + /// The color to apply to the icon for the current update state. var iconColor: Color { switch state { @@ -147,6 +199,22 @@ enum UpdateState: Equatable { return false } + /// This is true if we're in a state that can be force installed. + var isInstallable: Bool { + switch (self) { + case .checking, + .updateAvailable, + .downloading, + .extracting, + .readyToInstall, + .installing: + return true + + default: + return false + } + } + func cancel() { switch self { case .checking(let checking): @@ -166,6 +234,20 @@ enum UpdateState: Equatable { } } + /// Confirms or accepts the current update state. + /// - For available updates: begins installation + /// - For ready-to-install: proceeds with installation + func confirm() { + switch self { + case .updateAvailable(let available): + available.reply(.install) + case .readyToInstall(let ready): + ready.reply(.install) + default: + break + } + } + static func == (lhs: UpdateState, rhs: UpdateState) -> Bool { switch (lhs, rhs) { case (.idle, .idle): From 854c8e6975806c86765dbc2af5222d07cf28e3d7 Mon Sep 17 00:00:00 2001 From: Bruno BELANYI Date: Fri, 10 Oct 2025 21:41:38 +0100 Subject: [PATCH 080/702] Set title as argv[0] for commands specified with `-e` (#9121) I want to see #7932 get merged, so applied the latest proposed patch. Will close if the original PR gets some traction, as I do _not_ know Zig nor this project. Co-authored-by: rhodes-b <59537185+rhodes-b@users.noreply.github.com> --- src/Surface.zig | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index b1553edff..82d6615b6 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -702,7 +702,22 @@ pub fn init( .set_title, .{ .title = title }, ); - } + } else if (command) |cmd| switch (cmd) { + // If a user specifies a command it is appropriate to set the title as argv[0] + // we know in the case of a direct command it has been supplied by the user + .direct => |cmd_str| if (cmd_str.len != 0) { + _ = try rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = cmd_str[0] }, + ); + }, + + // We won't set the title in the case the shell expands the command + // as that should typically be used to launch a shell which should + // set its own titles + .shell => {}, + }; // We are no longer the first surface app.first = false; From c5ad7563f92656ec02bd08856b46431f2e222e69 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 10 Oct 2025 15:41:58 -0500 Subject: [PATCH 081/702] gtk: better reporting for CSS parsing problems (#9129) Log messages will include the problematic CSS, simplifying debugging. Especially helpful since some of our CSS is generated at runtime so it could be difficult to examine the CSS "source". ``` info(gtk_ghostty_application): loading gtk-custom-css path=/home/ghostty/dev/ghostty/x.css warning(gtk_ghostty_application): css parsing failed at :2:3-14: gtk-css-parser-error-quark 4 No property named "border-poop" * { border-poop: 0; warning(gtk_ghostty_application): css parsing failed at :1:3-3:1: gtk-css-parser-warning-quark 1 Unterminated block at end of document * { border-poop: 0; ``` vs: ``` info(gtk_ghostty_application): loading gtk-custom-css path=/home/ghostty/dev/ghostty/x.css warning(glib): WARNING: Gtk: Theme parser error: :2:3-14: No property named "border-poop" warning(glib): WARNING: Gtk: Theme parser warning: :1:3-3:1: Unterminated block at end of document ``` --- src/apprt/gtk/class/application.zig | 107 ++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index a35cd5b3f..d75a0ef7f 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -394,6 +394,14 @@ pub const Application = extern struct { .{ .detail = "config" }, ); + _ = gtk.CssProvider.signals.parsing_error.connect( + css_provider, + *Self, + signalCssParsingError, + self, + .{}, + ); + // Trigger initial config changes self.as(gobject.Object).notifyByPspec(properties.config.impl.param_spec); @@ -812,8 +820,8 @@ pub const Application = extern struct { fn loadRuntimeCss(self: *Self) (Allocator.Error || std.Io.Writer.Error)!void { const alloc = self.allocator(); - - const config = self.private().config.get(); + const priv: *Private = self.private(); + const config = priv.config.get(); var buf: std.Io.Writer.Allocating = try .initCapacity(alloc, 2048); defer buf.deinit(); @@ -862,19 +870,15 @@ pub const Application = extern struct { , .{ .font_family = font_family }); } - // ensure that we have a sentinel - try writer.writeByte(0); + const contents = buf.written(); - const data_ = buf.written(); - const data = data_[0 .. data_.len - 1 :0]; + log.debug("runtime CSS is {d} bytes", .{contents.len}); - log.debug("runtime CSS is {d} bytes", .{data.len + 1}); + const bytes = glib.Bytes.new(contents.ptr, contents.len); + defer bytes.unref(); // Clears any previously loaded CSS from this provider - loadCssProviderFromData( - self.private().css_provider, - data, - ); + priv.css_provider.loadFromBytes(bytes); } /// Load runtime CSS for older than GTK 4.16 @@ -1013,8 +1017,8 @@ pub const Application = extern struct { } } - fn loadCustomCss(self: *Self) !void { - const priv = self.private(); + fn loadCustomCss(self: *Self) (std.fs.File.ReadError || Allocator.Error)!void { + const priv: *Private = self.private(); const alloc = self.allocator(); const display = gdk.Display.getDefault() orelse { log.warn("unable to get display", .{}); @@ -1031,7 +1035,7 @@ pub const Application = extern struct { } priv.custom_css_providers.clearRetainingCapacity(); - const config = priv.config.getMut(); + const config = priv.config.get(); for (config.@"gtk-custom-css".value.items) |p| { const path, const optional = switch (p) { .optional => |path| .{ path, true }, @@ -1048,23 +1052,42 @@ pub const Application = extern struct { }; defer file.close(); + const css_file_size_limit = 5 * 1024 * 1024; // 5MB + log.info("loading gtk-custom-css path={s}", .{path}); - const contents = try file.readToEndAlloc( + const contents = file.readToEndAlloc( alloc, - 5 * 1024 * 1024, // 5MB, - ); + css_file_size_limit, + ) catch |err| switch (err) { + error.FileTooBig => { + log.warn("gtk-custom-css file {s} was larger than {Bi}", .{ path, css_file_size_limit }); + continue; + }, + else => |e| return e, + }; defer alloc.free(contents); - const data = try alloc.dupeZ(u8, contents); - defer alloc.free(data); + const bytes = glib.Bytes.new(contents.ptr, contents.len); + defer bytes.unref(); + + const css_provider = gtk.CssProvider.new(); + errdefer css_provider.unref(); + + _ = gtk.CssProvider.signals.parsing_error.connect( + css_provider, + *Self, + signalCssParsingError, + self, + .{}, + ); + + try priv.custom_css_providers.append(alloc, css_provider); + + css_provider.loadFromBytes(bytes); - const provider = gtk.CssProvider.new(); - errdefer provider.unref(); - try priv.custom_css_providers.append(alloc, provider); - loadCssProviderFromData(provider, data); gtk.StyleContext.addProviderForDisplay( display, - provider.as(gtk.StyleProvider), + css_provider.as(gtk.StyleProvider), gtk.STYLE_PROVIDER_PRIORITY_USER, ); } @@ -1180,6 +1203,37 @@ pub const Application = extern struct { }; } + /// Log CSS parsing error + fn signalCssParsingError( + _: *gtk.CssProvider, + css_section: *gtk.CssSection, + err: *glib.Error, + _: *Self, + ) callconv(.c) void { + const location = css_section.toString(); + defer glib.free(location); + if (comptime gtk_version.atLeast(4, 16, 0)) bytes: { + const bytes = css_section.getBytes() orelse break :bytes; + var len: usize = undefined; + const ptr = bytes.getData(&len) orelse break :bytes; + const data = ptr[0..len]; + log.warn("css parsing failed at {s}: {s} {d} {s}\n{s}", .{ + location, + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "«unknown»", + data, + }); + return; + } + log.warn("css parsing failed at {s}: {s} {d} {s}", .{ + location, + glib.quarkToString(err.f_domain), + err.f_code, + err.f_message orelse "«unknown»", + }); + } + //--------------------------------------------------------------- // Libghostty Callbacks @@ -2583,8 +2637,3 @@ fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) // Abusing integers to be enums and booleans is a terrible idea, C. return if (window.isActive() != 0) 0 else -1; } - -fn loadCssProviderFromData(provider: *gtk.CssProvider, data: [:0]const u8) void { - assert(gtk_version.runtimeAtLeast(4, 12, 0)); - provider.loadFromString(data); -} From 81c982df96985c398fca41acc28a386bcb121679 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 11 Oct 2025 14:51:52 -0500 Subject: [PATCH 082/702] gtk: fix clicking on desktop notifications (#9146) Clicking on desktop notifications sent by Ghostty _should_ cause the window that sent the notification to come to the top. However, because the notification that was sent targeted the wrong surface (apprt surface vs core surface) and the window did not call `present()` on itself the window would never be brought to the surface, the correct tab would not be selected, etc. Fixes #9145 --- src/apprt/gtk/class/surface.zig | 8 +++++++- src/apprt/gtk/class/window.zig | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index cc17e3470..51e4ea7b2 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1468,6 +1468,12 @@ pub const Surface = extern struct { pub fn sendDesktopNotification(self: *Self, title: [:0]const u8, body: [:0]const u8) void { const app = Application.default(); + const priv: *Private = self.private(); + + const core_surface = priv.core_surface orelse { + log.warn("can't send notification because there is no core surface", .{}); + return; + }; const t = switch (title.len) { 0 => "Ghostty", @@ -1482,7 +1488,7 @@ pub const Surface = extern struct { defer icon.unref(); notification.setIcon(icon.as(gio.Icon)); - const pointer = glib.Variant.newUint64(@intFromPtr(self)); + const pointer = glib.Variant.newUint64(@intFromPtr(core_surface)); notification.setDefaultActionAndTargetValue( "app.present-surface", pointer, diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 8efff8729..746bcd379 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -1585,6 +1585,9 @@ pub const Window = extern struct { // Grab focus surface.grabFocus(); + + // Bring the window to the front. + self.as(gtk.Window).present(); } fn surfaceToggleFullscreen( From c7058143c76aede87ba6d51efff4cc9990719d7d Mon Sep 17 00:00:00 2001 From: Brice <59537185+rhodes-b@users.noreply.github.com> Date: Sat, 11 Oct 2025 14:52:35 -0500 Subject: [PATCH 083/702] GTK fix quick terminal autohide (#9139) This is pretty much a direct port of the previous GTK app. still inside of the `isActive` handler for a window https://github.com/ghostty-org/ghostty/blob/7e429d73d6af65a397c6264b18ab60609ae8eefe/src/apprt/gtk/Window.zig#L822-L837 Fixes: https://github.com/ghostty-org/ghostty/discussions/9137 --- src/apprt/gtk/class/window.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 746bcd379..4febebfc6 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -1015,6 +1015,15 @@ pub const Window = extern struct { _: *gobject.ParamSpec, self: *Self, ) callconv(.c) void { + // Hide quick-terminal if set to autohide + if (self.isQuickTerminal()) { + if (self.getConfig()) |cfg| { + if (cfg.get().@"quick-terminal-autohide" and self.as(gtk.Window).isActive() == 0) { + self.toggleVisibility(); + } + } + } + // Don't change urgency if we're not the active window. if (self.as(gtk.Window).isActive() == 0) return; From 6ab416376a6ba529bca55203435f61cb9ac6afbf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Oct 2025 12:54:23 -0700 Subject: [PATCH 084/702] chore: mark the nerd font tables as a generated file. --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 6bf5ceb13..fa59a364c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,4 +9,5 @@ pkg/glfw/wayland-headers/** linguist-vendored pkg/libintl/config.h linguist-generated=true pkg/libintl/libintl.h linguist-generated=true pkg/simdutf/vendor/** linguist-vendored +src/font/nerd_font_codepoint_tables.py linguist-generated=true src/terminal/res/** linguist-vendored From 7087eea1e23297fb23ffb79b04a6c119b8acfb98 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Oct 2025 13:08:09 -0700 Subject: [PATCH 085/702] chore: add nerd font attributes as generated too --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index fa59a364c..87f1eb32e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,5 +9,6 @@ pkg/glfw/wayland-headers/** linguist-vendored pkg/libintl/config.h linguist-generated=true pkg/libintl/libintl.h linguist-generated=true pkg/simdutf/vendor/** linguist-vendored +src/font/nerd_font_attributes.zig linguist-generated=true src/font/nerd_font_codepoint_tables.py linguist-generated=true src/terminal/res/** linguist-vendored From 4af93975ed69ade2fcb4a915bab7d81dcbd5ebb8 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 11 Oct 2025 13:12:13 -0700 Subject: [PATCH 086/702] font(fix): Extract and apply Nerd Font codepoint mapping table (#9142) Fixes #9076 **Before** Screenshot 2025-10-11 at 00 07 09 **After** Screenshot 2025-10-11 at 00 07 31 These screenshots show the chevrons mentioned in https://github.com/ghostty-org/ghostty/discussions/7820#discussioncomment-14617170, which should be scaled as a group but were not until this PR. The added code downloads each individual symbol font file from the Nerd Fonts github repo (making sure to get the version corresponding to the vendored `font-patcher.py`) and iterates over all of them to build the correct and complete codepoint mapping table. The table is saved to `nerd_font_codepoint_tables.py`, which `nerd_font_codegen.py` will reuse if possible instead of downloading the font files again. I'm not going to utter any famous last words or anything, but... after this, I don't think the number of remaining issues with icon scaling/alignment is _large._ --- src/font/nerd_font_attributes.zig | 1730 +--- src/font/nerd_font_codegen.py | 228 +- src/font/nerd_font_codepoint_tables.py | 10449 +++++++++++++++++++++++ 3 files changed, 10971 insertions(+), 1436 deletions(-) create mode 100644 src/font/nerd_font_codepoint_tables.py diff --git a/src/font/nerd_font_attributes.zig b/src/font/nerd_font_attributes.zig index 138108288..73fcf0bd3 100644 --- a/src/font/nerd_font_attributes.zig +++ b/src/font/nerd_font_attributes.zig @@ -310,33 +310,6 @@ pub fn getConstraint(cp: u21) ?Constraint { .pad_bottom = -0.01, .max_xy_ratio = 0.7, }, - 0xe300, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8984375000000000, - .relative_y = 0.0986328125000000, - }, - 0xe301, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8798828125000000, - .relative_y = 0.1171875000000000, - }, - 0xe302, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7646484375000000, - .relative_y = 0.2314453125000000, - }, 0xe303, => .{ .size = .fit_cover1, @@ -355,187 +328,6 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.9755859375000000, .relative_y = 0.0244140625000000, }, - 0xe305, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9960937500000000, - .relative_y = 0.0019531250000000, - }, - 0xe306, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9863281250000000, - .relative_y = 0.0097656250000000, - }, - 0xe307, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9951171875000000, - .relative_y = 0.0039062500000000, - }, - 0xe308, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9785156250000000, - .relative_y = 0.0195312500000000, - }, - 0xe309, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9736328125000000, - .relative_y = 0.0214843750000000, - }, - 0xe30a, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9648437500000000, - .relative_y = 0.0302734375000000, - }, - 0xe30b, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8437500000000000, - .relative_y = 0.1513671875000000, - }, - 0xe30c, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8027343750000000, - .relative_y = 0.1835937500000000, - }, - 0xe30d, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7753906250000000, - .relative_y = 0.1083984375000000, - }, - 0xe30e, - 0xe365, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9833984375000000, - .relative_y = 0.0166015625000000, - }, - 0xe30f, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9716796875000000, - .relative_y = 0.0263671875000000, - }, - 0xe310, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6621093750000000, - .relative_y = 0.0986328125000000, - }, - 0xe311, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6425781250000000, - .relative_y = 0.1171875000000000, - }, - 0xe312, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5322265625000000, - .relative_y = 0.2314453125000000, - }, - 0xe313, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6416015625000000, - .relative_y = 0.1181640625000000, - }, - 0xe314, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7382812500000000, - .relative_y = 0.0195312500000000, - }, - 0xe315, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6787109375000000, - .relative_y = 0.1357421875000000, - }, - 0xe316, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7480468750000000, - .relative_y = 0.0097656250000000, - }, - 0xe317, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7529296875000000, - .relative_y = 0.0048828125000000, - }, - 0xe318, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7314453125000000, - .relative_y = 0.0263671875000000, - }, 0xe319, => .{ .size = .fit_cover1, @@ -599,24 +391,6 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7363281250000000, .relative_y = 0.0986328125000000, }, - 0xe320, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7177734375000000, - .relative_y = 0.1171875000000000, - }, - 0xe321, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8085937500000000, - .relative_y = 0.0253906250000000, - }, 0xe322, => .{ .size = .fit_cover1, @@ -626,32 +400,6 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7509765625000000, .relative_y = 0.0839843750000000, }, - 0xe323, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8281250000000000, - .relative_y = 0.0097656250000000, - }, - 0xe324, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8349609375000000, - }, - 0xe325, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8154296875000000, - .relative_y = 0.0214843750000000, - }, 0xe326, => .{ .size = .fit_cover1, @@ -661,7 +409,43 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.8144531250000000, .relative_y = 0.0195312500000000, }, - 0xe327, + 0xe347, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5273437500000000, + .relative_y = 0.2617187500000000, + }, + 0xe34f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5751953125000000, + .relative_y = 0.1142578125000000, + }, + 0xe35f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9648437500000000, + .relative_y = 0.0302734375000000, + }, + 0xe360, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7695312500000000, + .relative_y = 0.0302734375000000, + }, + 0xe361, => .{ .size = .fit_cover1, .height = .icon, @@ -670,482 +454,15 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.8076171875000000, .relative_y = 0.0273437500000000, }, - 0xe328, + 0xe363, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.6845703125000000, - .relative_y = 0.1503906250000000, - }, - 0xe329, - 0xe367, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8173828125000000, - .relative_y = 0.0175781250000000, - }, - 0xe32a, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8105468750000000, - .relative_y = 0.0263671875000000, - }, - 0xe32b, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5175781250000000, - .relative_y = 0.2421875000000000, - }, - 0xe32c, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6992187500000000, - .relative_y = 0.1005859375000000, - }, - 0xe32d, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6787109375000000, - .relative_y = 0.1201171875000000, - }, - 0xe32e, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5654296875000000, - .relative_y = 0.2324218750000000, - }, - 0xe32f, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7714843750000000, - .relative_y = 0.0273437500000000, - }, - 0xe330, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7148437500000000, - .relative_y = 0.0830078125000000, - }, - 0xe331, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7919921875000000, + .relative_height = 0.7900390625000000, .relative_y = 0.0097656250000000, }, - 0xe332, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7871093750000000, - .relative_y = 0.0126953125000000, - }, - 0xe333, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7714843750000000, - .relative_y = 0.0263671875000000, - }, - 0xe334, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7773437500000000, - .relative_y = 0.0195312500000000, - }, - 0xe335, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7714843750000000, - .relative_y = 0.0283203125000000, - }, - 0xe336, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6503906250000000, - .relative_y = 0.1503906250000000, - }, - 0xe337, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7753906250000000, - .relative_y = 0.0234375000000000, - }, - 0xe338, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7792968750000000, - .relative_y = 0.0185546875000000, - }, - 0xe339, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.4882812500000000, - .relative_y = 0.2109375000000000, - }, - 0xe33a, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5283203125000000, - .relative_y = 0.2324218750000000, - }, - 0xe33b, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5449218750000000, - .relative_y = 0.2148437500000000, - }, - 0xe33c, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6006674082313682, - .relative_y = 0.1952169076751947, - }, - 0xe33d, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5273437500000000, - .relative_y = 0.2324218750000000, - }, - 0xe33e, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.1904296875000000, - .relative_y = 0.5986328125000000, - }, - 0xe33f, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.3300781250000000, - .relative_y = 0.3544921875000000, - }, - 0xe340, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5273437500000000, - .relative_y = 0.2373046875000000, - }, - 0xe341, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.4814453125000000, - .relative_y = 0.2138671875000000, - }, - 0xe343, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6816406250000000, - .relative_y = 0.1591796875000000, - }, - 0xe344, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.3369140625000000, - .relative_y = 0.3154296875000000, - }, - 0xe345, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6073507601038191, - .relative_y = 0.1629495736002966, - }, - 0xe348, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6347656250000000, - .relative_y = 0.1826171875000000, - }, - 0xe349, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.3402542969850663, - .relative_y = 0.3471400394477318, - }, - 0xe34b, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6621093750000000, - .relative_y = 0.1689453125000000, - }, - 0xe34c, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8662109375000000, - .relative_y = 0.1337890625000000, - }, - 0xe34d, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9892578125000000, - }, - 0xe351, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7992831541218638, - .relative_y = 0.0919952210274791, - }, - 0xe352, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.4050179211469534, - .relative_y = 0.3739545997610514, - }, - 0xe353, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7040688830943068, - .relative_y = 0.1811983920034767, - }, - 0xe356, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7564102564102564, - .relative_y = 0.1213017751479290, - }, - 0xe357, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7509765625000000, - .relative_y = 0.1230468750000000, - }, - 0xe358, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7490234375000000, - .relative_y = 0.1250000000000000, - }, - 0xe359, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7643248629795715, - .relative_y = 0.1121076233183857, - }, - 0xe35a, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7643248629795715, - .relative_y = 0.1111111111111111, - }, - 0xe35b, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7683109118086696, - .relative_y = 0.1111111111111111, - }, - 0xe35c, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9895366218236173, - }, - 0xe35d, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5590433482810164, - .relative_y = 0.2152466367713005, - }, - 0xe35e, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7443946188340808, - .relative_y = 0.0134529147982063, - }, - 0xe35f, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9845540607872446, - .relative_y = 0.0154459392127554, - }, - 0xe360, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7852516193323368, - .relative_y = 0.0154459392127554, - }, - 0xe361, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8241155954160438, - .relative_y = 0.0124564025909317, - }, - 0xe364, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8251953125000000, - .relative_y = 0.0097656250000000, - }, - 0xe366, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7832031250000000, - .relative_y = 0.0166015625000000, - }, - 0xe369, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.4902343750000000, - .relative_y = 0.2548828125000000, - }, - 0xe36b, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9335937500000000, - .relative_y = 0.0263671875000000, - }, 0xe36c, => .{ .size = .fit_cover1, @@ -1164,31 +481,14 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.8427734375000000, .relative_y = 0.0625000000000000, }, - 0xe36e, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8712355686563498, - .relative_y = 0.0383689511176615, - }, - 0xe371, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6163708086785010, - .relative_y = 0.1903353057199211, - }, 0xe372, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.9725209080047790, + .relative_height = 0.7949218750000000, + .relative_y = 0.0576171875000000, }, 0xe373, => .{ @@ -1196,62 +496,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8737672583826430, - .relative_y = 0.0009861932938856, - }, - 0xe374, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.3185404339250493, - .relative_y = 0.2840236686390533, - }, - 0xe375, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6839250493096647, - .relative_y = 0.1267258382642998, - }, - 0xe376, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7061143984220908, - .relative_y = 0.1301775147928994, - }, - 0xe377, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7386587771203156, - .relative_y = 0.1518737672583826, - }, - 0xe378, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7386587771203156, - .relative_y = 0.1508875739644970, - }, - 0xe379, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5808678500986193, - .relative_y = 0.1794871794871795, + .relative_height = 0.8652343750000000, + .relative_y = 0.0058593750000000, }, 0xe37a, => .{ @@ -1259,8 +505,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5315581854043393, - .relative_y = 0.2258382642998027, + .relative_height = 0.5263671875000000, + .relative_y = 0.2285156250000000, }, 0xe37b, => .{ @@ -1268,17 +514,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5808678500986193, - .relative_y = 0.1804733727810651, - }, - 0xe37d, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9003906250000000, - .relative_y = 0.0957031250000000, + .relative_height = 0.5751953125000000, + .relative_y = 0.1835937500000000, }, 0xe37e, => .{ @@ -1289,27 +526,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.6015625000000000, .relative_y = 0.2324218750000000, }, - 0xe37f, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.3300781250000000, - .relative_y = 0.3593750000000000, - }, - 0xe380, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.3300781250000000, - .relative_y = 0.3496093750000000, - }, 0xe381...0xe383, - 0xe385...0xe388, - 0xf451...0xf453, + 0xe386, + 0xe388, + 0xe38b, => .{ .size = .fit_cover1, .height = .icon, @@ -1318,65 +538,27 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7500000000000000, .relative_y = 0.1250000000000000, }, - 0xe389...0xe38c, + 0xe38d, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_width = 0.9987004548408057, - .relative_height = 0.9974025974025974, - .relative_y = 0.0012987012987013, + .relative_height = 0.7519531250000000, + .relative_y = 0.1240234375000000, }, - 0xe38e...0xe391, - 0xe394, + 0xe390, + 0xe393, + 0xe396, + 0xe3a3, + 0xe3a6...0xe3a7, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_width = 0.4990253411306043, - .relative_height = 0.9987012987012988, - .relative_x = 0.4996751137102014, - }, - 0xe392...0xe393, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_width = 0.4996751137102014, - .relative_height = 0.9987012987012988, - .relative_x = 0.4990253411306043, - }, - 0xe395...0xe396, - 0xe39b, - 0xe3a2...0xe3a8, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7568897637795275, - .relative_y = 0.1190944881889764, - }, - 0xe397...0xe39a, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7578740157480315, - .relative_y = 0.1190944881889764, - }, - 0xe39c, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7529527559055118, - .relative_y = 0.1190944881889764, + .relative_height = 0.7509765625000000, + .relative_y = 0.1240234375000000, }, 0xe39d, => .{ @@ -1384,8 +566,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7539370078740157, - .relative_y = 0.1190944881889764, + .relative_height = 0.7480468750000000, + .relative_y = 0.1240234375000000, }, 0xe39e...0xe3a0, => .{ @@ -1393,8 +575,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7549212598425197, - .relative_y = 0.1190944881889764, + .relative_height = 0.7490234375000000, + .relative_y = 0.1240234375000000, }, 0xe3a1, => .{ @@ -1402,26 +584,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7559055118110236, - .relative_y = 0.1190944881889764, - }, - 0xe3a9, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7568897637795275, - .relative_y = 0.1181102362204724, - }, - 0xe3aa, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9980314960629921, - .relative_y = 0.0019685039370079, + .relative_height = 0.7500000000000000, + .relative_y = 0.1240234375000000, }, 0xe3ab, => .{ @@ -1429,7 +593,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7962598425196851, + .relative_height = 0.7900390625000000, + .relative_y = 0.0058593750000000, }, 0xe3ac, => .{ @@ -1437,8 +602,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8316929133858267, - .relative_y = 0.0019685039370079, + .relative_height = 0.8251953125000000, + .relative_y = 0.0078125000000000, }, 0xe3ad, => .{ @@ -1446,56 +611,13 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7578740157480315, - .relative_y = 0.0009842519685039, - }, - 0xe3ae, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6200787401574803, - .relative_y = 0.2283464566929134, + .relative_height = 0.7519531250000000, + .relative_y = 0.0068359375000000, }, 0xe3af, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7057086614173228, - .relative_y = 0.1456692913385827, - }, - 0xe3b0, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7037401574803149, - .relative_y = 0.1476377952755905, - }, - 0xe3b1, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7125062282012955, - .relative_y = 0.1400099651220728, - }, - 0xe3b2, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6982421875000000, - .relative_y = 0.1523437500000000, - }, 0xe3b3, - 0xe3b5...0xe3b6, + 0xe3b5, + 0xe3b7...0xe3bb, => .{ .size = .fit_cover1, .height = .icon, @@ -1504,6 +626,15 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7001953125000000, .relative_y = 0.1503906250000000, }, + 0xe3b0...0xe3b1, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6982421875000000, + .relative_y = 0.1523437500000000, + }, 0xe3b4, => .{ .size = .fit_cover1, @@ -1513,99 +644,40 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7011718750000000, .relative_y = 0.1494140625000000, }, - 0xe3b7...0xe3bb, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7000244081034903, - .relative_y = 0.1505979985355138, - }, - 0xe3bc, - 0xe3c0, - 0xe3c3, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9997559189650964, - .relative_y = 0.0002440810349036, - }, - 0xe3bd, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9431291188674640, - .relative_y = 0.0285574810837198, - }, - 0xe3be, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9896346920510943, - .relative_y = 0.0051257017329753, - }, - 0xe3bf, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9060288015621186, - .relative_y = 0.0471076397363925, - }, - 0xe3c1, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.6590187942396876, - .relative_y = 0.1349768123016842, - }, - 0xe3c2, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7939956065413717, - }, - 0xe3c9...0xe3ca, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9175627240143369, - .relative_y = 0.0824372759856631, - }, 0x23fb...0x23fe, 0x2665, 0x26a1, 0x2b58, 0xe000...0xe00a, 0xe200...0xe2a9, - 0xe342, - 0xe346...0xe347, - 0xe34a, - 0xe34e...0xe350, - 0xe354...0xe355, - 0xe362...0xe363, - 0xe368, - 0xe36a, - 0xe36f...0xe370, - 0xe37c, - 0xe384, - 0xe38d, - 0xe3c4...0xe3c8, - 0xe3cb...0xe3e3, + 0xe300...0xe302, + 0xe305...0xe318, + 0xe320...0xe321, + 0xe323...0xe325, + 0xe327...0xe346, + 0xe348...0xe34e, + 0xe350...0xe35e, + 0xe362, + 0xe364...0xe36b, + 0xe36e...0xe371, + 0xe374...0xe379, + 0xe37c...0xe37d, + 0xe37f...0xe380, + 0xe384...0xe385, + 0xe387, + 0xe389...0xe38a, + 0xe38c, + 0xe38e...0xe38f, + 0xe391...0xe392, + 0xe394...0xe395, + 0xe397...0xe39c, + 0xe3a2, + 0xe3a4...0xe3a5, + 0xe3a8...0xe3aa, + 0xe3ae, + 0xe3b2, + 0xe3b6, + 0xe3bc...0xe3e3, 0xe5fa...0xe6b8, 0xe700...0xe8ef, 0xea60, @@ -1629,23 +701,9 @@ pub fn getConstraint(cp: u21) ?Constraint { 0xec0d...0xec1e, 0xed00...0xedff, 0xee0c...0xefce, - 0xf000...0xf004, - 0xf006...0xf025, - 0xf028...0xf02a, - 0xf02c...0xf030, - 0xf034, - 0xf036...0xf043, - 0xf045, - 0xf047, - 0xf053...0xf05f, - 0xf062, - 0xf064...0xf076, - 0xf079...0xf07d, - 0xf07f...0xf088, - 0xf08a...0xf0a3, - 0xf0a6...0xf0d6, - 0xf0db, + 0xf000...0xf0db, 0xf0df...0xf0ff, + 0xf101...0xf105, 0xf108...0xf12f, 0xf131...0xf140, 0xf142...0xf152, @@ -1658,23 +716,27 @@ pub fn getConstraint(cp: u21) ?Constraint { 0xf22e...0xf254, 0xf259, 0xf25c...0xf381, - 0xf400...0xf418, - 0xf41a...0xf42f, - 0xf431...0xf43d, - 0xf43f, - 0xf441...0xf443, - 0xf445...0xf449, - 0xf44b...0xf450, - 0xf454...0xf459, - 0xf45c...0xf470, - 0xf472...0xf47a, - 0xf47c...0xf480, - 0xf482...0xf491, - 0xf493...0xf49e, - 0xf4a0...0xf4c2, + 0xf400...0xf415, + 0xf417...0xf423, + 0xf425...0xf430, + 0xf435...0xf437, + 0xf439...0xf43d, + 0xf43f...0xf442, + 0xf446...0xf449, + 0xf44c...0xf45b, + 0xf45d...0xf45f, + 0xf462...0xf466, + 0xf468...0xf46b, + 0xf46d...0xf46f, + 0xf471...0xf475, + 0xf477...0xf479, + 0xf47f...0xf48a, + 0xf48c...0xf492, + 0xf494...0xf499, + 0xf49b...0xf4c2, 0xf4c4...0xf4ee, 0xf4f3...0xf51c, - 0xf51e...0xf532, + 0xf51e...0xf533, 0xf0001...0xf1af0, => .{ .size = .fit_cover1, @@ -2078,274 +1140,20 @@ pub fn getConstraint(cp: u21) ?Constraint { .pad_top = 0.03, .pad_bottom = 0.03, }, - 0xf005, + 0xf0dc...0xf0de, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + }, + 0xf100, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.9999664113932554, - .relative_y = 0.0000335886067446, - }, - 0xf026...0xf027, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9786184354605580, - .relative_y = 0.0103951316192896, - }, - 0xf02b, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9758052740827267, - .relative_y = 0.0238869355863696, - }, - 0xf031...0xf033, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9987922705314010, - .relative_y = 0.0006038647342995, - }, - 0xf035, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9989935587761675, - .relative_y = 0.0004025764895330, - }, - 0xf044, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9925925925925926, - }, - 0xf046, - 0xf153...0xf154, - 0xf158, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8751322751322751, - .relative_y = 0.0624338624338624, - }, - 0xf048, - 0xf04a, - 0xf04e, - 0xf051, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8577706898990622, - .relative_y = 0.0711892586341537, - }, - 0xf049, - 0xf050, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8579450878868969, - .relative_y = 0.0710148606463189, - }, - 0xf04b, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9997041418532618, - .relative_y = 0.0002958581467381, - }, - 0xf04c...0xf04d, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8572940020656472, - .relative_y = 0.0713404035569438, - }, - 0xf04f, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7138835298072554, - .relative_y = 0.1433479295317200, - }, - 0xf052, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9999748091795350, - }, - 0xf060...0xf061, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8567975830815709, - .relative_y = 0.0719033232628399, - }, - 0xf063, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9987915407854985, - .relative_y = 0.0006042296072508, - }, - 0xf077, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5700483091787439, - .relative_y = 0.2862318840579710, - }, - 0xf078, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.5700483091787439, - .relative_y = 0.1437198067632850, - }, - 0xf07e, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.4989429175475687, - .relative_y = 0.2505285412262157, - }, - 0xf089, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9998488512696494, - .relative_y = 0.0001511487303507, - }, - 0xf0a4...0xf0a5, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7502645502645503, - .relative_y = 0.1248677248677249, - }, - 0xf0d7, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.4281400966183575, - .relative_y = 0.2053140096618357, - }, - 0xf0d8, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.4281400966183575, - .relative_y = 0.3472222222222222, - }, - 0xf0d9, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7140772371750631, - .relative_y = 0.1333462732919255, - }, - 0xf0da, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.7140396210163651, - .relative_y = 0.1333838894506235, - }, - 0xf0dc, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - }, - 0xf0dd, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .relative_height = 0.4275362318840580, - .relative_y = 0.0012077294685990, - }, - 0xf0de, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .relative_height = 0.4287439613526570, - .relative_y = 0.5712560386473430, - }, - 0xf100...0xf101, - 0xf104...0xf105, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8573155985489722, - .relative_y = 0.0713422007255139, - }, - 0xf102, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9286577992744861, - .relative_y = 0.0713422007255139, - }, - 0xf103, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9286577992744861, + .relative_height = 0.6923828125000000, + .relative_y = 0.1538085937500000, }, 0xf106...0xf107, => .{ @@ -2353,8 +1161,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5000000000000000, - .relative_y = 0.2853688029020556, + .relative_height = 0.4038085937500000, + .relative_y = 0.3266601562500000, }, 0xf130, => .{ @@ -2373,6 +1181,16 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.2593984962406015, .relative_y = 0.3696741854636592, }, + 0xf153...0xf154, + 0xf158, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8751322751322751, + .relative_y = 0.0624338624338624, + }, 0xf156, => .{ .size = .fit_cover1, @@ -2502,24 +1320,80 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_vertical = .center1, .relative_height = 0.9975006099019084, }, - 0xf419, - 0xf45a, + 0xf416, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8750000000000000, - .relative_y = 0.0625000000000000, + .relative_height = 0.6090604026845637, + .relative_y = 0.2119686800894855, }, - 0xf430, + 0xf424, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8496093750000000, - .relative_y = 0.0751953125000000, + .relative_width = 0.5019531250000000, + .relative_height = 0.5755033557046980, + .relative_x = 0.2480468750000000, + .relative_y = 0.2108501118568233, + }, + 0xf431, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6240234375000000, + .relative_height = 0.7695749440715883, + .relative_x = 0.2031250000000000, + .relative_y = 0.1420581655480984, + }, + 0xf432, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6718750000000000, + .relative_height = 0.7147651006711410, + .relative_x = 0.1875000000000000, + .relative_y = 0.1610738255033557, + }, + 0xf433, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6240234375000000, + .relative_height = 0.7695749440715883, + .relative_x = 0.2041015625000000, + .relative_y = 0.0883668903803132, + }, + 0xf434, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6718750000000000, + .relative_height = 0.7147651006711410, + .relative_x = 0.1406250000000000, + .relative_y = 0.1599552572706935, + }, + 0xf438, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.2436523437500000, + .relative_height = 0.4560546875000000, + .relative_x = 0.3813476562500000, + .relative_y = 0.2719726562500000, }, 0xf43e, => .{ @@ -2527,26 +1401,31 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5024414062500000, - .relative_y = 0.2500000000000000, + .relative_width = 0.5029296875000000, + .relative_height = 0.5755033557046980, + .relative_x = 0.2500000000000000, + .relative_y = 0.2136465324384788, }, - 0xf440, - 0xf492, + 0xf443, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8437500000000000, - .relative_y = 0.0781250000000000, + .relative_width = 0.7500000000000000, + .relative_x = 0.1250000000000000, }, - 0xf444, + 0xf444...0xf445, + 0xf4c3, + 0xf51d, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, + .relative_width = 0.5000000000000000, .relative_height = 0.5000000000000000, + .relative_x = 0.2500000000000000, .relative_y = 0.2500000000000000, }, 0xf44a, @@ -2555,55 +1434,169 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, + .relative_width = 0.2436523437500000, .relative_height = 0.4560546875000000, + .relative_x = 0.3750000000000000, .relative_y = 0.2719726562500000, }, - 0xf45b, + 0xf44b, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.0937500000000000, - .relative_y = 0.4531250000000000, + .relative_width = 0.4560546875000000, + .relative_height = 0.2436523437500000, + .relative_x = 0.2719726562500000, + .relative_y = 0.3188476562500000, }, - 0xf471, - 0xf481, + 0xf45c, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.9375000000000000, - .relative_y = 0.0312500000000000, + .relative_width = 0.5019531250000000, + .relative_height = 0.5749440715883669, + .relative_x = 0.2480468750000000, + .relative_y = 0.2114093959731544, }, - 0xf47b, + 0xf460, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, + .relative_width = 0.3593750000000000, + .relative_height = 0.6240234375000000, + .relative_x = 0.3750000000000000, + .relative_y = 0.1884765625000000, + }, + 0xf461, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6237816764132553, + .relative_height = 0.9988851727982163, + .relative_x = 0.1881091617933723, + }, + 0xf467, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5639648437500000, + .relative_height = 0.5649414062500000, + .relative_x = 0.2187500000000000, + .relative_y = 0.2177734375000000, + }, + 0xf46c, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5039062500000000, + .relative_height = 0.5771812080536913, + .relative_x = 0.2490234375000000, + .relative_y = 0.2091722595078300, + }, + 0xf470, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9926757812500000, + .relative_height = 0.2690429687500000, + .relative_y = 0.6865234375000000, + }, + 0xf476, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8732325694783033, + .relative_x = 0.0633837152608484, + }, + 0xf47a, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5843079922027290, + .relative_height = 0.9509476031215162, + .relative_x = 0.2066276803118908, + .relative_y = 0.0234113712374582, + }, + 0xf47b...0xf47c, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6250000000000000, .relative_height = 0.3593750000000000, + .relative_x = 0.1875000000000000, .relative_y = 0.3281250000000000, }, - 0xf49f, + 0xf47d, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7680000000000000, - .relative_y = 0.1160000000000000, + .relative_width = 0.3593750000000000, + .relative_height = 0.6240234375000000, + .relative_x = 0.2656250000000000, + .relative_y = 0.1875000000000000, }, - 0xf4c3, - 0xf51d, + 0xf47e, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.5417989417989418, - .relative_y = 0.2291005291005291, + .relative_width = 0.4560546875000000, + .relative_height = 0.2436523437500000, + .relative_x = 0.2719726562500000, + .relative_y = 0.3750000000000000, + }, + 0xf48b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7187500000000000, + .relative_height = 0.0937500000000000, + .relative_x = 0.1250000000000000, + .relative_y = 0.4687500000000000, + }, + 0xf493, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8313840155945419, + .relative_height = 0.9509476031215162, + .relative_x = 0.0843079922027290, + .relative_y = 0.0234113712374582, + }, + 0xf49a, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8727450024378351, + .relative_x = 0.0633837152608484, }, 0xf4ef, 0xf4f2, @@ -2636,15 +1629,6 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_x = 0.0357142857142857, .relative_y = 0.1111111111111111, }, - 0xf533, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.9228395061728395, - .relative_y = 0.0390946502057613, - }, else => null, }; } diff --git a/src/font/nerd_font_codegen.py b/src/font/nerd_font_codegen.py index 4b1a2b857..f5bac5e86 100644 --- a/src/font/nerd_font_codegen.py +++ b/src/font/nerd_font_codegen.py @@ -15,13 +15,14 @@ SymbolsNerdFont (not Mono!) font is passed as the first argument to it. import ast import sys import math -from fontTools.ttLib import TTFont +from fontTools.ttLib import TTFont, TTLibError from fontTools.pens.boundsPen import BoundsPen from collections import defaultdict from contextlib import suppress from pathlib import Path from types import SimpleNamespace from typing import Literal, TypedDict, cast +from urllib.request import urlretrieve type PatchSetAttributes = dict[Literal["default"] | int, PatchSetAttributeEntry] type AttributeHash = tuple[ @@ -58,6 +59,8 @@ class PatchSetAttributeEntry(TypedDict): class PatchSet(TypedDict): Name: str + Filename: str + Exact: bool SymStart: int SymEnd: int SrcStart: int | None @@ -69,6 +72,18 @@ class PatchSetExtractor(ast.NodeVisitor): def __init__(self) -> None: self.symbol_table: dict[str, ast.expr] = {} self.patch_set_values: list[PatchSet] = [] + self.nf_version: str = "" + + def visit_Assign(self, node): + if ( + node.col_offset == 0 # top-level assignment + and len(node.targets) == 1 # no funny destructuring business + and isinstance(node.targets[0], ast.Name) # no setitem et cetera + and node.targets[0].id == "version" # it's the version string! + ): + self.nf_version = ast.literal_eval(node.value) + else: + return self.generic_visit(node) def visit_ClassDef(self, node: ast.ClassDef) -> None: if node.name != "font_patcher": @@ -140,12 +155,8 @@ class PatchSetExtractor(ast.NodeVisitor): def process_patch_entry(self, dict_node: ast.Dict) -> None: entry = {} - disallowed_key_nodes = frozenset({"Filename", "Exact"}) for key_node, value_node in zip(dict_node.keys, dict_node.values): - if ( - isinstance(key_node, ast.Constant) - and key_node.value not in disallowed_key_nodes - ): + if isinstance(key_node, ast.Constant): if key_node.value == "Enabled": if self.safe_literal_eval(value_node): continue # This patch set is enabled, continue to next key @@ -156,11 +167,11 @@ class PatchSetExtractor(ast.NodeVisitor): self.patch_set_values.append(cast("PatchSet", entry)) -def extract_patch_set_values(source_code: str) -> list[PatchSet]: +def extract_patch_set_values(source_code: str) -> tuple[list[PatchSet], str]: tree = ast.parse(source_code) extractor = PatchSetExtractor() extractor.visit(tree) - return extractor.patch_set_values + return extractor.patch_set_values, extractor.nf_version def parse_alignment(val: str) -> str | None: @@ -290,12 +301,127 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry) return s +def generate_codepoint_tables( + patch_sets: list[PatchSet], + nerd_font: TTFont, + nf_version: str, +) -> dict[str, dict[int, int]]: + # We may already have the table saved from a previous run. + if Path("nerd_font_codepoint_tables.py").exists(): + import nerd_font_codepoint_tables + + if nerd_font_codepoint_tables.version == nf_version: + return nerd_font_codepoint_tables.cp_tables + + cp_tables: dict[str, dict[int, int]] = {} + cp_table_full: dict[int, int] = {} + cmap = nerd_font.getBestCmap() + for entry in patch_sets: + patch_set_name = entry["Name"] + print(f"Info: Extracting codepoint table from patch set '{patch_set_name}'") + + # Extract codepoint map from original font file; download if needed + source_filename = entry["Filename"] + target_folder = Path("nerd_font_symbol_fonts") + target_folder.mkdir(exist_ok=True) + target_file = target_folder / Path(source_filename).name + if not target_file.exists(): + print(f"Info: Downloading '{source_filename}'") + urlretrieve( + f"https://github.com/ryanoasis/nerd-fonts/raw/refs/tags/v{nf_version}/src/glyphs/{source_filename}", + target_file, + ) + try: + with TTFont(target_file) as patchfont: + patch_cmap = patchfont.getBestCmap() + except TTLibError: + # Not a TTF/OTF font. This is OK if this patch set is exact, so we + # let if pass. If there's a problem, later checks will catch it. + patch_cmap = None + + # A glyph's scale rules are specified using its codepoint in + # the original font, which is sometimes different from its + # Nerd Font codepoint. If entry["Exact"] is False, the codepoints are + # mapped according to the following rules: + # * entry["SymStart"] and entry["SymEnd"] denote the patch set's codepoint + # range in the original font. + # * entry["SrcStart"] is the starting point of the patch set's mapped + # codepoint range. It must not be None if entry["Exact"] is False. + # * The destination codepoint range is packed; that is, while there may be + # gaps without glyphs in the original font's codepoint range, there are + # none in the Nerd Font range. Hence there is no constant codepoint + # offset; instead we must iterate through the range and increment the + # destination codepoint every time we encounter a glyph in the original + # font. + # If entry["Exact"] is True, the origin and Nerd Font codepoints are the + # same, gaps included, and entry["SrcStart"] must be None. + if entry["Exact"]: + assert entry["SrcStart"] is None + cp_nerdfont = 0 + else: + assert entry["SrcStart"] + assert patch_cmap is not None + cp_nerdfont = entry["SrcStart"] - 1 + + if patch_set_name not in cp_tables: + # There are several patch sets with the same name, representing + # different codepoint ranges within the same original font. Merging + # these into a single table is OK. However, we need to keep separate + # tables for the different fonts to correctly deal with cases where + # they fill in each other's gaps. + cp_tables[patch_set_name] = {} + for cp_original in range(entry["SymStart"], entry["SymEnd"] + 1): + if patch_cmap and cp_original not in patch_cmap: + continue + if not entry["Exact"]: + cp_nerdfont += 1 + else: + cp_nerdfont = cp_original + if cp_nerdfont not in cmap: + raise ValueError( + f"Missing codepoint in Symbols Only Font: {hex(cp_nerdfont)} in patch set '{patch_set_name}'" + ) + elif cp_nerdfont in cp_table_full.values(): + raise ValueError( + f"Overlap for codepoint {hex(cp_nerdfont)} in patch set '{patch_set_name}'" + ) + cp_tables[patch_set_name][cp_original] = cp_nerdfont + cp_table_full |= cp_tables[patch_set_name] + + # Store the table and corresponding Nerd Fonts version together in a module. + with open("nerd_font_codepoint_tables.py", "w") as f: + print( + """#! This is a generated file, produced by nerd_font_codegen.py +#! DO NOT EDIT BY HAND! +#! +#! This file specifies the mapping of codepoints in the original symbol +#! fonts to codepoints in a patched Nerd Font. This is extracted from +#! the nerd fonts patcher script and the symbol font files.""", + file=f, + ) + print(f'version = "{nf_version}"', file=f) + print("cp_tables = {", file=f) + for name, table in cp_tables.items(): + print(f' "{name}": {{', file=f) + for key, value in table.items(): + print(f" {hex(key)}: {hex(value)},", file=f) + print(" },", file=f) + print("}", file=f) + + return cp_tables + + def generate_zig_switch_arms( patch_sets: list[PatchSet], nerd_font: TTFont, + nf_version: str, ) -> str: cmap = nerd_font.getBestCmap() glyphs = nerd_font.getGlyphSet() + cp_tables = generate_codepoint_tables(patch_sets, nerd_font, nf_version) + cp_table_full: dict[int, int] = {} + for cp_table in cp_tables.values(): + cp_table_full |= cp_table entries: dict[int, PatchSetAttributeEntry] = {} for entry in patch_sets: @@ -305,47 +431,21 @@ def generate_zig_switch_arms( attributes = entry["Attributes"] patch_set_entries: dict[int, PatchSetAttributeEntry] = {} - # A glyph's scale rules are specified using its codepoint in - # the original font, which is sometimes different from its - # Nerd Font codepoint. In font_patcher, the font to be patched - # (including the Symbols Only font embedded in Ghostty) is - # termed the sourceFont, while the original font is the - # symbolFont. Thus, the offset that maps the scale rule - # codepoint to the Nerd Font codepoint is SrcStart - SymStart. - cp_offset = entry["SrcStart"] - entry["SymStart"] if entry["SrcStart"] else 0 - for cp_rule in range(entry["SymStart"], entry["SymEnd"] + 1): - cp_font = cp_rule + cp_offset - if cp_font not in cmap: - print(f"Info: Skipping missing codepoint {hex(cp_font)}") + cp_table = cp_tables[patch_set_name] + for cp_original in range(entry["SymStart"], entry["SymEnd"] + 1): + if cp_original not in cp_table: continue - elif cp_font in entries: - # Patch sets sometimes have overlapping codepoint ranges. - # Sometimes a later set is a smaller set filling in a gap - # in the range of a larger, preceding set. Sometimes it's - # the other way around. The best thing we can do is hardcode - # each case. - if patch_set_name == "Font Awesome": - # The Font Awesome range has a gap matching the - # prededing Progress Indicators range. - print(f"Info: Not overwriting existing codepoint {hex(cp_font)}") - continue - elif patch_set_name == "Octicons": - # The fourth Octicons range overlaps with the first. - print(f"Info: Overwriting existing codepoint {hex(cp_font)}") - else: - raise ValueError( - f"Unknown case of overlap for codepoint {hex(cp_font)} in patch set '{patch_set_name}'" - ) - if cp_rule in attributes: - patch_set_entries[cp_font] = attributes[cp_rule].copy() + cp_nerdfont = cp_table[cp_original] + if cp_nerdfont in entries: + raise ValueError( + f"Overlap for codepoint {hex(cp_nerdfont)} in patch set '{patch_set_name}'" + ) + if cp_original in attributes: + patch_set_entries[cp_nerdfont] = attributes[cp_original].copy() else: - patch_set_entries[cp_font] = attributes["default"].copy() + patch_set_entries[cp_nerdfont] = attributes["default"].copy() if entry["ScaleRules"] is not None: - if "ScaleGroups" not in entry["ScaleRules"]: - raise ValueError( - f"Scale rule format {entry['ScaleRules']} not implemented." - ) for group in entry["ScaleRules"]["ScaleGroups"]: xMin = math.inf yMin = math.inf @@ -353,15 +453,15 @@ def generate_zig_switch_arms( yMax = -math.inf individual_bounds: dict[int, tuple[int, int, int, int]] = {} individual_advances: set[float] = set() - for cp_rule in group: - cp_font = cp_rule + cp_offset - if cp_font not in cmap: - continue - glyph = glyphs[cmap[cp_font]] + for cp_original in group: + # Scale groups may cut across patch sets, so we need to use + # the full lookup table here + cp_nerdfont = cp_table_full[cp_original] + glyph = glyphs[cmap[cp_nerdfont]] individual_advances.add(glyph.width) bounds = BoundsPen(glyphSet=glyphs) glyph.draw(bounds) - individual_bounds[cp_font] = bounds.bounds + individual_bounds[cp_nerdfont] = bounds.bounds xMin = min(bounds.bounds[0], xMin) yMin = min(bounds.bounds[1], yMin) xMax = max(bounds.bounds[2], xMax) @@ -371,34 +471,36 @@ def generate_zig_switch_arms( group_is_monospace = (len(individual_bounds) > 1) and ( len(individual_advances) == 1 ) - for cp_rule in group: - cp_font = cp_rule + cp_offset + for cp_original in group: + cp_nerdfont = cp_table_full[cp_original] if ( - cp_font not in cmap - or cp_font not in patch_set_entries + # Scale groups may cut across patch sets, but we're only + # updating a single patch set at a time, so we skip + # codepoints not in it. + cp_nerdfont not in patch_set_entries # Codepoints may contribute to the bounding box of multiple groups, # but should be scaled according to the first group they are found # in. Hence, to avoid overwriting, we need to skip codepoints that # have already been assigned a scale group. - or "relative_height" in patch_set_entries[cp_font] + or "relative_height" in patch_set_entries[cp_nerdfont] ): continue - this_bounds = individual_bounds[cp_font] + this_bounds = individual_bounds[cp_nerdfont] this_height = this_bounds[3] - this_bounds[1] - patch_set_entries[cp_font]["relative_height"] = ( + patch_set_entries[cp_nerdfont]["relative_height"] = ( this_height / group_height ) - patch_set_entries[cp_font]["relative_y"] = ( + patch_set_entries[cp_nerdfont]["relative_y"] = ( this_bounds[1] - yMin ) / group_height # Horizontal alignment should only be grouped if the group is monospace, # that is, if all glyphs in the group have the same advance width. if group_is_monospace: this_width = this_bounds[2] - this_bounds[0] - patch_set_entries[cp_font]["relative_width"] = ( + patch_set_entries[cp_nerdfont]["relative_width"] = ( this_width / group_width ) - patch_set_entries[cp_font]["relative_x"] = ( + patch_set_entries[cp_nerdfont]["relative_x"] = ( this_bounds[0] - xMin ) / group_width entries |= patch_set_entries @@ -427,7 +529,7 @@ if __name__ == "__main__": patcher_path = project_root / "vendor" / "nerd-fonts" / "font-patcher.py" source = patcher_path.read_text(encoding="utf-8") - patch_set = extract_patch_set_values(source) + patch_set, nf_version = extract_patch_set_values(source) out_path = project_root / "src" / "font" / "nerd_font_attributes.zig" @@ -444,5 +546,5 @@ const Constraint = @import("face.zig").RenderOptions.Constraint; pub fn getConstraint(cp: u21) ?Constraint { return switch (cp) { """) - f.write(generate_zig_switch_arms(patch_set, nerd_font)) + f.write(generate_zig_switch_arms(patch_set, nerd_font, nf_version)) f.write("\n else => null,\n };\n}\n") diff --git a/src/font/nerd_font_codepoint_tables.py b/src/font/nerd_font_codepoint_tables.py new file mode 100644 index 000000000..89a623f1c --- /dev/null +++ b/src/font/nerd_font_codepoint_tables.py @@ -0,0 +1,10449 @@ +#! This is a generated file, produced by nerd_font_codegen.py +#! DO NOT EDIT BY HAND! +#! +#! This file specifies the mapping of codepoints in the original symbol +#! fonts to codepoints in a patched Nerd Font. This is extracted from +#! the nerd fonts patcher script and the symbol font files. +version = "3.4.0" +cp_tables = { + "Seti-UI + Custom": { + 0xe4fa: 0xe5fa, + 0xe4fb: 0xe5fb, + 0xe4fc: 0xe5fc, + 0xe4fd: 0xe5fd, + 0xe4fe: 0xe5fe, + 0xe4ff: 0xe5ff, + 0xe500: 0xe600, + 0xe501: 0xe601, + 0xe502: 0xe602, + 0xe503: 0xe603, + 0xe504: 0xe604, + 0xe505: 0xe605, + 0xe506: 0xe606, + 0xe507: 0xe607, + 0xe508: 0xe608, + 0xe509: 0xe609, + 0xe50a: 0xe60a, + 0xe50b: 0xe60b, + 0xe50c: 0xe60c, + 0xe50d: 0xe60d, + 0xe50e: 0xe60e, + 0xe50f: 0xe60f, + 0xe510: 0xe610, + 0xe511: 0xe611, + 0xe512: 0xe612, + 0xe513: 0xe613, + 0xe514: 0xe614, + 0xe515: 0xe615, + 0xe516: 0xe616, + 0xe517: 0xe617, + 0xe518: 0xe618, + 0xe519: 0xe619, + 0xe51a: 0xe61a, + 0xe51b: 0xe61b, + 0xe51c: 0xe61c, + 0xe51d: 0xe61d, + 0xe51e: 0xe61e, + 0xe51f: 0xe61f, + 0xe520: 0xe620, + 0xe521: 0xe621, + 0xe522: 0xe622, + 0xe523: 0xe623, + 0xe524: 0xe624, + 0xe525: 0xe625, + 0xe526: 0xe626, + 0xe527: 0xe627, + 0xe528: 0xe628, + 0xe529: 0xe629, + 0xe52a: 0xe62a, + 0xe52b: 0xe62b, + 0xe52c: 0xe62c, + 0xe52d: 0xe62d, + 0xe52e: 0xe62e, + 0xe52f: 0xe62f, + 0xe530: 0xe630, + 0xe531: 0xe631, + 0xe532: 0xe632, + 0xe533: 0xe633, + 0xe534: 0xe634, + 0xe535: 0xe635, + 0xe536: 0xe636, + 0xe537: 0xe637, + 0xe538: 0xe638, + 0xe539: 0xe639, + 0xe53a: 0xe63a, + 0xe53b: 0xe63b, + 0xe53c: 0xe63c, + 0xe53d: 0xe63d, + 0xe53e: 0xe63e, + 0xe53f: 0xe63f, + 0xe540: 0xe640, + 0xe541: 0xe641, + 0xe542: 0xe642, + 0xe543: 0xe643, + 0xe544: 0xe644, + 0xe545: 0xe645, + 0xe546: 0xe646, + 0xe547: 0xe647, + 0xe548: 0xe648, + 0xe549: 0xe649, + 0xe54a: 0xe64a, + 0xe54b: 0xe64b, + 0xe54c: 0xe64c, + 0xe54d: 0xe64d, + 0xe54e: 0xe64e, + 0xe54f: 0xe64f, + 0xe550: 0xe650, + 0xe551: 0xe651, + 0xe552: 0xe652, + 0xe553: 0xe653, + 0xe554: 0xe654, + 0xe555: 0xe655, + 0xe556: 0xe656, + 0xe557: 0xe657, + 0xe558: 0xe658, + 0xe559: 0xe659, + 0xe55a: 0xe65a, + 0xe55b: 0xe65b, + 0xe55c: 0xe65c, + 0xe55d: 0xe65d, + 0xe55e: 0xe65e, + 0xe55f: 0xe65f, + 0xe560: 0xe660, + 0xe561: 0xe661, + 0xe562: 0xe662, + 0xe563: 0xe663, + 0xe564: 0xe664, + 0xe565: 0xe665, + 0xe566: 0xe666, + 0xe567: 0xe667, + 0xe568: 0xe668, + 0xe569: 0xe669, + 0xe56a: 0xe66a, + 0xe56b: 0xe66b, + 0xe56c: 0xe66c, + 0xe56d: 0xe66d, + 0xe56e: 0xe66e, + 0xe56f: 0xe66f, + 0xe570: 0xe670, + 0xe571: 0xe671, + 0xe572: 0xe672, + 0xe573: 0xe673, + 0xe574: 0xe674, + 0xe575: 0xe675, + 0xe576: 0xe676, + 0xe577: 0xe677, + 0xe578: 0xe678, + 0xe579: 0xe679, + 0xe57a: 0xe67a, + 0xe57b: 0xe67b, + 0xe57c: 0xe67c, + 0xe57d: 0xe67d, + 0xe57e: 0xe67e, + 0xe57f: 0xe67f, + 0xe580: 0xe680, + 0xe581: 0xe681, + 0xe582: 0xe682, + 0xe583: 0xe683, + 0xe584: 0xe684, + 0xe585: 0xe685, + 0xe586: 0xe686, + 0xe587: 0xe687, + 0xe588: 0xe688, + 0xe589: 0xe689, + 0xe58a: 0xe68a, + 0xe58b: 0xe68b, + 0xe58c: 0xe68c, + 0xe58d: 0xe68d, + 0xe58e: 0xe68e, + 0xe58f: 0xe68f, + 0xe590: 0xe690, + 0xe591: 0xe691, + 0xe592: 0xe692, + 0xe593: 0xe693, + 0xe594: 0xe694, + 0xe595: 0xe695, + 0xe596: 0xe696, + 0xe597: 0xe697, + 0xe598: 0xe698, + 0xe599: 0xe699, + 0xe59a: 0xe69a, + 0xe59b: 0xe69b, + 0xe59c: 0xe69c, + 0xe59d: 0xe69d, + 0xe59e: 0xe69e, + 0xe59f: 0xe69f, + 0xe5a0: 0xe6a0, + 0xe5a1: 0xe6a1, + 0xe5a2: 0xe6a2, + 0xe5a3: 0xe6a3, + 0xe5a4: 0xe6a4, + 0xe5a5: 0xe6a5, + 0xe5a6: 0xe6a6, + 0xe5a7: 0xe6a7, + 0xe5a8: 0xe6a8, + 0xe5a9: 0xe6a9, + 0xe5aa: 0xe6aa, + 0xe5ab: 0xe6ab, + 0xe5ac: 0xe6ac, + 0xe5ad: 0xe6ad, + 0xe5ae: 0xe6ae, + 0xe5af: 0xe6af, + 0xe5b0: 0xe6b0, + 0xe5b1: 0xe6b1, + 0xe5b2: 0xe6b2, + 0xe5b3: 0xe6b3, + 0xe5b4: 0xe6b4, + 0xe5b5: 0xe6b5, + 0xe5b6: 0xe6b6, + 0xe5b7: 0xe6b7, + 0xe5b8: 0xe6b8, + }, + "Heavy Angle Brackets": { + 0x276c: 0x276c, + 0x276d: 0x276d, + 0x276e: 0x276e, + 0x276f: 0x276f, + 0x2770: 0x2770, + 0x2771: 0x2771, + }, + "Progress Indicators": { + 0xee00: 0xee00, + 0xee01: 0xee01, + 0xee02: 0xee02, + 0xee03: 0xee03, + 0xee04: 0xee04, + 0xee05: 0xee05, + 0xee06: 0xee06, + 0xee07: 0xee07, + 0xee08: 0xee08, + 0xee09: 0xee09, + 0xee0a: 0xee0a, + 0xee0b: 0xee0b, + }, + "Devicons": { + 0xe600: 0xe700, + 0xe601: 0xe701, + 0xe602: 0xe702, + 0xe603: 0xe703, + 0xe604: 0xe704, + 0xe605: 0xe705, + 0xe606: 0xe706, + 0xe607: 0xe707, + 0xe608: 0xe708, + 0xe609: 0xe709, + 0xe60a: 0xe70a, + 0xe60b: 0xe70b, + 0xe60c: 0xe70c, + 0xe60d: 0xe70d, + 0xe60e: 0xe70e, + 0xe60f: 0xe70f, + 0xe610: 0xe710, + 0xe611: 0xe711, + 0xe612: 0xe712, + 0xe613: 0xe713, + 0xe614: 0xe714, + 0xe615: 0xe715, + 0xe616: 0xe716, + 0xe617: 0xe717, + 0xe618: 0xe718, + 0xe619: 0xe719, + 0xe61a: 0xe71a, + 0xe61b: 0xe71b, + 0xe61c: 0xe71c, + 0xe61d: 0xe71d, + 0xe61e: 0xe71e, + 0xe61f: 0xe71f, + 0xe620: 0xe720, + 0xe621: 0xe721, + 0xe622: 0xe722, + 0xe623: 0xe723, + 0xe624: 0xe724, + 0xe625: 0xe725, + 0xe626: 0xe726, + 0xe627: 0xe727, + 0xe628: 0xe728, + 0xe629: 0xe729, + 0xe62a: 0xe72a, + 0xe62b: 0xe72b, + 0xe62c: 0xe72c, + 0xe62d: 0xe72d, + 0xe62e: 0xe72e, + 0xe62f: 0xe72f, + 0xe630: 0xe730, + 0xe631: 0xe731, + 0xe632: 0xe732, + 0xe633: 0xe733, + 0xe634: 0xe734, + 0xe635: 0xe735, + 0xe636: 0xe736, + 0xe637: 0xe737, + 0xe638: 0xe738, + 0xe639: 0xe739, + 0xe63a: 0xe73a, + 0xe63b: 0xe73b, + 0xe63c: 0xe73c, + 0xe63d: 0xe73d, + 0xe63e: 0xe73e, + 0xe63f: 0xe73f, + 0xe640: 0xe740, + 0xe641: 0xe741, + 0xe642: 0xe742, + 0xe643: 0xe743, + 0xe644: 0xe744, + 0xe645: 0xe745, + 0xe646: 0xe746, + 0xe647: 0xe747, + 0xe648: 0xe748, + 0xe649: 0xe749, + 0xe64a: 0xe74a, + 0xe64b: 0xe74b, + 0xe64c: 0xe74c, + 0xe64d: 0xe74d, + 0xe64e: 0xe74e, + 0xe64f: 0xe74f, + 0xe650: 0xe750, + 0xe651: 0xe751, + 0xe652: 0xe752, + 0xe653: 0xe753, + 0xe654: 0xe754, + 0xe655: 0xe755, + 0xe656: 0xe756, + 0xe657: 0xe757, + 0xe658: 0xe758, + 0xe659: 0xe759, + 0xe65a: 0xe75a, + 0xe65b: 0xe75b, + 0xe65c: 0xe75c, + 0xe65d: 0xe75d, + 0xe65e: 0xe75e, + 0xe65f: 0xe75f, + 0xe660: 0xe760, + 0xe661: 0xe761, + 0xe662: 0xe762, + 0xe663: 0xe763, + 0xe664: 0xe764, + 0xe665: 0xe765, + 0xe666: 0xe766, + 0xe667: 0xe767, + 0xe668: 0xe768, + 0xe669: 0xe769, + 0xe66a: 0xe76a, + 0xe66b: 0xe76b, + 0xe66c: 0xe76c, + 0xe66d: 0xe76d, + 0xe66e: 0xe76e, + 0xe66f: 0xe76f, + 0xe670: 0xe770, + 0xe671: 0xe771, + 0xe672: 0xe772, + 0xe673: 0xe773, + 0xe674: 0xe774, + 0xe675: 0xe775, + 0xe676: 0xe776, + 0xe677: 0xe777, + 0xe678: 0xe778, + 0xe679: 0xe779, + 0xe67a: 0xe77a, + 0xe67b: 0xe77b, + 0xe67c: 0xe77c, + 0xe67d: 0xe77d, + 0xe67e: 0xe77e, + 0xe67f: 0xe77f, + 0xe680: 0xe780, + 0xe681: 0xe781, + 0xe682: 0xe782, + 0xe683: 0xe783, + 0xe684: 0xe784, + 0xe685: 0xe785, + 0xe686: 0xe786, + 0xe687: 0xe787, + 0xe688: 0xe788, + 0xe689: 0xe789, + 0xe68a: 0xe78a, + 0xe68b: 0xe78b, + 0xe68c: 0xe78c, + 0xe68d: 0xe78d, + 0xe68e: 0xe78e, + 0xe68f: 0xe78f, + 0xe690: 0xe790, + 0xe691: 0xe791, + 0xe692: 0xe792, + 0xe693: 0xe793, + 0xe694: 0xe794, + 0xe695: 0xe795, + 0xe696: 0xe796, + 0xe697: 0xe797, + 0xe698: 0xe798, + 0xe699: 0xe799, + 0xe69a: 0xe79a, + 0xe69b: 0xe79b, + 0xe69c: 0xe79c, + 0xe69d: 0xe79d, + 0xe69e: 0xe79e, + 0xe69f: 0xe79f, + 0xe6a0: 0xe7a0, + 0xe6a1: 0xe7a1, + 0xe6a2: 0xe7a2, + 0xe6a3: 0xe7a3, + 0xe6a4: 0xe7a4, + 0xe6a5: 0xe7a5, + 0xe6a6: 0xe7a6, + 0xe6a7: 0xe7a7, + 0xe6a8: 0xe7a8, + 0xe6a9: 0xe7a9, + 0xe6aa: 0xe7aa, + 0xe6ab: 0xe7ab, + 0xe6ac: 0xe7ac, + 0xe6ad: 0xe7ad, + 0xe6ae: 0xe7ae, + 0xe6af: 0xe7af, + 0xe6b0: 0xe7b0, + 0xe6b1: 0xe7b1, + 0xe6b2: 0xe7b2, + 0xe6b3: 0xe7b3, + 0xe6b4: 0xe7b4, + 0xe6b5: 0xe7b5, + 0xe6b6: 0xe7b6, + 0xe6b7: 0xe7b7, + 0xe6b8: 0xe7b8, + 0xe6b9: 0xe7b9, + 0xe6ba: 0xe7ba, + 0xe6bb: 0xe7bb, + 0xe6bc: 0xe7bc, + 0xe6bd: 0xe7bd, + 0xe6be: 0xe7be, + 0xe6bf: 0xe7bf, + 0xe6c0: 0xe7c0, + 0xe6c1: 0xe7c1, + 0xe6c2: 0xe7c2, + 0xe6c3: 0xe7c3, + 0xe6c4: 0xe7c4, + 0xe6c5: 0xe7c5, + 0xe6c6: 0xe7c6, + 0xe6c7: 0xe7c7, + 0xe6c8: 0xe7c8, + 0xe6c9: 0xe7c9, + 0xe6ca: 0xe7ca, + 0xe6cb: 0xe7cb, + 0xe6cc: 0xe7cc, + 0xe6cd: 0xe7cd, + 0xe6ce: 0xe7ce, + 0xe6cf: 0xe7cf, + 0xe6d0: 0xe7d0, + 0xe6d1: 0xe7d1, + 0xe6d2: 0xe7d2, + 0xe6d3: 0xe7d3, + 0xe6d4: 0xe7d4, + 0xe6d5: 0xe7d5, + 0xe6d6: 0xe7d6, + 0xe6d7: 0xe7d7, + 0xe6d8: 0xe7d8, + 0xe6d9: 0xe7d9, + 0xe6da: 0xe7da, + 0xe6db: 0xe7db, + 0xe6dc: 0xe7dc, + 0xe6dd: 0xe7dd, + 0xe6de: 0xe7de, + 0xe6df: 0xe7df, + 0xe6e0: 0xe7e0, + 0xe6e1: 0xe7e1, + 0xe6e2: 0xe7e2, + 0xe6e3: 0xe7e3, + 0xe6e4: 0xe7e4, + 0xe6e5: 0xe7e5, + 0xe6e6: 0xe7e6, + 0xe6e7: 0xe7e7, + 0xe6e8: 0xe7e8, + 0xe6e9: 0xe7e9, + 0xe6ea: 0xe7ea, + 0xe6eb: 0xe7eb, + 0xe6ec: 0xe7ec, + 0xe6ed: 0xe7ed, + 0xe6ee: 0xe7ee, + 0xe6ef: 0xe7ef, + 0xe6f0: 0xe7f0, + 0xe6f1: 0xe7f1, + 0xe6f2: 0xe7f2, + 0xe6f3: 0xe7f3, + 0xe6f4: 0xe7f4, + 0xe6f5: 0xe7f5, + 0xe6f6: 0xe7f6, + 0xe6f7: 0xe7f7, + 0xe6f8: 0xe7f8, + 0xe6f9: 0xe7f9, + 0xe6fa: 0xe7fa, + 0xe6fb: 0xe7fb, + 0xe6fc: 0xe7fc, + 0xe6fd: 0xe7fd, + 0xe6fe: 0xe7fe, + 0xe6ff: 0xe7ff, + 0xe700: 0xe800, + 0xe701: 0xe801, + 0xe702: 0xe802, + 0xe703: 0xe803, + 0xe704: 0xe804, + 0xe705: 0xe805, + 0xe706: 0xe806, + 0xe707: 0xe807, + 0xe708: 0xe808, + 0xe709: 0xe809, + 0xe70a: 0xe80a, + 0xe70b: 0xe80b, + 0xe70c: 0xe80c, + 0xe70d: 0xe80d, + 0xe70e: 0xe80e, + 0xe70f: 0xe80f, + 0xe710: 0xe810, + 0xe711: 0xe811, + 0xe712: 0xe812, + 0xe713: 0xe813, + 0xe714: 0xe814, + 0xe715: 0xe815, + 0xe716: 0xe816, + 0xe717: 0xe817, + 0xe718: 0xe818, + 0xe719: 0xe819, + 0xe71a: 0xe81a, + 0xe71b: 0xe81b, + 0xe71c: 0xe81c, + 0xe71d: 0xe81d, + 0xe71e: 0xe81e, + 0xe71f: 0xe81f, + 0xe720: 0xe820, + 0xe721: 0xe821, + 0xe722: 0xe822, + 0xe723: 0xe823, + 0xe724: 0xe824, + 0xe725: 0xe825, + 0xe726: 0xe826, + 0xe727: 0xe827, + 0xe728: 0xe828, + 0xe729: 0xe829, + 0xe72a: 0xe82a, + 0xe72b: 0xe82b, + 0xe72c: 0xe82c, + 0xe72d: 0xe82d, + 0xe72e: 0xe82e, + 0xe72f: 0xe82f, + 0xe730: 0xe830, + 0xe731: 0xe831, + 0xe732: 0xe832, + 0xe733: 0xe833, + 0xe734: 0xe834, + 0xe735: 0xe835, + 0xe736: 0xe836, + 0xe737: 0xe837, + 0xe738: 0xe838, + 0xe739: 0xe839, + 0xe73a: 0xe83a, + 0xe73b: 0xe83b, + 0xe73c: 0xe83c, + 0xe73d: 0xe83d, + 0xe73e: 0xe83e, + 0xe73f: 0xe83f, + 0xe740: 0xe840, + 0xe741: 0xe841, + 0xe742: 0xe842, + 0xe743: 0xe843, + 0xe744: 0xe844, + 0xe745: 0xe845, + 0xe746: 0xe846, + 0xe747: 0xe847, + 0xe748: 0xe848, + 0xe749: 0xe849, + 0xe74a: 0xe84a, + 0xe74b: 0xe84b, + 0xe74c: 0xe84c, + 0xe74d: 0xe84d, + 0xe74e: 0xe84e, + 0xe74f: 0xe84f, + 0xe750: 0xe850, + 0xe751: 0xe851, + 0xe752: 0xe852, + 0xe753: 0xe853, + 0xe754: 0xe854, + 0xe755: 0xe855, + 0xe756: 0xe856, + 0xe757: 0xe857, + 0xe758: 0xe858, + 0xe759: 0xe859, + 0xe75a: 0xe85a, + 0xe75b: 0xe85b, + 0xe75c: 0xe85c, + 0xe75d: 0xe85d, + 0xe75e: 0xe85e, + 0xe75f: 0xe85f, + 0xe760: 0xe860, + 0xe761: 0xe861, + 0xe762: 0xe862, + 0xe763: 0xe863, + 0xe764: 0xe864, + 0xe765: 0xe865, + 0xe766: 0xe866, + 0xe767: 0xe867, + 0xe768: 0xe868, + 0xe769: 0xe869, + 0xe76a: 0xe86a, + 0xe76b: 0xe86b, + 0xe76c: 0xe86c, + 0xe76d: 0xe86d, + 0xe76e: 0xe86e, + 0xe76f: 0xe86f, + 0xe770: 0xe870, + 0xe771: 0xe871, + 0xe772: 0xe872, + 0xe773: 0xe873, + 0xe774: 0xe874, + 0xe775: 0xe875, + 0xe776: 0xe876, + 0xe777: 0xe877, + 0xe778: 0xe878, + 0xe779: 0xe879, + 0xe77a: 0xe87a, + 0xe77b: 0xe87b, + 0xe77c: 0xe87c, + 0xe77d: 0xe87d, + 0xe77e: 0xe87e, + 0xe77f: 0xe87f, + 0xe780: 0xe880, + 0xe781: 0xe881, + 0xe782: 0xe882, + 0xe783: 0xe883, + 0xe784: 0xe884, + 0xe785: 0xe885, + 0xe786: 0xe886, + 0xe787: 0xe887, + 0xe788: 0xe888, + 0xe789: 0xe889, + 0xe78a: 0xe88a, + 0xe78b: 0xe88b, + 0xe78c: 0xe88c, + 0xe78d: 0xe88d, + 0xe78e: 0xe88e, + 0xe78f: 0xe88f, + 0xe790: 0xe890, + 0xe791: 0xe891, + 0xe792: 0xe892, + 0xe793: 0xe893, + 0xe794: 0xe894, + 0xe795: 0xe895, + 0xe796: 0xe896, + 0xe797: 0xe897, + 0xe798: 0xe898, + 0xe799: 0xe899, + 0xe79a: 0xe89a, + 0xe79b: 0xe89b, + 0xe79c: 0xe89c, + 0xe79d: 0xe89d, + 0xe79e: 0xe89e, + 0xe79f: 0xe89f, + 0xe7a0: 0xe8a0, + 0xe7a1: 0xe8a1, + 0xe7a2: 0xe8a2, + 0xe7a3: 0xe8a3, + 0xe7a4: 0xe8a4, + 0xe7a5: 0xe8a5, + 0xe7a6: 0xe8a6, + 0xe7a7: 0xe8a7, + 0xe7a8: 0xe8a8, + 0xe7a9: 0xe8a9, + 0xe7aa: 0xe8aa, + 0xe7ab: 0xe8ab, + 0xe7ac: 0xe8ac, + 0xe7ad: 0xe8ad, + 0xe7ae: 0xe8ae, + 0xe7af: 0xe8af, + 0xe7b0: 0xe8b0, + 0xe7b1: 0xe8b1, + 0xe7b2: 0xe8b2, + 0xe7b3: 0xe8b3, + 0xe7b4: 0xe8b4, + 0xe7b5: 0xe8b5, + 0xe7b6: 0xe8b6, + 0xe7b7: 0xe8b7, + 0xe7b8: 0xe8b8, + 0xe7b9: 0xe8b9, + 0xe7ba: 0xe8ba, + 0xe7bb: 0xe8bb, + 0xe7bc: 0xe8bc, + 0xe7bd: 0xe8bd, + 0xe7be: 0xe8be, + 0xe7bf: 0xe8bf, + 0xe7c0: 0xe8c0, + 0xe7c1: 0xe8c1, + 0xe7c2: 0xe8c2, + 0xe7c3: 0xe8c3, + 0xe7c4: 0xe8c4, + 0xe7c5: 0xe8c5, + 0xe7c6: 0xe8c6, + 0xe7c7: 0xe8c7, + 0xe7c8: 0xe8c8, + 0xe7c9: 0xe8c9, + 0xe7ca: 0xe8ca, + 0xe7cb: 0xe8cb, + 0xe7cc: 0xe8cc, + 0xe7cd: 0xe8cd, + 0xe7ce: 0xe8ce, + 0xe7cf: 0xe8cf, + 0xe7d0: 0xe8d0, + 0xe7d1: 0xe8d1, + 0xe7d2: 0xe8d2, + 0xe7d3: 0xe8d3, + 0xe7d4: 0xe8d4, + 0xe7d5: 0xe8d5, + 0xe7d6: 0xe8d6, + 0xe7d7: 0xe8d7, + 0xe7d8: 0xe8d8, + 0xe7d9: 0xe8d9, + 0xe7da: 0xe8da, + 0xe7db: 0xe8db, + 0xe7dc: 0xe8dc, + 0xe7dd: 0xe8dd, + 0xe7de: 0xe8de, + 0xe7df: 0xe8df, + 0xe7e0: 0xe8e0, + 0xe7e1: 0xe8e1, + 0xe7e2: 0xe8e2, + 0xe7e3: 0xe8e3, + 0xe7e4: 0xe8e4, + 0xe7e5: 0xe8e5, + 0xe7e6: 0xe8e6, + 0xe7e7: 0xe8e7, + 0xe7e8: 0xe8e8, + 0xe7e9: 0xe8e9, + 0xe7ea: 0xe8ea, + 0xe7eb: 0xe8eb, + 0xe7ec: 0xe8ec, + 0xe7ed: 0xe8ed, + 0xe7ee: 0xe8ee, + 0xe7ef: 0xe8ef, + }, + "Powerline Symbols": { + 0xe0a0: 0xe0a0, + 0xe0a1: 0xe0a1, + 0xe0a2: 0xe0a2, + 0xe0b0: 0xe0b0, + 0xe0b1: 0xe0b1, + 0xe0b2: 0xe0b2, + 0xe0b3: 0xe0b3, + }, + "Powerline Extra Symbols": { + 0xe0a3: 0xe0a3, + 0xe0b4: 0xe0b4, + 0xe0b5: 0xe0b5, + 0xe0b6: 0xe0b6, + 0xe0b7: 0xe0b7, + 0xe0b8: 0xe0b8, + 0xe0b9: 0xe0b9, + 0xe0ba: 0xe0ba, + 0xe0bb: 0xe0bb, + 0xe0bc: 0xe0bc, + 0xe0bd: 0xe0bd, + 0xe0be: 0xe0be, + 0xe0bf: 0xe0bf, + 0xe0c0: 0xe0c0, + 0xe0c1: 0xe0c1, + 0xe0c2: 0xe0c2, + 0xe0c3: 0xe0c3, + 0xe0c4: 0xe0c4, + 0xe0c5: 0xe0c5, + 0xe0c6: 0xe0c6, + 0xe0c7: 0xe0c7, + 0xe0c8: 0xe0c8, + 0xe0ca: 0xe0ca, + 0xe0cc: 0xe0cc, + 0xe0cd: 0xe0cd, + 0xe0ce: 0xe0ce, + 0xe0cf: 0xe0cf, + 0xe0d0: 0xe0d0, + 0xe0d1: 0xe0d1, + 0xe0d2: 0xe0d2, + 0xe0d4: 0xe0d4, + 0xe0d6: 0xe0d6, + 0xe0d7: 0xe0d7, + 0x2630: 0x2630, + }, + "Pomicons": { + 0xe000: 0xe000, + 0xe001: 0xe001, + 0xe002: 0xe002, + 0xe003: 0xe003, + 0xe004: 0xe004, + 0xe005: 0xe005, + 0xe006: 0xe006, + 0xe007: 0xe007, + 0xe008: 0xe008, + 0xe009: 0xe009, + 0xe00a: 0xe00a, + }, + "Font Awesome": { + 0xed00: 0xed00, + 0xed01: 0xed01, + 0xed02: 0xed02, + 0xed03: 0xed03, + 0xed04: 0xed04, + 0xed05: 0xed05, + 0xed06: 0xed06, + 0xed07: 0xed07, + 0xed08: 0xed08, + 0xed09: 0xed09, + 0xed0a: 0xed0a, + 0xed0b: 0xed0b, + 0xed0c: 0xed0c, + 0xed0d: 0xed0d, + 0xed0e: 0xed0e, + 0xed0f: 0xed0f, + 0xed10: 0xed10, + 0xed11: 0xed11, + 0xed12: 0xed12, + 0xed13: 0xed13, + 0xed14: 0xed14, + 0xed15: 0xed15, + 0xed16: 0xed16, + 0xed17: 0xed17, + 0xed18: 0xed18, + 0xed19: 0xed19, + 0xed1a: 0xed1a, + 0xed1b: 0xed1b, + 0xed1c: 0xed1c, + 0xed1d: 0xed1d, + 0xed1e: 0xed1e, + 0xed1f: 0xed1f, + 0xed20: 0xed20, + 0xed21: 0xed21, + 0xed22: 0xed22, + 0xed23: 0xed23, + 0xed24: 0xed24, + 0xed25: 0xed25, + 0xed26: 0xed26, + 0xed27: 0xed27, + 0xed28: 0xed28, + 0xed29: 0xed29, + 0xed2a: 0xed2a, + 0xed2b: 0xed2b, + 0xed2c: 0xed2c, + 0xed2d: 0xed2d, + 0xed2e: 0xed2e, + 0xed2f: 0xed2f, + 0xed30: 0xed30, + 0xed31: 0xed31, + 0xed32: 0xed32, + 0xed33: 0xed33, + 0xed34: 0xed34, + 0xed35: 0xed35, + 0xed36: 0xed36, + 0xed37: 0xed37, + 0xed38: 0xed38, + 0xed39: 0xed39, + 0xed3a: 0xed3a, + 0xed3b: 0xed3b, + 0xed3c: 0xed3c, + 0xed3d: 0xed3d, + 0xed3e: 0xed3e, + 0xed3f: 0xed3f, + 0xed40: 0xed40, + 0xed41: 0xed41, + 0xed42: 0xed42, + 0xed43: 0xed43, + 0xed44: 0xed44, + 0xed45: 0xed45, + 0xed46: 0xed46, + 0xed47: 0xed47, + 0xed48: 0xed48, + 0xed49: 0xed49, + 0xed4a: 0xed4a, + 0xed4b: 0xed4b, + 0xed4c: 0xed4c, + 0xed4d: 0xed4d, + 0xed4e: 0xed4e, + 0xed4f: 0xed4f, + 0xed50: 0xed50, + 0xed51: 0xed51, + 0xed52: 0xed52, + 0xed53: 0xed53, + 0xed54: 0xed54, + 0xed55: 0xed55, + 0xed56: 0xed56, + 0xed57: 0xed57, + 0xed58: 0xed58, + 0xed59: 0xed59, + 0xed5a: 0xed5a, + 0xed5b: 0xed5b, + 0xed5c: 0xed5c, + 0xed5d: 0xed5d, + 0xed5e: 0xed5e, + 0xed5f: 0xed5f, + 0xed60: 0xed60, + 0xed61: 0xed61, + 0xed62: 0xed62, + 0xed63: 0xed63, + 0xed64: 0xed64, + 0xed65: 0xed65, + 0xed66: 0xed66, + 0xed67: 0xed67, + 0xed68: 0xed68, + 0xed69: 0xed69, + 0xed6a: 0xed6a, + 0xed6b: 0xed6b, + 0xed6c: 0xed6c, + 0xed6d: 0xed6d, + 0xed6e: 0xed6e, + 0xed6f: 0xed6f, + 0xed70: 0xed70, + 0xed71: 0xed71, + 0xed72: 0xed72, + 0xed73: 0xed73, + 0xed74: 0xed74, + 0xed75: 0xed75, + 0xed76: 0xed76, + 0xed77: 0xed77, + 0xed78: 0xed78, + 0xed79: 0xed79, + 0xed7a: 0xed7a, + 0xed7b: 0xed7b, + 0xed7c: 0xed7c, + 0xed7d: 0xed7d, + 0xed7e: 0xed7e, + 0xed7f: 0xed7f, + 0xed80: 0xed80, + 0xed81: 0xed81, + 0xed82: 0xed82, + 0xed83: 0xed83, + 0xed84: 0xed84, + 0xed85: 0xed85, + 0xed86: 0xed86, + 0xed87: 0xed87, + 0xed88: 0xed88, + 0xed89: 0xed89, + 0xed8a: 0xed8a, + 0xed8b: 0xed8b, + 0xed8c: 0xed8c, + 0xed8d: 0xed8d, + 0xed8e: 0xed8e, + 0xed8f: 0xed8f, + 0xed90: 0xed90, + 0xed91: 0xed91, + 0xed92: 0xed92, + 0xed93: 0xed93, + 0xed94: 0xed94, + 0xed95: 0xed95, + 0xed96: 0xed96, + 0xed97: 0xed97, + 0xed98: 0xed98, + 0xed99: 0xed99, + 0xed9a: 0xed9a, + 0xed9b: 0xed9b, + 0xed9c: 0xed9c, + 0xed9d: 0xed9d, + 0xed9e: 0xed9e, + 0xed9f: 0xed9f, + 0xeda0: 0xeda0, + 0xeda1: 0xeda1, + 0xeda2: 0xeda2, + 0xeda3: 0xeda3, + 0xeda4: 0xeda4, + 0xeda5: 0xeda5, + 0xeda6: 0xeda6, + 0xeda7: 0xeda7, + 0xeda8: 0xeda8, + 0xeda9: 0xeda9, + 0xedaa: 0xedaa, + 0xedab: 0xedab, + 0xedac: 0xedac, + 0xedad: 0xedad, + 0xedae: 0xedae, + 0xedaf: 0xedaf, + 0xedb0: 0xedb0, + 0xedb1: 0xedb1, + 0xedb2: 0xedb2, + 0xedb3: 0xedb3, + 0xedb4: 0xedb4, + 0xedb5: 0xedb5, + 0xedb6: 0xedb6, + 0xedb7: 0xedb7, + 0xedb8: 0xedb8, + 0xedb9: 0xedb9, + 0xedba: 0xedba, + 0xedbb: 0xedbb, + 0xedbc: 0xedbc, + 0xedbd: 0xedbd, + 0xedbe: 0xedbe, + 0xedbf: 0xedbf, + 0xedc0: 0xedc0, + 0xedc1: 0xedc1, + 0xedc2: 0xedc2, + 0xedc3: 0xedc3, + 0xedc4: 0xedc4, + 0xedc5: 0xedc5, + 0xedc6: 0xedc6, + 0xedc7: 0xedc7, + 0xedc8: 0xedc8, + 0xedc9: 0xedc9, + 0xedca: 0xedca, + 0xedcb: 0xedcb, + 0xedcc: 0xedcc, + 0xedcd: 0xedcd, + 0xedce: 0xedce, + 0xedcf: 0xedcf, + 0xedd0: 0xedd0, + 0xedd1: 0xedd1, + 0xedd2: 0xedd2, + 0xedd3: 0xedd3, + 0xedd4: 0xedd4, + 0xedd5: 0xedd5, + 0xedd6: 0xedd6, + 0xedd7: 0xedd7, + 0xedd8: 0xedd8, + 0xedd9: 0xedd9, + 0xedda: 0xedda, + 0xeddb: 0xeddb, + 0xeddc: 0xeddc, + 0xeddd: 0xeddd, + 0xedde: 0xedde, + 0xeddf: 0xeddf, + 0xede0: 0xede0, + 0xede1: 0xede1, + 0xede2: 0xede2, + 0xede3: 0xede3, + 0xede4: 0xede4, + 0xede5: 0xede5, + 0xede6: 0xede6, + 0xede7: 0xede7, + 0xede8: 0xede8, + 0xede9: 0xede9, + 0xedea: 0xedea, + 0xedeb: 0xedeb, + 0xedec: 0xedec, + 0xeded: 0xeded, + 0xedee: 0xedee, + 0xedef: 0xedef, + 0xedf0: 0xedf0, + 0xedf1: 0xedf1, + 0xedf2: 0xedf2, + 0xedf3: 0xedf3, + 0xedf4: 0xedf4, + 0xedf5: 0xedf5, + 0xedf6: 0xedf6, + 0xedf7: 0xedf7, + 0xedf8: 0xedf8, + 0xedf9: 0xedf9, + 0xedfa: 0xedfa, + 0xedfb: 0xedfb, + 0xedfc: 0xedfc, + 0xedfd: 0xedfd, + 0xedfe: 0xedfe, + 0xedff: 0xedff, + 0xee0c: 0xee0c, + 0xee0d: 0xee0d, + 0xee0e: 0xee0e, + 0xee0f: 0xee0f, + 0xee10: 0xee10, + 0xee11: 0xee11, + 0xee12: 0xee12, + 0xee13: 0xee13, + 0xee14: 0xee14, + 0xee15: 0xee15, + 0xee16: 0xee16, + 0xee17: 0xee17, + 0xee18: 0xee18, + 0xee19: 0xee19, + 0xee1a: 0xee1a, + 0xee1b: 0xee1b, + 0xee1c: 0xee1c, + 0xee1d: 0xee1d, + 0xee1e: 0xee1e, + 0xee1f: 0xee1f, + 0xee20: 0xee20, + 0xee21: 0xee21, + 0xee22: 0xee22, + 0xee23: 0xee23, + 0xee24: 0xee24, + 0xee25: 0xee25, + 0xee26: 0xee26, + 0xee27: 0xee27, + 0xee28: 0xee28, + 0xee29: 0xee29, + 0xee2a: 0xee2a, + 0xee2b: 0xee2b, + 0xee2c: 0xee2c, + 0xee2d: 0xee2d, + 0xee2e: 0xee2e, + 0xee2f: 0xee2f, + 0xee30: 0xee30, + 0xee31: 0xee31, + 0xee32: 0xee32, + 0xee33: 0xee33, + 0xee34: 0xee34, + 0xee35: 0xee35, + 0xee36: 0xee36, + 0xee37: 0xee37, + 0xee38: 0xee38, + 0xee39: 0xee39, + 0xee3a: 0xee3a, + 0xee3b: 0xee3b, + 0xee3c: 0xee3c, + 0xee3d: 0xee3d, + 0xee3e: 0xee3e, + 0xee3f: 0xee3f, + 0xee40: 0xee40, + 0xee41: 0xee41, + 0xee42: 0xee42, + 0xee43: 0xee43, + 0xee44: 0xee44, + 0xee45: 0xee45, + 0xee46: 0xee46, + 0xee47: 0xee47, + 0xee48: 0xee48, + 0xee49: 0xee49, + 0xee4a: 0xee4a, + 0xee4b: 0xee4b, + 0xee4c: 0xee4c, + 0xee4d: 0xee4d, + 0xee4e: 0xee4e, + 0xee4f: 0xee4f, + 0xee50: 0xee50, + 0xee51: 0xee51, + 0xee52: 0xee52, + 0xee53: 0xee53, + 0xee54: 0xee54, + 0xee55: 0xee55, + 0xee56: 0xee56, + 0xee57: 0xee57, + 0xee58: 0xee58, + 0xee59: 0xee59, + 0xee5a: 0xee5a, + 0xee5b: 0xee5b, + 0xee5c: 0xee5c, + 0xee5d: 0xee5d, + 0xee5e: 0xee5e, + 0xee5f: 0xee5f, + 0xee60: 0xee60, + 0xee61: 0xee61, + 0xee62: 0xee62, + 0xee63: 0xee63, + 0xee64: 0xee64, + 0xee65: 0xee65, + 0xee66: 0xee66, + 0xee67: 0xee67, + 0xee68: 0xee68, + 0xee69: 0xee69, + 0xee6a: 0xee6a, + 0xee6b: 0xee6b, + 0xee6c: 0xee6c, + 0xee6d: 0xee6d, + 0xee6e: 0xee6e, + 0xee6f: 0xee6f, + 0xee70: 0xee70, + 0xee71: 0xee71, + 0xee72: 0xee72, + 0xee73: 0xee73, + 0xee74: 0xee74, + 0xee75: 0xee75, + 0xee76: 0xee76, + 0xee77: 0xee77, + 0xee78: 0xee78, + 0xee79: 0xee79, + 0xee7a: 0xee7a, + 0xee7b: 0xee7b, + 0xee7c: 0xee7c, + 0xee7d: 0xee7d, + 0xee7e: 0xee7e, + 0xee7f: 0xee7f, + 0xee80: 0xee80, + 0xee81: 0xee81, + 0xee82: 0xee82, + 0xee83: 0xee83, + 0xee84: 0xee84, + 0xee85: 0xee85, + 0xee86: 0xee86, + 0xee87: 0xee87, + 0xee88: 0xee88, + 0xee89: 0xee89, + 0xee8a: 0xee8a, + 0xee8b: 0xee8b, + 0xee8c: 0xee8c, + 0xee8d: 0xee8d, + 0xee8e: 0xee8e, + 0xee8f: 0xee8f, + 0xee90: 0xee90, + 0xee91: 0xee91, + 0xee92: 0xee92, + 0xee93: 0xee93, + 0xee94: 0xee94, + 0xee95: 0xee95, + 0xee96: 0xee96, + 0xee97: 0xee97, + 0xee98: 0xee98, + 0xee99: 0xee99, + 0xee9a: 0xee9a, + 0xee9b: 0xee9b, + 0xee9c: 0xee9c, + 0xee9d: 0xee9d, + 0xee9e: 0xee9e, + 0xee9f: 0xee9f, + 0xeea0: 0xeea0, + 0xeea1: 0xeea1, + 0xeea2: 0xeea2, + 0xeea3: 0xeea3, + 0xeea4: 0xeea4, + 0xeea5: 0xeea5, + 0xeea6: 0xeea6, + 0xeea7: 0xeea7, + 0xeea8: 0xeea8, + 0xeea9: 0xeea9, + 0xeeaa: 0xeeaa, + 0xeeab: 0xeeab, + 0xeeac: 0xeeac, + 0xeead: 0xeead, + 0xeeae: 0xeeae, + 0xeeaf: 0xeeaf, + 0xeeb0: 0xeeb0, + 0xeeb1: 0xeeb1, + 0xeeb2: 0xeeb2, + 0xeeb3: 0xeeb3, + 0xeeb4: 0xeeb4, + 0xeeb5: 0xeeb5, + 0xeeb6: 0xeeb6, + 0xeeb7: 0xeeb7, + 0xeeb8: 0xeeb8, + 0xeeb9: 0xeeb9, + 0xeeba: 0xeeba, + 0xeebb: 0xeebb, + 0xeebc: 0xeebc, + 0xeebd: 0xeebd, + 0xeebe: 0xeebe, + 0xeebf: 0xeebf, + 0xeec0: 0xeec0, + 0xeec1: 0xeec1, + 0xeec2: 0xeec2, + 0xeec3: 0xeec3, + 0xeec4: 0xeec4, + 0xeec5: 0xeec5, + 0xeec6: 0xeec6, + 0xeec7: 0xeec7, + 0xeec8: 0xeec8, + 0xeec9: 0xeec9, + 0xeeca: 0xeeca, + 0xeecb: 0xeecb, + 0xeecc: 0xeecc, + 0xeecd: 0xeecd, + 0xeece: 0xeece, + 0xeecf: 0xeecf, + 0xeed0: 0xeed0, + 0xeed1: 0xeed1, + 0xeed2: 0xeed2, + 0xeed3: 0xeed3, + 0xeed4: 0xeed4, + 0xeed5: 0xeed5, + 0xeed6: 0xeed6, + 0xeed7: 0xeed7, + 0xeed8: 0xeed8, + 0xeed9: 0xeed9, + 0xeeda: 0xeeda, + 0xeedb: 0xeedb, + 0xeedc: 0xeedc, + 0xeedd: 0xeedd, + 0xeede: 0xeede, + 0xeedf: 0xeedf, + 0xeee0: 0xeee0, + 0xeee1: 0xeee1, + 0xeee2: 0xeee2, + 0xeee3: 0xeee3, + 0xeee4: 0xeee4, + 0xeee5: 0xeee5, + 0xeee6: 0xeee6, + 0xeee7: 0xeee7, + 0xeee8: 0xeee8, + 0xeee9: 0xeee9, + 0xeeea: 0xeeea, + 0xeeeb: 0xeeeb, + 0xeeec: 0xeeec, + 0xeeed: 0xeeed, + 0xeeee: 0xeeee, + 0xeeef: 0xeeef, + 0xeef0: 0xeef0, + 0xeef1: 0xeef1, + 0xeef2: 0xeef2, + 0xeef3: 0xeef3, + 0xeef4: 0xeef4, + 0xeef5: 0xeef5, + 0xeef6: 0xeef6, + 0xeef7: 0xeef7, + 0xeef8: 0xeef8, + 0xeef9: 0xeef9, + 0xeefa: 0xeefa, + 0xeefb: 0xeefb, + 0xeefc: 0xeefc, + 0xeefd: 0xeefd, + 0xeefe: 0xeefe, + 0xeeff: 0xeeff, + 0xef00: 0xef00, + 0xef01: 0xef01, + 0xef02: 0xef02, + 0xef03: 0xef03, + 0xef04: 0xef04, + 0xef05: 0xef05, + 0xef06: 0xef06, + 0xef07: 0xef07, + 0xef08: 0xef08, + 0xef09: 0xef09, + 0xef0a: 0xef0a, + 0xef0b: 0xef0b, + 0xef0c: 0xef0c, + 0xef0d: 0xef0d, + 0xef0e: 0xef0e, + 0xef0f: 0xef0f, + 0xef10: 0xef10, + 0xef11: 0xef11, + 0xef12: 0xef12, + 0xef13: 0xef13, + 0xef14: 0xef14, + 0xef15: 0xef15, + 0xef16: 0xef16, + 0xef17: 0xef17, + 0xef18: 0xef18, + 0xef19: 0xef19, + 0xef1a: 0xef1a, + 0xef1b: 0xef1b, + 0xef1c: 0xef1c, + 0xef1d: 0xef1d, + 0xef1e: 0xef1e, + 0xef1f: 0xef1f, + 0xef20: 0xef20, + 0xef21: 0xef21, + 0xef22: 0xef22, + 0xef23: 0xef23, + 0xef24: 0xef24, + 0xef25: 0xef25, + 0xef26: 0xef26, + 0xef27: 0xef27, + 0xef28: 0xef28, + 0xef29: 0xef29, + 0xef2a: 0xef2a, + 0xef2b: 0xef2b, + 0xef2c: 0xef2c, + 0xef2d: 0xef2d, + 0xef2e: 0xef2e, + 0xef2f: 0xef2f, + 0xef30: 0xef30, + 0xef31: 0xef31, + 0xef32: 0xef32, + 0xef33: 0xef33, + 0xef34: 0xef34, + 0xef35: 0xef35, + 0xef36: 0xef36, + 0xef37: 0xef37, + 0xef38: 0xef38, + 0xef39: 0xef39, + 0xef3a: 0xef3a, + 0xef3b: 0xef3b, + 0xef3c: 0xef3c, + 0xef3d: 0xef3d, + 0xef3e: 0xef3e, + 0xef3f: 0xef3f, + 0xef40: 0xef40, + 0xef41: 0xef41, + 0xef42: 0xef42, + 0xef43: 0xef43, + 0xef44: 0xef44, + 0xef45: 0xef45, + 0xef46: 0xef46, + 0xef47: 0xef47, + 0xef48: 0xef48, + 0xef49: 0xef49, + 0xef4a: 0xef4a, + 0xef4b: 0xef4b, + 0xef4c: 0xef4c, + 0xef4d: 0xef4d, + 0xef4e: 0xef4e, + 0xef4f: 0xef4f, + 0xef50: 0xef50, + 0xef51: 0xef51, + 0xef52: 0xef52, + 0xef53: 0xef53, + 0xef54: 0xef54, + 0xef55: 0xef55, + 0xef56: 0xef56, + 0xef57: 0xef57, + 0xef58: 0xef58, + 0xef59: 0xef59, + 0xef5a: 0xef5a, + 0xef5b: 0xef5b, + 0xef5c: 0xef5c, + 0xef5d: 0xef5d, + 0xef5e: 0xef5e, + 0xef5f: 0xef5f, + 0xef60: 0xef60, + 0xef61: 0xef61, + 0xef62: 0xef62, + 0xef63: 0xef63, + 0xef64: 0xef64, + 0xef65: 0xef65, + 0xef66: 0xef66, + 0xef67: 0xef67, + 0xef68: 0xef68, + 0xef69: 0xef69, + 0xef6a: 0xef6a, + 0xef6b: 0xef6b, + 0xef6c: 0xef6c, + 0xef6d: 0xef6d, + 0xef6e: 0xef6e, + 0xef6f: 0xef6f, + 0xef70: 0xef70, + 0xef71: 0xef71, + 0xef72: 0xef72, + 0xef73: 0xef73, + 0xef74: 0xef74, + 0xef75: 0xef75, + 0xef76: 0xef76, + 0xef77: 0xef77, + 0xef78: 0xef78, + 0xef79: 0xef79, + 0xef7a: 0xef7a, + 0xef7b: 0xef7b, + 0xef7c: 0xef7c, + 0xef7d: 0xef7d, + 0xef7e: 0xef7e, + 0xef7f: 0xef7f, + 0xef80: 0xef80, + 0xef81: 0xef81, + 0xef82: 0xef82, + 0xef83: 0xef83, + 0xef84: 0xef84, + 0xef85: 0xef85, + 0xef86: 0xef86, + 0xef87: 0xef87, + 0xef88: 0xef88, + 0xef89: 0xef89, + 0xef8a: 0xef8a, + 0xef8b: 0xef8b, + 0xef8c: 0xef8c, + 0xef8d: 0xef8d, + 0xef8e: 0xef8e, + 0xef8f: 0xef8f, + 0xef90: 0xef90, + 0xef91: 0xef91, + 0xef92: 0xef92, + 0xef93: 0xef93, + 0xef94: 0xef94, + 0xef95: 0xef95, + 0xef96: 0xef96, + 0xef97: 0xef97, + 0xef98: 0xef98, + 0xef99: 0xef99, + 0xef9a: 0xef9a, + 0xef9b: 0xef9b, + 0xef9c: 0xef9c, + 0xef9d: 0xef9d, + 0xef9e: 0xef9e, + 0xef9f: 0xef9f, + 0xefa0: 0xefa0, + 0xefa1: 0xefa1, + 0xefa2: 0xefa2, + 0xefa3: 0xefa3, + 0xefa4: 0xefa4, + 0xefa5: 0xefa5, + 0xefa6: 0xefa6, + 0xefa7: 0xefa7, + 0xefa8: 0xefa8, + 0xefa9: 0xefa9, + 0xefaa: 0xefaa, + 0xefab: 0xefab, + 0xefac: 0xefac, + 0xefad: 0xefad, + 0xefae: 0xefae, + 0xefaf: 0xefaf, + 0xefb0: 0xefb0, + 0xefb1: 0xefb1, + 0xefb2: 0xefb2, + 0xefb3: 0xefb3, + 0xefb4: 0xefb4, + 0xefb5: 0xefb5, + 0xefb6: 0xefb6, + 0xefb7: 0xefb7, + 0xefb8: 0xefb8, + 0xefb9: 0xefb9, + 0xefba: 0xefba, + 0xefbb: 0xefbb, + 0xefbc: 0xefbc, + 0xefbd: 0xefbd, + 0xefbe: 0xefbe, + 0xefbf: 0xefbf, + 0xefc0: 0xefc0, + 0xefc1: 0xefc1, + 0xefc2: 0xefc2, + 0xefc3: 0xefc3, + 0xefc4: 0xefc4, + 0xefc5: 0xefc5, + 0xefc6: 0xefc6, + 0xefc7: 0xefc7, + 0xefc8: 0xefc8, + 0xefc9: 0xefc9, + 0xefca: 0xefca, + 0xefcb: 0xefcb, + 0xefcc: 0xefcc, + 0xefcd: 0xefcd, + 0xefce: 0xefce, + 0xf000: 0xf000, + 0xf001: 0xf001, + 0xf002: 0xf002, + 0xf003: 0xf003, + 0xf004: 0xf004, + 0xf005: 0xf005, + 0xf006: 0xf006, + 0xf007: 0xf007, + 0xf008: 0xf008, + 0xf009: 0xf009, + 0xf00a: 0xf00a, + 0xf00b: 0xf00b, + 0xf00c: 0xf00c, + 0xf00d: 0xf00d, + 0xf00e: 0xf00e, + 0xf00f: 0xf00f, + 0xf010: 0xf010, + 0xf011: 0xf011, + 0xf012: 0xf012, + 0xf013: 0xf013, + 0xf014: 0xf014, + 0xf015: 0xf015, + 0xf016: 0xf016, + 0xf017: 0xf017, + 0xf018: 0xf018, + 0xf019: 0xf019, + 0xf01a: 0xf01a, + 0xf01b: 0xf01b, + 0xf01c: 0xf01c, + 0xf01d: 0xf01d, + 0xf01e: 0xf01e, + 0xf01f: 0xf01f, + 0xf020: 0xf020, + 0xf021: 0xf021, + 0xf022: 0xf022, + 0xf023: 0xf023, + 0xf024: 0xf024, + 0xf025: 0xf025, + 0xf026: 0xf026, + 0xf027: 0xf027, + 0xf028: 0xf028, + 0xf029: 0xf029, + 0xf02a: 0xf02a, + 0xf02b: 0xf02b, + 0xf02c: 0xf02c, + 0xf02d: 0xf02d, + 0xf02e: 0xf02e, + 0xf02f: 0xf02f, + 0xf030: 0xf030, + 0xf031: 0xf031, + 0xf032: 0xf032, + 0xf033: 0xf033, + 0xf034: 0xf034, + 0xf035: 0xf035, + 0xf036: 0xf036, + 0xf037: 0xf037, + 0xf038: 0xf038, + 0xf039: 0xf039, + 0xf03a: 0xf03a, + 0xf03b: 0xf03b, + 0xf03c: 0xf03c, + 0xf03d: 0xf03d, + 0xf03e: 0xf03e, + 0xf03f: 0xf03f, + 0xf040: 0xf040, + 0xf041: 0xf041, + 0xf042: 0xf042, + 0xf043: 0xf043, + 0xf044: 0xf044, + 0xf045: 0xf045, + 0xf046: 0xf046, + 0xf047: 0xf047, + 0xf048: 0xf048, + 0xf049: 0xf049, + 0xf04a: 0xf04a, + 0xf04b: 0xf04b, + 0xf04c: 0xf04c, + 0xf04d: 0xf04d, + 0xf04e: 0xf04e, + 0xf04f: 0xf04f, + 0xf050: 0xf050, + 0xf051: 0xf051, + 0xf052: 0xf052, + 0xf053: 0xf053, + 0xf054: 0xf054, + 0xf055: 0xf055, + 0xf056: 0xf056, + 0xf057: 0xf057, + 0xf058: 0xf058, + 0xf059: 0xf059, + 0xf05a: 0xf05a, + 0xf05b: 0xf05b, + 0xf05c: 0xf05c, + 0xf05d: 0xf05d, + 0xf05e: 0xf05e, + 0xf05f: 0xf05f, + 0xf060: 0xf060, + 0xf061: 0xf061, + 0xf062: 0xf062, + 0xf063: 0xf063, + 0xf064: 0xf064, + 0xf065: 0xf065, + 0xf066: 0xf066, + 0xf067: 0xf067, + 0xf068: 0xf068, + 0xf069: 0xf069, + 0xf06a: 0xf06a, + 0xf06b: 0xf06b, + 0xf06c: 0xf06c, + 0xf06d: 0xf06d, + 0xf06e: 0xf06e, + 0xf06f: 0xf06f, + 0xf070: 0xf070, + 0xf071: 0xf071, + 0xf072: 0xf072, + 0xf073: 0xf073, + 0xf074: 0xf074, + 0xf075: 0xf075, + 0xf076: 0xf076, + 0xf077: 0xf077, + 0xf078: 0xf078, + 0xf079: 0xf079, + 0xf07a: 0xf07a, + 0xf07b: 0xf07b, + 0xf07c: 0xf07c, + 0xf07d: 0xf07d, + 0xf07e: 0xf07e, + 0xf07f: 0xf07f, + 0xf080: 0xf080, + 0xf081: 0xf081, + 0xf082: 0xf082, + 0xf083: 0xf083, + 0xf084: 0xf084, + 0xf085: 0xf085, + 0xf086: 0xf086, + 0xf087: 0xf087, + 0xf088: 0xf088, + 0xf089: 0xf089, + 0xf08a: 0xf08a, + 0xf08b: 0xf08b, + 0xf08c: 0xf08c, + 0xf08d: 0xf08d, + 0xf08e: 0xf08e, + 0xf08f: 0xf08f, + 0xf090: 0xf090, + 0xf091: 0xf091, + 0xf092: 0xf092, + 0xf093: 0xf093, + 0xf094: 0xf094, + 0xf095: 0xf095, + 0xf096: 0xf096, + 0xf097: 0xf097, + 0xf098: 0xf098, + 0xf099: 0xf099, + 0xf09a: 0xf09a, + 0xf09b: 0xf09b, + 0xf09c: 0xf09c, + 0xf09d: 0xf09d, + 0xf09e: 0xf09e, + 0xf09f: 0xf09f, + 0xf0a0: 0xf0a0, + 0xf0a1: 0xf0a1, + 0xf0a2: 0xf0a2, + 0xf0a3: 0xf0a3, + 0xf0a4: 0xf0a4, + 0xf0a5: 0xf0a5, + 0xf0a6: 0xf0a6, + 0xf0a7: 0xf0a7, + 0xf0a8: 0xf0a8, + 0xf0a9: 0xf0a9, + 0xf0aa: 0xf0aa, + 0xf0ab: 0xf0ab, + 0xf0ac: 0xf0ac, + 0xf0ad: 0xf0ad, + 0xf0ae: 0xf0ae, + 0xf0af: 0xf0af, + 0xf0b0: 0xf0b0, + 0xf0b1: 0xf0b1, + 0xf0b2: 0xf0b2, + 0xf0b3: 0xf0b3, + 0xf0b4: 0xf0b4, + 0xf0b5: 0xf0b5, + 0xf0b6: 0xf0b6, + 0xf0b7: 0xf0b7, + 0xf0b8: 0xf0b8, + 0xf0b9: 0xf0b9, + 0xf0ba: 0xf0ba, + 0xf0bb: 0xf0bb, + 0xf0bc: 0xf0bc, + 0xf0bd: 0xf0bd, + 0xf0be: 0xf0be, + 0xf0bf: 0xf0bf, + 0xf0c0: 0xf0c0, + 0xf0c1: 0xf0c1, + 0xf0c2: 0xf0c2, + 0xf0c3: 0xf0c3, + 0xf0c4: 0xf0c4, + 0xf0c5: 0xf0c5, + 0xf0c6: 0xf0c6, + 0xf0c7: 0xf0c7, + 0xf0c8: 0xf0c8, + 0xf0c9: 0xf0c9, + 0xf0ca: 0xf0ca, + 0xf0cb: 0xf0cb, + 0xf0cc: 0xf0cc, + 0xf0cd: 0xf0cd, + 0xf0ce: 0xf0ce, + 0xf0cf: 0xf0cf, + 0xf0d0: 0xf0d0, + 0xf0d1: 0xf0d1, + 0xf0d2: 0xf0d2, + 0xf0d3: 0xf0d3, + 0xf0d4: 0xf0d4, + 0xf0d5: 0xf0d5, + 0xf0d6: 0xf0d6, + 0xf0d7: 0xf0d7, + 0xf0d8: 0xf0d8, + 0xf0d9: 0xf0d9, + 0xf0da: 0xf0da, + 0xf0db: 0xf0db, + 0xf0dc: 0xf0dc, + 0xf0dd: 0xf0dd, + 0xf0de: 0xf0de, + 0xf0df: 0xf0df, + 0xf0e0: 0xf0e0, + 0xf0e1: 0xf0e1, + 0xf0e2: 0xf0e2, + 0xf0e3: 0xf0e3, + 0xf0e4: 0xf0e4, + 0xf0e5: 0xf0e5, + 0xf0e6: 0xf0e6, + 0xf0e7: 0xf0e7, + 0xf0e8: 0xf0e8, + 0xf0e9: 0xf0e9, + 0xf0ea: 0xf0ea, + 0xf0eb: 0xf0eb, + 0xf0ec: 0xf0ec, + 0xf0ed: 0xf0ed, + 0xf0ee: 0xf0ee, + 0xf0ef: 0xf0ef, + 0xf0f0: 0xf0f0, + 0xf0f1: 0xf0f1, + 0xf0f2: 0xf0f2, + 0xf0f3: 0xf0f3, + 0xf0f4: 0xf0f4, + 0xf0f5: 0xf0f5, + 0xf0f6: 0xf0f6, + 0xf0f7: 0xf0f7, + 0xf0f8: 0xf0f8, + 0xf0f9: 0xf0f9, + 0xf0fa: 0xf0fa, + 0xf0fb: 0xf0fb, + 0xf0fc: 0xf0fc, + 0xf0fd: 0xf0fd, + 0xf0fe: 0xf0fe, + 0xf0ff: 0xf0ff, + 0xf100: 0xf100, + 0xf101: 0xf101, + 0xf102: 0xf102, + 0xf103: 0xf103, + 0xf104: 0xf104, + 0xf105: 0xf105, + 0xf106: 0xf106, + 0xf107: 0xf107, + 0xf108: 0xf108, + 0xf109: 0xf109, + 0xf10a: 0xf10a, + 0xf10b: 0xf10b, + 0xf10c: 0xf10c, + 0xf10d: 0xf10d, + 0xf10e: 0xf10e, + 0xf10f: 0xf10f, + 0xf110: 0xf110, + 0xf111: 0xf111, + 0xf112: 0xf112, + 0xf113: 0xf113, + 0xf114: 0xf114, + 0xf115: 0xf115, + 0xf116: 0xf116, + 0xf117: 0xf117, + 0xf118: 0xf118, + 0xf119: 0xf119, + 0xf11a: 0xf11a, + 0xf11b: 0xf11b, + 0xf11c: 0xf11c, + 0xf11d: 0xf11d, + 0xf11e: 0xf11e, + 0xf11f: 0xf11f, + 0xf120: 0xf120, + 0xf121: 0xf121, + 0xf122: 0xf122, + 0xf123: 0xf123, + 0xf124: 0xf124, + 0xf125: 0xf125, + 0xf126: 0xf126, + 0xf127: 0xf127, + 0xf128: 0xf128, + 0xf129: 0xf129, + 0xf12a: 0xf12a, + 0xf12b: 0xf12b, + 0xf12c: 0xf12c, + 0xf12d: 0xf12d, + 0xf12e: 0xf12e, + 0xf12f: 0xf12f, + 0xf130: 0xf130, + 0xf131: 0xf131, + 0xf132: 0xf132, + 0xf133: 0xf133, + 0xf134: 0xf134, + 0xf135: 0xf135, + 0xf136: 0xf136, + 0xf137: 0xf137, + 0xf138: 0xf138, + 0xf139: 0xf139, + 0xf13a: 0xf13a, + 0xf13b: 0xf13b, + 0xf13c: 0xf13c, + 0xf13d: 0xf13d, + 0xf13e: 0xf13e, + 0xf13f: 0xf13f, + 0xf140: 0xf140, + 0xf141: 0xf141, + 0xf142: 0xf142, + 0xf143: 0xf143, + 0xf144: 0xf144, + 0xf145: 0xf145, + 0xf146: 0xf146, + 0xf147: 0xf147, + 0xf148: 0xf148, + 0xf149: 0xf149, + 0xf14a: 0xf14a, + 0xf14b: 0xf14b, + 0xf14c: 0xf14c, + 0xf14d: 0xf14d, + 0xf14e: 0xf14e, + 0xf14f: 0xf14f, + 0xf150: 0xf150, + 0xf151: 0xf151, + 0xf152: 0xf152, + 0xf153: 0xf153, + 0xf154: 0xf154, + 0xf155: 0xf155, + 0xf156: 0xf156, + 0xf157: 0xf157, + 0xf158: 0xf158, + 0xf159: 0xf159, + 0xf15a: 0xf15a, + 0xf15b: 0xf15b, + 0xf15c: 0xf15c, + 0xf15d: 0xf15d, + 0xf15e: 0xf15e, + 0xf15f: 0xf15f, + 0xf160: 0xf160, + 0xf161: 0xf161, + 0xf162: 0xf162, + 0xf163: 0xf163, + 0xf164: 0xf164, + 0xf165: 0xf165, + 0xf166: 0xf166, + 0xf167: 0xf167, + 0xf168: 0xf168, + 0xf169: 0xf169, + 0xf16a: 0xf16a, + 0xf16b: 0xf16b, + 0xf16c: 0xf16c, + 0xf16d: 0xf16d, + 0xf16e: 0xf16e, + 0xf16f: 0xf16f, + 0xf170: 0xf170, + 0xf171: 0xf171, + 0xf172: 0xf172, + 0xf173: 0xf173, + 0xf174: 0xf174, + 0xf175: 0xf175, + 0xf176: 0xf176, + 0xf177: 0xf177, + 0xf178: 0xf178, + 0xf179: 0xf179, + 0xf17a: 0xf17a, + 0xf17b: 0xf17b, + 0xf17c: 0xf17c, + 0xf17d: 0xf17d, + 0xf17e: 0xf17e, + 0xf17f: 0xf17f, + 0xf180: 0xf180, + 0xf181: 0xf181, + 0xf182: 0xf182, + 0xf183: 0xf183, + 0xf184: 0xf184, + 0xf185: 0xf185, + 0xf186: 0xf186, + 0xf187: 0xf187, + 0xf188: 0xf188, + 0xf189: 0xf189, + 0xf18a: 0xf18a, + 0xf18b: 0xf18b, + 0xf18c: 0xf18c, + 0xf18d: 0xf18d, + 0xf18e: 0xf18e, + 0xf18f: 0xf18f, + 0xf190: 0xf190, + 0xf191: 0xf191, + 0xf192: 0xf192, + 0xf193: 0xf193, + 0xf194: 0xf194, + 0xf195: 0xf195, + 0xf196: 0xf196, + 0xf197: 0xf197, + 0xf198: 0xf198, + 0xf199: 0xf199, + 0xf19a: 0xf19a, + 0xf19b: 0xf19b, + 0xf19c: 0xf19c, + 0xf19d: 0xf19d, + 0xf19e: 0xf19e, + 0xf19f: 0xf19f, + 0xf1a0: 0xf1a0, + 0xf1a1: 0xf1a1, + 0xf1a2: 0xf1a2, + 0xf1a3: 0xf1a3, + 0xf1a4: 0xf1a4, + 0xf1a5: 0xf1a5, + 0xf1a6: 0xf1a6, + 0xf1a7: 0xf1a7, + 0xf1a8: 0xf1a8, + 0xf1a9: 0xf1a9, + 0xf1aa: 0xf1aa, + 0xf1ab: 0xf1ab, + 0xf1ac: 0xf1ac, + 0xf1ad: 0xf1ad, + 0xf1ae: 0xf1ae, + 0xf1af: 0xf1af, + 0xf1b0: 0xf1b0, + 0xf1b1: 0xf1b1, + 0xf1b2: 0xf1b2, + 0xf1b3: 0xf1b3, + 0xf1b4: 0xf1b4, + 0xf1b5: 0xf1b5, + 0xf1b6: 0xf1b6, + 0xf1b7: 0xf1b7, + 0xf1b8: 0xf1b8, + 0xf1b9: 0xf1b9, + 0xf1ba: 0xf1ba, + 0xf1bb: 0xf1bb, + 0xf1bc: 0xf1bc, + 0xf1bd: 0xf1bd, + 0xf1be: 0xf1be, + 0xf1bf: 0xf1bf, + 0xf1c0: 0xf1c0, + 0xf1c1: 0xf1c1, + 0xf1c2: 0xf1c2, + 0xf1c3: 0xf1c3, + 0xf1c4: 0xf1c4, + 0xf1c5: 0xf1c5, + 0xf1c6: 0xf1c6, + 0xf1c7: 0xf1c7, + 0xf1c8: 0xf1c8, + 0xf1c9: 0xf1c9, + 0xf1ca: 0xf1ca, + 0xf1cb: 0xf1cb, + 0xf1cc: 0xf1cc, + 0xf1cd: 0xf1cd, + 0xf1ce: 0xf1ce, + 0xf1cf: 0xf1cf, + 0xf1d0: 0xf1d0, + 0xf1d1: 0xf1d1, + 0xf1d2: 0xf1d2, + 0xf1d3: 0xf1d3, + 0xf1d4: 0xf1d4, + 0xf1d5: 0xf1d5, + 0xf1d6: 0xf1d6, + 0xf1d7: 0xf1d7, + 0xf1d8: 0xf1d8, + 0xf1d9: 0xf1d9, + 0xf1da: 0xf1da, + 0xf1db: 0xf1db, + 0xf1dc: 0xf1dc, + 0xf1dd: 0xf1dd, + 0xf1de: 0xf1de, + 0xf1df: 0xf1df, + 0xf1e0: 0xf1e0, + 0xf1e1: 0xf1e1, + 0xf1e2: 0xf1e2, + 0xf1e3: 0xf1e3, + 0xf1e4: 0xf1e4, + 0xf1e5: 0xf1e5, + 0xf1e6: 0xf1e6, + 0xf1e7: 0xf1e7, + 0xf1e8: 0xf1e8, + 0xf1e9: 0xf1e9, + 0xf1ea: 0xf1ea, + 0xf1eb: 0xf1eb, + 0xf1ec: 0xf1ec, + 0xf1ed: 0xf1ed, + 0xf1ee: 0xf1ee, + 0xf1ef: 0xf1ef, + 0xf1f0: 0xf1f0, + 0xf1f1: 0xf1f1, + 0xf1f2: 0xf1f2, + 0xf1f3: 0xf1f3, + 0xf1f4: 0xf1f4, + 0xf1f5: 0xf1f5, + 0xf1f6: 0xf1f6, + 0xf1f7: 0xf1f7, + 0xf1f8: 0xf1f8, + 0xf1f9: 0xf1f9, + 0xf1fa: 0xf1fa, + 0xf1fb: 0xf1fb, + 0xf1fc: 0xf1fc, + 0xf1fd: 0xf1fd, + 0xf1fe: 0xf1fe, + 0xf1ff: 0xf1ff, + 0xf200: 0xf200, + 0xf201: 0xf201, + 0xf202: 0xf202, + 0xf203: 0xf203, + 0xf204: 0xf204, + 0xf205: 0xf205, + 0xf206: 0xf206, + 0xf207: 0xf207, + 0xf208: 0xf208, + 0xf209: 0xf209, + 0xf20a: 0xf20a, + 0xf20b: 0xf20b, + 0xf20c: 0xf20c, + 0xf20d: 0xf20d, + 0xf20e: 0xf20e, + 0xf20f: 0xf20f, + 0xf210: 0xf210, + 0xf211: 0xf211, + 0xf212: 0xf212, + 0xf213: 0xf213, + 0xf214: 0xf214, + 0xf215: 0xf215, + 0xf216: 0xf216, + 0xf217: 0xf217, + 0xf218: 0xf218, + 0xf219: 0xf219, + 0xf21a: 0xf21a, + 0xf21b: 0xf21b, + 0xf21c: 0xf21c, + 0xf21d: 0xf21d, + 0xf21e: 0xf21e, + 0xf21f: 0xf21f, + 0xf220: 0xf220, + 0xf221: 0xf221, + 0xf222: 0xf222, + 0xf223: 0xf223, + 0xf224: 0xf224, + 0xf225: 0xf225, + 0xf226: 0xf226, + 0xf227: 0xf227, + 0xf228: 0xf228, + 0xf229: 0xf229, + 0xf22a: 0xf22a, + 0xf22b: 0xf22b, + 0xf22c: 0xf22c, + 0xf22d: 0xf22d, + 0xf22e: 0xf22e, + 0xf22f: 0xf22f, + 0xf230: 0xf230, + 0xf231: 0xf231, + 0xf232: 0xf232, + 0xf233: 0xf233, + 0xf234: 0xf234, + 0xf235: 0xf235, + 0xf236: 0xf236, + 0xf237: 0xf237, + 0xf238: 0xf238, + 0xf239: 0xf239, + 0xf23a: 0xf23a, + 0xf23b: 0xf23b, + 0xf23c: 0xf23c, + 0xf23d: 0xf23d, + 0xf23e: 0xf23e, + 0xf23f: 0xf23f, + 0xf240: 0xf240, + 0xf241: 0xf241, + 0xf242: 0xf242, + 0xf243: 0xf243, + 0xf244: 0xf244, + 0xf245: 0xf245, + 0xf246: 0xf246, + 0xf247: 0xf247, + 0xf248: 0xf248, + 0xf249: 0xf249, + 0xf24a: 0xf24a, + 0xf24b: 0xf24b, + 0xf24c: 0xf24c, + 0xf24d: 0xf24d, + 0xf24e: 0xf24e, + 0xf24f: 0xf24f, + 0xf250: 0xf250, + 0xf251: 0xf251, + 0xf252: 0xf252, + 0xf253: 0xf253, + 0xf254: 0xf254, + 0xf255: 0xf255, + 0xf256: 0xf256, + 0xf257: 0xf257, + 0xf258: 0xf258, + 0xf259: 0xf259, + 0xf25a: 0xf25a, + 0xf25b: 0xf25b, + 0xf25c: 0xf25c, + 0xf25d: 0xf25d, + 0xf25e: 0xf25e, + 0xf25f: 0xf25f, + 0xf260: 0xf260, + 0xf261: 0xf261, + 0xf262: 0xf262, + 0xf263: 0xf263, + 0xf264: 0xf264, + 0xf265: 0xf265, + 0xf266: 0xf266, + 0xf267: 0xf267, + 0xf268: 0xf268, + 0xf269: 0xf269, + 0xf26a: 0xf26a, + 0xf26b: 0xf26b, + 0xf26c: 0xf26c, + 0xf26d: 0xf26d, + 0xf26e: 0xf26e, + 0xf26f: 0xf26f, + 0xf270: 0xf270, + 0xf271: 0xf271, + 0xf272: 0xf272, + 0xf273: 0xf273, + 0xf274: 0xf274, + 0xf275: 0xf275, + 0xf276: 0xf276, + 0xf277: 0xf277, + 0xf278: 0xf278, + 0xf279: 0xf279, + 0xf27a: 0xf27a, + 0xf27b: 0xf27b, + 0xf27c: 0xf27c, + 0xf27d: 0xf27d, + 0xf27e: 0xf27e, + 0xf27f: 0xf27f, + 0xf280: 0xf280, + 0xf281: 0xf281, + 0xf282: 0xf282, + 0xf283: 0xf283, + 0xf284: 0xf284, + 0xf285: 0xf285, + 0xf286: 0xf286, + 0xf287: 0xf287, + 0xf288: 0xf288, + 0xf289: 0xf289, + 0xf28a: 0xf28a, + 0xf28b: 0xf28b, + 0xf28c: 0xf28c, + 0xf28d: 0xf28d, + 0xf28e: 0xf28e, + 0xf28f: 0xf28f, + 0xf290: 0xf290, + 0xf291: 0xf291, + 0xf292: 0xf292, + 0xf293: 0xf293, + 0xf294: 0xf294, + 0xf295: 0xf295, + 0xf296: 0xf296, + 0xf297: 0xf297, + 0xf298: 0xf298, + 0xf299: 0xf299, + 0xf29a: 0xf29a, + 0xf29b: 0xf29b, + 0xf29c: 0xf29c, + 0xf29d: 0xf29d, + 0xf29e: 0xf29e, + 0xf29f: 0xf29f, + 0xf2a0: 0xf2a0, + 0xf2a1: 0xf2a1, + 0xf2a2: 0xf2a2, + 0xf2a3: 0xf2a3, + 0xf2a4: 0xf2a4, + 0xf2a5: 0xf2a5, + 0xf2a6: 0xf2a6, + 0xf2a7: 0xf2a7, + 0xf2a8: 0xf2a8, + 0xf2a9: 0xf2a9, + 0xf2aa: 0xf2aa, + 0xf2ab: 0xf2ab, + 0xf2ac: 0xf2ac, + 0xf2ad: 0xf2ad, + 0xf2ae: 0xf2ae, + 0xf2af: 0xf2af, + 0xf2b0: 0xf2b0, + 0xf2b1: 0xf2b1, + 0xf2b2: 0xf2b2, + 0xf2b3: 0xf2b3, + 0xf2b4: 0xf2b4, + 0xf2b5: 0xf2b5, + 0xf2b6: 0xf2b6, + 0xf2b7: 0xf2b7, + 0xf2b8: 0xf2b8, + 0xf2b9: 0xf2b9, + 0xf2ba: 0xf2ba, + 0xf2bb: 0xf2bb, + 0xf2bc: 0xf2bc, + 0xf2bd: 0xf2bd, + 0xf2be: 0xf2be, + 0xf2bf: 0xf2bf, + 0xf2c0: 0xf2c0, + 0xf2c1: 0xf2c1, + 0xf2c2: 0xf2c2, + 0xf2c3: 0xf2c3, + 0xf2c4: 0xf2c4, + 0xf2c5: 0xf2c5, + 0xf2c6: 0xf2c6, + 0xf2c7: 0xf2c7, + 0xf2c8: 0xf2c8, + 0xf2c9: 0xf2c9, + 0xf2ca: 0xf2ca, + 0xf2cb: 0xf2cb, + 0xf2cc: 0xf2cc, + 0xf2cd: 0xf2cd, + 0xf2ce: 0xf2ce, + 0xf2cf: 0xf2cf, + 0xf2d0: 0xf2d0, + 0xf2d1: 0xf2d1, + 0xf2d2: 0xf2d2, + 0xf2d3: 0xf2d3, + 0xf2d4: 0xf2d4, + 0xf2d5: 0xf2d5, + 0xf2d6: 0xf2d6, + 0xf2d7: 0xf2d7, + 0xf2d8: 0xf2d8, + 0xf2d9: 0xf2d9, + 0xf2da: 0xf2da, + 0xf2db: 0xf2db, + 0xf2dc: 0xf2dc, + 0xf2dd: 0xf2dd, + 0xf2de: 0xf2de, + 0xf2df: 0xf2df, + 0xf2e0: 0xf2e0, + 0xf2e1: 0xf2e1, + 0xf2e2: 0xf2e2, + 0xf2e3: 0xf2e3, + 0xf2e4: 0xf2e4, + 0xf2e5: 0xf2e5, + 0xf2e6: 0xf2e6, + 0xf2e7: 0xf2e7, + 0xf2e8: 0xf2e8, + 0xf2e9: 0xf2e9, + 0xf2ea: 0xf2ea, + 0xf2eb: 0xf2eb, + 0xf2ec: 0xf2ec, + 0xf2ed: 0xf2ed, + 0xf2ee: 0xf2ee, + 0xf2ef: 0xf2ef, + 0xf2f0: 0xf2f0, + 0xf2f1: 0xf2f1, + 0xf2f2: 0xf2f2, + 0xf2f3: 0xf2f3, + 0xf2f4: 0xf2f4, + 0xf2f5: 0xf2f5, + 0xf2f6: 0xf2f6, + 0xf2f7: 0xf2f7, + 0xf2f8: 0xf2f8, + 0xf2f9: 0xf2f9, + 0xf2fa: 0xf2fa, + 0xf2fb: 0xf2fb, + 0xf2fc: 0xf2fc, + 0xf2fd: 0xf2fd, + 0xf2fe: 0xf2fe, + 0xf2ff: 0xf2ff, + }, + "Font Awesome Extension": { + 0xe000: 0xe200, + 0xe001: 0xe201, + 0xe002: 0xe202, + 0xe003: 0xe203, + 0xe004: 0xe204, + 0xe005: 0xe205, + 0xe006: 0xe206, + 0xe007: 0xe207, + 0xe008: 0xe208, + 0xe009: 0xe209, + 0xe00a: 0xe20a, + 0xe00b: 0xe20b, + 0xe00c: 0xe20c, + 0xe00d: 0xe20d, + 0xe00e: 0xe20e, + 0xe00f: 0xe20f, + 0xe010: 0xe210, + 0xe011: 0xe211, + 0xe012: 0xe212, + 0xe013: 0xe213, + 0xe014: 0xe214, + 0xe015: 0xe215, + 0xe016: 0xe216, + 0xe017: 0xe217, + 0xe018: 0xe218, + 0xe019: 0xe219, + 0xe01a: 0xe21a, + 0xe01b: 0xe21b, + 0xe01c: 0xe21c, + 0xe01d: 0xe21d, + 0xe01e: 0xe21e, + 0xe01f: 0xe21f, + 0xe020: 0xe220, + 0xe021: 0xe221, + 0xe022: 0xe222, + 0xe023: 0xe223, + 0xe024: 0xe224, + 0xe025: 0xe225, + 0xe026: 0xe226, + 0xe027: 0xe227, + 0xe028: 0xe228, + 0xe029: 0xe229, + 0xe02a: 0xe22a, + 0xe02b: 0xe22b, + 0xe02c: 0xe22c, + 0xe02d: 0xe22d, + 0xe02e: 0xe22e, + 0xe02f: 0xe22f, + 0xe030: 0xe230, + 0xe031: 0xe231, + 0xe032: 0xe232, + 0xe033: 0xe233, + 0xe034: 0xe234, + 0xe035: 0xe235, + 0xe036: 0xe236, + 0xe037: 0xe237, + 0xe038: 0xe238, + 0xe039: 0xe239, + 0xe03a: 0xe23a, + 0xe03b: 0xe23b, + 0xe03c: 0xe23c, + 0xe03d: 0xe23d, + 0xe03e: 0xe23e, + 0xe03f: 0xe23f, + 0xe040: 0xe240, + 0xe041: 0xe241, + 0xe042: 0xe242, + 0xe043: 0xe243, + 0xe044: 0xe244, + 0xe045: 0xe245, + 0xe046: 0xe246, + 0xe047: 0xe247, + 0xe048: 0xe248, + 0xe049: 0xe249, + 0xe04a: 0xe24a, + 0xe04b: 0xe24b, + 0xe04c: 0xe24c, + 0xe04d: 0xe24d, + 0xe04e: 0xe24e, + 0xe04f: 0xe24f, + 0xe050: 0xe250, + 0xe051: 0xe251, + 0xe052: 0xe252, + 0xe053: 0xe253, + 0xe054: 0xe254, + 0xe055: 0xe255, + 0xe056: 0xe256, + 0xe057: 0xe257, + 0xe058: 0xe258, + 0xe059: 0xe259, + 0xe05a: 0xe25a, + 0xe05b: 0xe25b, + 0xe05c: 0xe25c, + 0xe05d: 0xe25d, + 0xe05e: 0xe25e, + 0xe05f: 0xe25f, + 0xe060: 0xe260, + 0xe061: 0xe261, + 0xe062: 0xe262, + 0xe063: 0xe263, + 0xe064: 0xe264, + 0xe065: 0xe265, + 0xe066: 0xe266, + 0xe067: 0xe267, + 0xe068: 0xe268, + 0xe069: 0xe269, + 0xe06a: 0xe26a, + 0xe06b: 0xe26b, + 0xe06c: 0xe26c, + 0xe06d: 0xe26d, + 0xe06e: 0xe26e, + 0xe06f: 0xe26f, + 0xe070: 0xe270, + 0xe071: 0xe271, + 0xe072: 0xe272, + 0xe073: 0xe273, + 0xe074: 0xe274, + 0xe075: 0xe275, + 0xe076: 0xe276, + 0xe077: 0xe277, + 0xe078: 0xe278, + 0xe079: 0xe279, + 0xe07a: 0xe27a, + 0xe07b: 0xe27b, + 0xe07c: 0xe27c, + 0xe07d: 0xe27d, + 0xe07e: 0xe27e, + 0xe07f: 0xe27f, + 0xe080: 0xe280, + 0xe081: 0xe281, + 0xe082: 0xe282, + 0xe083: 0xe283, + 0xe084: 0xe284, + 0xe085: 0xe285, + 0xe086: 0xe286, + 0xe087: 0xe287, + 0xe088: 0xe288, + 0xe089: 0xe289, + 0xe08a: 0xe28a, + 0xe08b: 0xe28b, + 0xe08c: 0xe28c, + 0xe08d: 0xe28d, + 0xe08e: 0xe28e, + 0xe08f: 0xe28f, + 0xe090: 0xe290, + 0xe091: 0xe291, + 0xe092: 0xe292, + 0xe093: 0xe293, + 0xe094: 0xe294, + 0xe095: 0xe295, + 0xe096: 0xe296, + 0xe097: 0xe297, + 0xe098: 0xe298, + 0xe099: 0xe299, + 0xe09a: 0xe29a, + 0xe09b: 0xe29b, + 0xe09c: 0xe29c, + 0xe09d: 0xe29d, + 0xe09e: 0xe29e, + 0xe09f: 0xe29f, + 0xe0a0: 0xe2a0, + 0xe0a1: 0xe2a1, + 0xe0a2: 0xe2a2, + 0xe0a3: 0xe2a3, + 0xe0a4: 0xe2a4, + 0xe0a5: 0xe2a5, + 0xe0a6: 0xe2a6, + 0xe0a7: 0xe2a7, + 0xe0a8: 0xe2a8, + 0xe0a9: 0xe2a9, + }, + "Power Symbols": { + 0x23fb: 0x23fb, + 0x23fc: 0x23fc, + 0x23fd: 0x23fd, + 0x23fe: 0x23fe, + 0x2b58: 0x2b58, + }, + "Material": { + 0xf0001: 0xf0001, + 0xf0002: 0xf0002, + 0xf0003: 0xf0003, + 0xf0004: 0xf0004, + 0xf0005: 0xf0005, + 0xf0006: 0xf0006, + 0xf0007: 0xf0007, + 0xf0008: 0xf0008, + 0xf0009: 0xf0009, + 0xf000a: 0xf000a, + 0xf000b: 0xf000b, + 0xf000c: 0xf000c, + 0xf000d: 0xf000d, + 0xf000e: 0xf000e, + 0xf000f: 0xf000f, + 0xf0010: 0xf0010, + 0xf0011: 0xf0011, + 0xf0012: 0xf0012, + 0xf0013: 0xf0013, + 0xf0014: 0xf0014, + 0xf0015: 0xf0015, + 0xf0016: 0xf0016, + 0xf0017: 0xf0017, + 0xf0018: 0xf0018, + 0xf0019: 0xf0019, + 0xf001a: 0xf001a, + 0xf001b: 0xf001b, + 0xf001c: 0xf001c, + 0xf001d: 0xf001d, + 0xf001e: 0xf001e, + 0xf001f: 0xf001f, + 0xf0020: 0xf0020, + 0xf0021: 0xf0021, + 0xf0022: 0xf0022, + 0xf0023: 0xf0023, + 0xf0024: 0xf0024, + 0xf0025: 0xf0025, + 0xf0026: 0xf0026, + 0xf0027: 0xf0027, + 0xf0028: 0xf0028, + 0xf0029: 0xf0029, + 0xf002a: 0xf002a, + 0xf002b: 0xf002b, + 0xf002c: 0xf002c, + 0xf002d: 0xf002d, + 0xf002e: 0xf002e, + 0xf002f: 0xf002f, + 0xf0030: 0xf0030, + 0xf0031: 0xf0031, + 0xf0032: 0xf0032, + 0xf0033: 0xf0033, + 0xf0034: 0xf0034, + 0xf0035: 0xf0035, + 0xf0036: 0xf0036, + 0xf0037: 0xf0037, + 0xf0038: 0xf0038, + 0xf0039: 0xf0039, + 0xf003a: 0xf003a, + 0xf003b: 0xf003b, + 0xf003c: 0xf003c, + 0xf003d: 0xf003d, + 0xf003e: 0xf003e, + 0xf003f: 0xf003f, + 0xf0040: 0xf0040, + 0xf0041: 0xf0041, + 0xf0042: 0xf0042, + 0xf0043: 0xf0043, + 0xf0044: 0xf0044, + 0xf0045: 0xf0045, + 0xf0046: 0xf0046, + 0xf0047: 0xf0047, + 0xf0048: 0xf0048, + 0xf0049: 0xf0049, + 0xf004a: 0xf004a, + 0xf004b: 0xf004b, + 0xf004c: 0xf004c, + 0xf004d: 0xf004d, + 0xf004e: 0xf004e, + 0xf004f: 0xf004f, + 0xf0050: 0xf0050, + 0xf0051: 0xf0051, + 0xf0052: 0xf0052, + 0xf0053: 0xf0053, + 0xf0054: 0xf0054, + 0xf0055: 0xf0055, + 0xf0056: 0xf0056, + 0xf0057: 0xf0057, + 0xf0058: 0xf0058, + 0xf0059: 0xf0059, + 0xf005a: 0xf005a, + 0xf005b: 0xf005b, + 0xf005c: 0xf005c, + 0xf005d: 0xf005d, + 0xf005e: 0xf005e, + 0xf005f: 0xf005f, + 0xf0060: 0xf0060, + 0xf0061: 0xf0061, + 0xf0062: 0xf0062, + 0xf0063: 0xf0063, + 0xf0064: 0xf0064, + 0xf0065: 0xf0065, + 0xf0066: 0xf0066, + 0xf0067: 0xf0067, + 0xf0068: 0xf0068, + 0xf0069: 0xf0069, + 0xf006a: 0xf006a, + 0xf006b: 0xf006b, + 0xf006c: 0xf006c, + 0xf006d: 0xf006d, + 0xf006e: 0xf006e, + 0xf006f: 0xf006f, + 0xf0070: 0xf0070, + 0xf0071: 0xf0071, + 0xf0072: 0xf0072, + 0xf0073: 0xf0073, + 0xf0074: 0xf0074, + 0xf0075: 0xf0075, + 0xf0076: 0xf0076, + 0xf0077: 0xf0077, + 0xf0078: 0xf0078, + 0xf0079: 0xf0079, + 0xf007a: 0xf007a, + 0xf007b: 0xf007b, + 0xf007c: 0xf007c, + 0xf007d: 0xf007d, + 0xf007e: 0xf007e, + 0xf007f: 0xf007f, + 0xf0080: 0xf0080, + 0xf0081: 0xf0081, + 0xf0082: 0xf0082, + 0xf0083: 0xf0083, + 0xf0084: 0xf0084, + 0xf0085: 0xf0085, + 0xf0086: 0xf0086, + 0xf0087: 0xf0087, + 0xf0088: 0xf0088, + 0xf0089: 0xf0089, + 0xf008a: 0xf008a, + 0xf008b: 0xf008b, + 0xf008c: 0xf008c, + 0xf008d: 0xf008d, + 0xf008e: 0xf008e, + 0xf008f: 0xf008f, + 0xf0090: 0xf0090, + 0xf0091: 0xf0091, + 0xf0092: 0xf0092, + 0xf0093: 0xf0093, + 0xf0094: 0xf0094, + 0xf0095: 0xf0095, + 0xf0096: 0xf0096, + 0xf0097: 0xf0097, + 0xf0098: 0xf0098, + 0xf0099: 0xf0099, + 0xf009a: 0xf009a, + 0xf009b: 0xf009b, + 0xf009c: 0xf009c, + 0xf009d: 0xf009d, + 0xf009e: 0xf009e, + 0xf009f: 0xf009f, + 0xf00a0: 0xf00a0, + 0xf00a1: 0xf00a1, + 0xf00a2: 0xf00a2, + 0xf00a3: 0xf00a3, + 0xf00a4: 0xf00a4, + 0xf00a5: 0xf00a5, + 0xf00a6: 0xf00a6, + 0xf00a7: 0xf00a7, + 0xf00a8: 0xf00a8, + 0xf00a9: 0xf00a9, + 0xf00aa: 0xf00aa, + 0xf00ab: 0xf00ab, + 0xf00ac: 0xf00ac, + 0xf00ad: 0xf00ad, + 0xf00ae: 0xf00ae, + 0xf00af: 0xf00af, + 0xf00b0: 0xf00b0, + 0xf00b1: 0xf00b1, + 0xf00b2: 0xf00b2, + 0xf00b3: 0xf00b3, + 0xf00b4: 0xf00b4, + 0xf00b5: 0xf00b5, + 0xf00b6: 0xf00b6, + 0xf00b7: 0xf00b7, + 0xf00b8: 0xf00b8, + 0xf00b9: 0xf00b9, + 0xf00ba: 0xf00ba, + 0xf00bb: 0xf00bb, + 0xf00bc: 0xf00bc, + 0xf00bd: 0xf00bd, + 0xf00be: 0xf00be, + 0xf00bf: 0xf00bf, + 0xf00c0: 0xf00c0, + 0xf00c1: 0xf00c1, + 0xf00c2: 0xf00c2, + 0xf00c3: 0xf00c3, + 0xf00c4: 0xf00c4, + 0xf00c5: 0xf00c5, + 0xf00c6: 0xf00c6, + 0xf00c7: 0xf00c7, + 0xf00c8: 0xf00c8, + 0xf00c9: 0xf00c9, + 0xf00ca: 0xf00ca, + 0xf00cb: 0xf00cb, + 0xf00cc: 0xf00cc, + 0xf00cd: 0xf00cd, + 0xf00ce: 0xf00ce, + 0xf00cf: 0xf00cf, + 0xf00d0: 0xf00d0, + 0xf00d1: 0xf00d1, + 0xf00d2: 0xf00d2, + 0xf00d3: 0xf00d3, + 0xf00d4: 0xf00d4, + 0xf00d5: 0xf00d5, + 0xf00d6: 0xf00d6, + 0xf00d7: 0xf00d7, + 0xf00d8: 0xf00d8, + 0xf00d9: 0xf00d9, + 0xf00da: 0xf00da, + 0xf00db: 0xf00db, + 0xf00dc: 0xf00dc, + 0xf00dd: 0xf00dd, + 0xf00de: 0xf00de, + 0xf00df: 0xf00df, + 0xf00e0: 0xf00e0, + 0xf00e1: 0xf00e1, + 0xf00e2: 0xf00e2, + 0xf00e3: 0xf00e3, + 0xf00e4: 0xf00e4, + 0xf00e5: 0xf00e5, + 0xf00e6: 0xf00e6, + 0xf00e7: 0xf00e7, + 0xf00e8: 0xf00e8, + 0xf00e9: 0xf00e9, + 0xf00ea: 0xf00ea, + 0xf00eb: 0xf00eb, + 0xf00ec: 0xf00ec, + 0xf00ed: 0xf00ed, + 0xf00ee: 0xf00ee, + 0xf00ef: 0xf00ef, + 0xf00f0: 0xf00f0, + 0xf00f1: 0xf00f1, + 0xf00f2: 0xf00f2, + 0xf00f3: 0xf00f3, + 0xf00f4: 0xf00f4, + 0xf00f5: 0xf00f5, + 0xf00f6: 0xf00f6, + 0xf00f7: 0xf00f7, + 0xf00f8: 0xf00f8, + 0xf00f9: 0xf00f9, + 0xf00fa: 0xf00fa, + 0xf00fb: 0xf00fb, + 0xf00fc: 0xf00fc, + 0xf00fd: 0xf00fd, + 0xf00fe: 0xf00fe, + 0xf00ff: 0xf00ff, + 0xf0100: 0xf0100, + 0xf0101: 0xf0101, + 0xf0102: 0xf0102, + 0xf0103: 0xf0103, + 0xf0104: 0xf0104, + 0xf0105: 0xf0105, + 0xf0106: 0xf0106, + 0xf0107: 0xf0107, + 0xf0108: 0xf0108, + 0xf0109: 0xf0109, + 0xf010a: 0xf010a, + 0xf010b: 0xf010b, + 0xf010c: 0xf010c, + 0xf010d: 0xf010d, + 0xf010e: 0xf010e, + 0xf010f: 0xf010f, + 0xf0110: 0xf0110, + 0xf0111: 0xf0111, + 0xf0112: 0xf0112, + 0xf0113: 0xf0113, + 0xf0114: 0xf0114, + 0xf0115: 0xf0115, + 0xf0116: 0xf0116, + 0xf0117: 0xf0117, + 0xf0118: 0xf0118, + 0xf0119: 0xf0119, + 0xf011a: 0xf011a, + 0xf011b: 0xf011b, + 0xf011c: 0xf011c, + 0xf011d: 0xf011d, + 0xf011e: 0xf011e, + 0xf011f: 0xf011f, + 0xf0120: 0xf0120, + 0xf0121: 0xf0121, + 0xf0122: 0xf0122, + 0xf0123: 0xf0123, + 0xf0124: 0xf0124, + 0xf0125: 0xf0125, + 0xf0126: 0xf0126, + 0xf0127: 0xf0127, + 0xf0128: 0xf0128, + 0xf0129: 0xf0129, + 0xf012a: 0xf012a, + 0xf012b: 0xf012b, + 0xf012c: 0xf012c, + 0xf012d: 0xf012d, + 0xf012e: 0xf012e, + 0xf012f: 0xf012f, + 0xf0130: 0xf0130, + 0xf0131: 0xf0131, + 0xf0132: 0xf0132, + 0xf0133: 0xf0133, + 0xf0134: 0xf0134, + 0xf0135: 0xf0135, + 0xf0136: 0xf0136, + 0xf0137: 0xf0137, + 0xf0138: 0xf0138, + 0xf0139: 0xf0139, + 0xf013a: 0xf013a, + 0xf013b: 0xf013b, + 0xf013c: 0xf013c, + 0xf013d: 0xf013d, + 0xf013e: 0xf013e, + 0xf013f: 0xf013f, + 0xf0140: 0xf0140, + 0xf0141: 0xf0141, + 0xf0142: 0xf0142, + 0xf0143: 0xf0143, + 0xf0144: 0xf0144, + 0xf0145: 0xf0145, + 0xf0146: 0xf0146, + 0xf0147: 0xf0147, + 0xf0148: 0xf0148, + 0xf0149: 0xf0149, + 0xf014a: 0xf014a, + 0xf014b: 0xf014b, + 0xf014c: 0xf014c, + 0xf014d: 0xf014d, + 0xf014e: 0xf014e, + 0xf014f: 0xf014f, + 0xf0150: 0xf0150, + 0xf0151: 0xf0151, + 0xf0152: 0xf0152, + 0xf0153: 0xf0153, + 0xf0154: 0xf0154, + 0xf0155: 0xf0155, + 0xf0156: 0xf0156, + 0xf0157: 0xf0157, + 0xf0158: 0xf0158, + 0xf0159: 0xf0159, + 0xf015a: 0xf015a, + 0xf015b: 0xf015b, + 0xf015c: 0xf015c, + 0xf015d: 0xf015d, + 0xf015e: 0xf015e, + 0xf015f: 0xf015f, + 0xf0160: 0xf0160, + 0xf0161: 0xf0161, + 0xf0162: 0xf0162, + 0xf0163: 0xf0163, + 0xf0164: 0xf0164, + 0xf0165: 0xf0165, + 0xf0166: 0xf0166, + 0xf0167: 0xf0167, + 0xf0168: 0xf0168, + 0xf0169: 0xf0169, + 0xf016a: 0xf016a, + 0xf016b: 0xf016b, + 0xf016c: 0xf016c, + 0xf016d: 0xf016d, + 0xf016e: 0xf016e, + 0xf016f: 0xf016f, + 0xf0170: 0xf0170, + 0xf0171: 0xf0171, + 0xf0172: 0xf0172, + 0xf0173: 0xf0173, + 0xf0174: 0xf0174, + 0xf0175: 0xf0175, + 0xf0176: 0xf0176, + 0xf0177: 0xf0177, + 0xf0178: 0xf0178, + 0xf0179: 0xf0179, + 0xf017a: 0xf017a, + 0xf017b: 0xf017b, + 0xf017c: 0xf017c, + 0xf017d: 0xf017d, + 0xf017e: 0xf017e, + 0xf017f: 0xf017f, + 0xf0180: 0xf0180, + 0xf0181: 0xf0181, + 0xf0182: 0xf0182, + 0xf0183: 0xf0183, + 0xf0184: 0xf0184, + 0xf0185: 0xf0185, + 0xf0186: 0xf0186, + 0xf0187: 0xf0187, + 0xf0188: 0xf0188, + 0xf0189: 0xf0189, + 0xf018a: 0xf018a, + 0xf018b: 0xf018b, + 0xf018c: 0xf018c, + 0xf018d: 0xf018d, + 0xf018e: 0xf018e, + 0xf018f: 0xf018f, + 0xf0190: 0xf0190, + 0xf0191: 0xf0191, + 0xf0192: 0xf0192, + 0xf0193: 0xf0193, + 0xf0194: 0xf0194, + 0xf0195: 0xf0195, + 0xf0196: 0xf0196, + 0xf0197: 0xf0197, + 0xf0198: 0xf0198, + 0xf0199: 0xf0199, + 0xf019a: 0xf019a, + 0xf019b: 0xf019b, + 0xf019c: 0xf019c, + 0xf019d: 0xf019d, + 0xf019e: 0xf019e, + 0xf019f: 0xf019f, + 0xf01a0: 0xf01a0, + 0xf01a1: 0xf01a1, + 0xf01a2: 0xf01a2, + 0xf01a3: 0xf01a3, + 0xf01a4: 0xf01a4, + 0xf01a5: 0xf01a5, + 0xf01a6: 0xf01a6, + 0xf01a7: 0xf01a7, + 0xf01a8: 0xf01a8, + 0xf01a9: 0xf01a9, + 0xf01aa: 0xf01aa, + 0xf01ab: 0xf01ab, + 0xf01ac: 0xf01ac, + 0xf01ad: 0xf01ad, + 0xf01ae: 0xf01ae, + 0xf01af: 0xf01af, + 0xf01b0: 0xf01b0, + 0xf01b1: 0xf01b1, + 0xf01b2: 0xf01b2, + 0xf01b3: 0xf01b3, + 0xf01b4: 0xf01b4, + 0xf01b5: 0xf01b5, + 0xf01b6: 0xf01b6, + 0xf01b7: 0xf01b7, + 0xf01b8: 0xf01b8, + 0xf01b9: 0xf01b9, + 0xf01ba: 0xf01ba, + 0xf01bb: 0xf01bb, + 0xf01bc: 0xf01bc, + 0xf01bd: 0xf01bd, + 0xf01be: 0xf01be, + 0xf01bf: 0xf01bf, + 0xf01c0: 0xf01c0, + 0xf01c1: 0xf01c1, + 0xf01c2: 0xf01c2, + 0xf01c3: 0xf01c3, + 0xf01c4: 0xf01c4, + 0xf01c5: 0xf01c5, + 0xf01c6: 0xf01c6, + 0xf01c7: 0xf01c7, + 0xf01c8: 0xf01c8, + 0xf01c9: 0xf01c9, + 0xf01ca: 0xf01ca, + 0xf01cb: 0xf01cb, + 0xf01cc: 0xf01cc, + 0xf01cd: 0xf01cd, + 0xf01ce: 0xf01ce, + 0xf01cf: 0xf01cf, + 0xf01d0: 0xf01d0, + 0xf01d1: 0xf01d1, + 0xf01d2: 0xf01d2, + 0xf01d3: 0xf01d3, + 0xf01d4: 0xf01d4, + 0xf01d5: 0xf01d5, + 0xf01d6: 0xf01d6, + 0xf01d7: 0xf01d7, + 0xf01d8: 0xf01d8, + 0xf01d9: 0xf01d9, + 0xf01da: 0xf01da, + 0xf01db: 0xf01db, + 0xf01dc: 0xf01dc, + 0xf01dd: 0xf01dd, + 0xf01de: 0xf01de, + 0xf01df: 0xf01df, + 0xf01e0: 0xf01e0, + 0xf01e1: 0xf01e1, + 0xf01e2: 0xf01e2, + 0xf01e3: 0xf01e3, + 0xf01e4: 0xf01e4, + 0xf01e5: 0xf01e5, + 0xf01e6: 0xf01e6, + 0xf01e7: 0xf01e7, + 0xf01e8: 0xf01e8, + 0xf01e9: 0xf01e9, + 0xf01ea: 0xf01ea, + 0xf01eb: 0xf01eb, + 0xf01ec: 0xf01ec, + 0xf01ed: 0xf01ed, + 0xf01ee: 0xf01ee, + 0xf01ef: 0xf01ef, + 0xf01f0: 0xf01f0, + 0xf01f1: 0xf01f1, + 0xf01f2: 0xf01f2, + 0xf01f3: 0xf01f3, + 0xf01f4: 0xf01f4, + 0xf01f5: 0xf01f5, + 0xf01f6: 0xf01f6, + 0xf01f7: 0xf01f7, + 0xf01f8: 0xf01f8, + 0xf01f9: 0xf01f9, + 0xf01fa: 0xf01fa, + 0xf01fb: 0xf01fb, + 0xf01fc: 0xf01fc, + 0xf01fd: 0xf01fd, + 0xf01fe: 0xf01fe, + 0xf01ff: 0xf01ff, + 0xf0200: 0xf0200, + 0xf0201: 0xf0201, + 0xf0202: 0xf0202, + 0xf0203: 0xf0203, + 0xf0204: 0xf0204, + 0xf0205: 0xf0205, + 0xf0206: 0xf0206, + 0xf0207: 0xf0207, + 0xf0208: 0xf0208, + 0xf0209: 0xf0209, + 0xf020a: 0xf020a, + 0xf020b: 0xf020b, + 0xf020c: 0xf020c, + 0xf020d: 0xf020d, + 0xf020e: 0xf020e, + 0xf020f: 0xf020f, + 0xf0210: 0xf0210, + 0xf0211: 0xf0211, + 0xf0212: 0xf0212, + 0xf0213: 0xf0213, + 0xf0214: 0xf0214, + 0xf0215: 0xf0215, + 0xf0216: 0xf0216, + 0xf0217: 0xf0217, + 0xf0218: 0xf0218, + 0xf0219: 0xf0219, + 0xf021a: 0xf021a, + 0xf021b: 0xf021b, + 0xf021c: 0xf021c, + 0xf021d: 0xf021d, + 0xf021e: 0xf021e, + 0xf021f: 0xf021f, + 0xf0220: 0xf0220, + 0xf0221: 0xf0221, + 0xf0222: 0xf0222, + 0xf0223: 0xf0223, + 0xf0224: 0xf0224, + 0xf0225: 0xf0225, + 0xf0226: 0xf0226, + 0xf0227: 0xf0227, + 0xf0228: 0xf0228, + 0xf0229: 0xf0229, + 0xf022a: 0xf022a, + 0xf022b: 0xf022b, + 0xf022c: 0xf022c, + 0xf022d: 0xf022d, + 0xf022e: 0xf022e, + 0xf022f: 0xf022f, + 0xf0230: 0xf0230, + 0xf0231: 0xf0231, + 0xf0232: 0xf0232, + 0xf0233: 0xf0233, + 0xf0234: 0xf0234, + 0xf0235: 0xf0235, + 0xf0236: 0xf0236, + 0xf0237: 0xf0237, + 0xf0238: 0xf0238, + 0xf0239: 0xf0239, + 0xf023a: 0xf023a, + 0xf023b: 0xf023b, + 0xf023c: 0xf023c, + 0xf023d: 0xf023d, + 0xf023e: 0xf023e, + 0xf023f: 0xf023f, + 0xf0240: 0xf0240, + 0xf0241: 0xf0241, + 0xf0242: 0xf0242, + 0xf0243: 0xf0243, + 0xf0244: 0xf0244, + 0xf0245: 0xf0245, + 0xf0246: 0xf0246, + 0xf0247: 0xf0247, + 0xf0248: 0xf0248, + 0xf0249: 0xf0249, + 0xf024a: 0xf024a, + 0xf024b: 0xf024b, + 0xf024c: 0xf024c, + 0xf024d: 0xf024d, + 0xf024e: 0xf024e, + 0xf024f: 0xf024f, + 0xf0250: 0xf0250, + 0xf0251: 0xf0251, + 0xf0252: 0xf0252, + 0xf0253: 0xf0253, + 0xf0254: 0xf0254, + 0xf0255: 0xf0255, + 0xf0256: 0xf0256, + 0xf0257: 0xf0257, + 0xf0258: 0xf0258, + 0xf0259: 0xf0259, + 0xf025a: 0xf025a, + 0xf025b: 0xf025b, + 0xf025c: 0xf025c, + 0xf025d: 0xf025d, + 0xf025e: 0xf025e, + 0xf025f: 0xf025f, + 0xf0260: 0xf0260, + 0xf0261: 0xf0261, + 0xf0262: 0xf0262, + 0xf0263: 0xf0263, + 0xf0264: 0xf0264, + 0xf0265: 0xf0265, + 0xf0266: 0xf0266, + 0xf0267: 0xf0267, + 0xf0268: 0xf0268, + 0xf0269: 0xf0269, + 0xf026a: 0xf026a, + 0xf026b: 0xf026b, + 0xf026c: 0xf026c, + 0xf026d: 0xf026d, + 0xf026e: 0xf026e, + 0xf026f: 0xf026f, + 0xf0270: 0xf0270, + 0xf0271: 0xf0271, + 0xf0272: 0xf0272, + 0xf0273: 0xf0273, + 0xf0274: 0xf0274, + 0xf0275: 0xf0275, + 0xf0276: 0xf0276, + 0xf0277: 0xf0277, + 0xf0278: 0xf0278, + 0xf0279: 0xf0279, + 0xf027a: 0xf027a, + 0xf027b: 0xf027b, + 0xf027c: 0xf027c, + 0xf027d: 0xf027d, + 0xf027e: 0xf027e, + 0xf027f: 0xf027f, + 0xf0280: 0xf0280, + 0xf0281: 0xf0281, + 0xf0282: 0xf0282, + 0xf0283: 0xf0283, + 0xf0284: 0xf0284, + 0xf0285: 0xf0285, + 0xf0286: 0xf0286, + 0xf0287: 0xf0287, + 0xf0288: 0xf0288, + 0xf0289: 0xf0289, + 0xf028a: 0xf028a, + 0xf028b: 0xf028b, + 0xf028c: 0xf028c, + 0xf028d: 0xf028d, + 0xf028e: 0xf028e, + 0xf028f: 0xf028f, + 0xf0290: 0xf0290, + 0xf0291: 0xf0291, + 0xf0292: 0xf0292, + 0xf0293: 0xf0293, + 0xf0294: 0xf0294, + 0xf0295: 0xf0295, + 0xf0296: 0xf0296, + 0xf0297: 0xf0297, + 0xf0298: 0xf0298, + 0xf0299: 0xf0299, + 0xf029a: 0xf029a, + 0xf029b: 0xf029b, + 0xf029c: 0xf029c, + 0xf029d: 0xf029d, + 0xf029e: 0xf029e, + 0xf029f: 0xf029f, + 0xf02a0: 0xf02a0, + 0xf02a1: 0xf02a1, + 0xf02a2: 0xf02a2, + 0xf02a3: 0xf02a3, + 0xf02a4: 0xf02a4, + 0xf02a5: 0xf02a5, + 0xf02a6: 0xf02a6, + 0xf02a7: 0xf02a7, + 0xf02a8: 0xf02a8, + 0xf02a9: 0xf02a9, + 0xf02aa: 0xf02aa, + 0xf02ab: 0xf02ab, + 0xf02ac: 0xf02ac, + 0xf02ad: 0xf02ad, + 0xf02ae: 0xf02ae, + 0xf02af: 0xf02af, + 0xf02b0: 0xf02b0, + 0xf02b1: 0xf02b1, + 0xf02b2: 0xf02b2, + 0xf02b3: 0xf02b3, + 0xf02b4: 0xf02b4, + 0xf02b5: 0xf02b5, + 0xf02b6: 0xf02b6, + 0xf02b7: 0xf02b7, + 0xf02b8: 0xf02b8, + 0xf02b9: 0xf02b9, + 0xf02ba: 0xf02ba, + 0xf02bb: 0xf02bb, + 0xf02bc: 0xf02bc, + 0xf02bd: 0xf02bd, + 0xf02be: 0xf02be, + 0xf02bf: 0xf02bf, + 0xf02c0: 0xf02c0, + 0xf02c1: 0xf02c1, + 0xf02c2: 0xf02c2, + 0xf02c3: 0xf02c3, + 0xf02c4: 0xf02c4, + 0xf02c5: 0xf02c5, + 0xf02c6: 0xf02c6, + 0xf02c7: 0xf02c7, + 0xf02c8: 0xf02c8, + 0xf02c9: 0xf02c9, + 0xf02ca: 0xf02ca, + 0xf02cb: 0xf02cb, + 0xf02cc: 0xf02cc, + 0xf02cd: 0xf02cd, + 0xf02ce: 0xf02ce, + 0xf02cf: 0xf02cf, + 0xf02d0: 0xf02d0, + 0xf02d1: 0xf02d1, + 0xf02d2: 0xf02d2, + 0xf02d3: 0xf02d3, + 0xf02d4: 0xf02d4, + 0xf02d5: 0xf02d5, + 0xf02d6: 0xf02d6, + 0xf02d7: 0xf02d7, + 0xf02d8: 0xf02d8, + 0xf02d9: 0xf02d9, + 0xf02da: 0xf02da, + 0xf02db: 0xf02db, + 0xf02dc: 0xf02dc, + 0xf02dd: 0xf02dd, + 0xf02de: 0xf02de, + 0xf02df: 0xf02df, + 0xf02e0: 0xf02e0, + 0xf02e1: 0xf02e1, + 0xf02e2: 0xf02e2, + 0xf02e3: 0xf02e3, + 0xf02e4: 0xf02e4, + 0xf02e5: 0xf02e5, + 0xf02e6: 0xf02e6, + 0xf02e7: 0xf02e7, + 0xf02e8: 0xf02e8, + 0xf02e9: 0xf02e9, + 0xf02ea: 0xf02ea, + 0xf02eb: 0xf02eb, + 0xf02ec: 0xf02ec, + 0xf02ed: 0xf02ed, + 0xf02ee: 0xf02ee, + 0xf02ef: 0xf02ef, + 0xf02f0: 0xf02f0, + 0xf02f1: 0xf02f1, + 0xf02f2: 0xf02f2, + 0xf02f3: 0xf02f3, + 0xf02f4: 0xf02f4, + 0xf02f5: 0xf02f5, + 0xf02f6: 0xf02f6, + 0xf02f7: 0xf02f7, + 0xf02f8: 0xf02f8, + 0xf02f9: 0xf02f9, + 0xf02fa: 0xf02fa, + 0xf02fb: 0xf02fb, + 0xf02fc: 0xf02fc, + 0xf02fd: 0xf02fd, + 0xf02fe: 0xf02fe, + 0xf02ff: 0xf02ff, + 0xf0300: 0xf0300, + 0xf0301: 0xf0301, + 0xf0302: 0xf0302, + 0xf0303: 0xf0303, + 0xf0304: 0xf0304, + 0xf0305: 0xf0305, + 0xf0306: 0xf0306, + 0xf0307: 0xf0307, + 0xf0308: 0xf0308, + 0xf0309: 0xf0309, + 0xf030a: 0xf030a, + 0xf030b: 0xf030b, + 0xf030c: 0xf030c, + 0xf030d: 0xf030d, + 0xf030e: 0xf030e, + 0xf030f: 0xf030f, + 0xf0310: 0xf0310, + 0xf0311: 0xf0311, + 0xf0312: 0xf0312, + 0xf0313: 0xf0313, + 0xf0314: 0xf0314, + 0xf0315: 0xf0315, + 0xf0316: 0xf0316, + 0xf0317: 0xf0317, + 0xf0318: 0xf0318, + 0xf0319: 0xf0319, + 0xf031a: 0xf031a, + 0xf031b: 0xf031b, + 0xf031c: 0xf031c, + 0xf031d: 0xf031d, + 0xf031e: 0xf031e, + 0xf031f: 0xf031f, + 0xf0320: 0xf0320, + 0xf0321: 0xf0321, + 0xf0322: 0xf0322, + 0xf0323: 0xf0323, + 0xf0324: 0xf0324, + 0xf0325: 0xf0325, + 0xf0326: 0xf0326, + 0xf0327: 0xf0327, + 0xf0328: 0xf0328, + 0xf0329: 0xf0329, + 0xf032a: 0xf032a, + 0xf032b: 0xf032b, + 0xf032c: 0xf032c, + 0xf032d: 0xf032d, + 0xf032e: 0xf032e, + 0xf032f: 0xf032f, + 0xf0330: 0xf0330, + 0xf0331: 0xf0331, + 0xf0332: 0xf0332, + 0xf0333: 0xf0333, + 0xf0334: 0xf0334, + 0xf0335: 0xf0335, + 0xf0336: 0xf0336, + 0xf0337: 0xf0337, + 0xf0338: 0xf0338, + 0xf0339: 0xf0339, + 0xf033a: 0xf033a, + 0xf033b: 0xf033b, + 0xf033c: 0xf033c, + 0xf033d: 0xf033d, + 0xf033e: 0xf033e, + 0xf033f: 0xf033f, + 0xf0340: 0xf0340, + 0xf0341: 0xf0341, + 0xf0342: 0xf0342, + 0xf0343: 0xf0343, + 0xf0344: 0xf0344, + 0xf0345: 0xf0345, + 0xf0346: 0xf0346, + 0xf0347: 0xf0347, + 0xf0348: 0xf0348, + 0xf0349: 0xf0349, + 0xf034a: 0xf034a, + 0xf034b: 0xf034b, + 0xf034c: 0xf034c, + 0xf034d: 0xf034d, + 0xf034e: 0xf034e, + 0xf034f: 0xf034f, + 0xf0350: 0xf0350, + 0xf0351: 0xf0351, + 0xf0352: 0xf0352, + 0xf0353: 0xf0353, + 0xf0354: 0xf0354, + 0xf0355: 0xf0355, + 0xf0356: 0xf0356, + 0xf0357: 0xf0357, + 0xf0358: 0xf0358, + 0xf0359: 0xf0359, + 0xf035a: 0xf035a, + 0xf035b: 0xf035b, + 0xf035c: 0xf035c, + 0xf035d: 0xf035d, + 0xf035e: 0xf035e, + 0xf035f: 0xf035f, + 0xf0360: 0xf0360, + 0xf0361: 0xf0361, + 0xf0362: 0xf0362, + 0xf0363: 0xf0363, + 0xf0364: 0xf0364, + 0xf0365: 0xf0365, + 0xf0366: 0xf0366, + 0xf0367: 0xf0367, + 0xf0368: 0xf0368, + 0xf0369: 0xf0369, + 0xf036a: 0xf036a, + 0xf036b: 0xf036b, + 0xf036c: 0xf036c, + 0xf036d: 0xf036d, + 0xf036e: 0xf036e, + 0xf036f: 0xf036f, + 0xf0370: 0xf0370, + 0xf0371: 0xf0371, + 0xf0372: 0xf0372, + 0xf0373: 0xf0373, + 0xf0374: 0xf0374, + 0xf0375: 0xf0375, + 0xf0376: 0xf0376, + 0xf0377: 0xf0377, + 0xf0378: 0xf0378, + 0xf0379: 0xf0379, + 0xf037a: 0xf037a, + 0xf037b: 0xf037b, + 0xf037c: 0xf037c, + 0xf037d: 0xf037d, + 0xf037e: 0xf037e, + 0xf037f: 0xf037f, + 0xf0380: 0xf0380, + 0xf0381: 0xf0381, + 0xf0382: 0xf0382, + 0xf0383: 0xf0383, + 0xf0384: 0xf0384, + 0xf0385: 0xf0385, + 0xf0386: 0xf0386, + 0xf0387: 0xf0387, + 0xf0388: 0xf0388, + 0xf0389: 0xf0389, + 0xf038a: 0xf038a, + 0xf038b: 0xf038b, + 0xf038c: 0xf038c, + 0xf038d: 0xf038d, + 0xf038e: 0xf038e, + 0xf038f: 0xf038f, + 0xf0390: 0xf0390, + 0xf0391: 0xf0391, + 0xf0392: 0xf0392, + 0xf0393: 0xf0393, + 0xf0394: 0xf0394, + 0xf0395: 0xf0395, + 0xf0396: 0xf0396, + 0xf0397: 0xf0397, + 0xf0398: 0xf0398, + 0xf0399: 0xf0399, + 0xf039a: 0xf039a, + 0xf039b: 0xf039b, + 0xf039c: 0xf039c, + 0xf039d: 0xf039d, + 0xf039e: 0xf039e, + 0xf039f: 0xf039f, + 0xf03a0: 0xf03a0, + 0xf03a1: 0xf03a1, + 0xf03a2: 0xf03a2, + 0xf03a3: 0xf03a3, + 0xf03a4: 0xf03a4, + 0xf03a5: 0xf03a5, + 0xf03a6: 0xf03a6, + 0xf03a7: 0xf03a7, + 0xf03a8: 0xf03a8, + 0xf03a9: 0xf03a9, + 0xf03aa: 0xf03aa, + 0xf03ab: 0xf03ab, + 0xf03ac: 0xf03ac, + 0xf03ad: 0xf03ad, + 0xf03ae: 0xf03ae, + 0xf03af: 0xf03af, + 0xf03b0: 0xf03b0, + 0xf03b1: 0xf03b1, + 0xf03b2: 0xf03b2, + 0xf03b3: 0xf03b3, + 0xf03b4: 0xf03b4, + 0xf03b5: 0xf03b5, + 0xf03b6: 0xf03b6, + 0xf03b7: 0xf03b7, + 0xf03b8: 0xf03b8, + 0xf03b9: 0xf03b9, + 0xf03ba: 0xf03ba, + 0xf03bb: 0xf03bb, + 0xf03bc: 0xf03bc, + 0xf03bd: 0xf03bd, + 0xf03be: 0xf03be, + 0xf03bf: 0xf03bf, + 0xf03c0: 0xf03c0, + 0xf03c1: 0xf03c1, + 0xf03c2: 0xf03c2, + 0xf03c3: 0xf03c3, + 0xf03c4: 0xf03c4, + 0xf03c5: 0xf03c5, + 0xf03c6: 0xf03c6, + 0xf03c7: 0xf03c7, + 0xf03c8: 0xf03c8, + 0xf03c9: 0xf03c9, + 0xf03ca: 0xf03ca, + 0xf03cb: 0xf03cb, + 0xf03cc: 0xf03cc, + 0xf03cd: 0xf03cd, + 0xf03ce: 0xf03ce, + 0xf03cf: 0xf03cf, + 0xf03d0: 0xf03d0, + 0xf03d1: 0xf03d1, + 0xf03d2: 0xf03d2, + 0xf03d3: 0xf03d3, + 0xf03d4: 0xf03d4, + 0xf03d5: 0xf03d5, + 0xf03d6: 0xf03d6, + 0xf03d7: 0xf03d7, + 0xf03d8: 0xf03d8, + 0xf03d9: 0xf03d9, + 0xf03da: 0xf03da, + 0xf03db: 0xf03db, + 0xf03dc: 0xf03dc, + 0xf03dd: 0xf03dd, + 0xf03de: 0xf03de, + 0xf03df: 0xf03df, + 0xf03e0: 0xf03e0, + 0xf03e1: 0xf03e1, + 0xf03e2: 0xf03e2, + 0xf03e3: 0xf03e3, + 0xf03e4: 0xf03e4, + 0xf03e5: 0xf03e5, + 0xf03e6: 0xf03e6, + 0xf03e7: 0xf03e7, + 0xf03e8: 0xf03e8, + 0xf03e9: 0xf03e9, + 0xf03ea: 0xf03ea, + 0xf03eb: 0xf03eb, + 0xf03ec: 0xf03ec, + 0xf03ed: 0xf03ed, + 0xf03ee: 0xf03ee, + 0xf03ef: 0xf03ef, + 0xf03f0: 0xf03f0, + 0xf03f1: 0xf03f1, + 0xf03f2: 0xf03f2, + 0xf03f3: 0xf03f3, + 0xf03f4: 0xf03f4, + 0xf03f5: 0xf03f5, + 0xf03f6: 0xf03f6, + 0xf03f7: 0xf03f7, + 0xf03f8: 0xf03f8, + 0xf03f9: 0xf03f9, + 0xf03fa: 0xf03fa, + 0xf03fb: 0xf03fb, + 0xf03fc: 0xf03fc, + 0xf03fd: 0xf03fd, + 0xf03fe: 0xf03fe, + 0xf03ff: 0xf03ff, + 0xf0400: 0xf0400, + 0xf0401: 0xf0401, + 0xf0402: 0xf0402, + 0xf0403: 0xf0403, + 0xf0404: 0xf0404, + 0xf0405: 0xf0405, + 0xf0406: 0xf0406, + 0xf0407: 0xf0407, + 0xf0408: 0xf0408, + 0xf0409: 0xf0409, + 0xf040a: 0xf040a, + 0xf040b: 0xf040b, + 0xf040c: 0xf040c, + 0xf040d: 0xf040d, + 0xf040e: 0xf040e, + 0xf040f: 0xf040f, + 0xf0410: 0xf0410, + 0xf0411: 0xf0411, + 0xf0412: 0xf0412, + 0xf0413: 0xf0413, + 0xf0414: 0xf0414, + 0xf0415: 0xf0415, + 0xf0416: 0xf0416, + 0xf0417: 0xf0417, + 0xf0418: 0xf0418, + 0xf0419: 0xf0419, + 0xf041a: 0xf041a, + 0xf041b: 0xf041b, + 0xf041c: 0xf041c, + 0xf041d: 0xf041d, + 0xf041e: 0xf041e, + 0xf041f: 0xf041f, + 0xf0420: 0xf0420, + 0xf0421: 0xf0421, + 0xf0422: 0xf0422, + 0xf0423: 0xf0423, + 0xf0424: 0xf0424, + 0xf0425: 0xf0425, + 0xf0426: 0xf0426, + 0xf0427: 0xf0427, + 0xf0428: 0xf0428, + 0xf0429: 0xf0429, + 0xf042a: 0xf042a, + 0xf042b: 0xf042b, + 0xf042c: 0xf042c, + 0xf042d: 0xf042d, + 0xf042e: 0xf042e, + 0xf042f: 0xf042f, + 0xf0430: 0xf0430, + 0xf0431: 0xf0431, + 0xf0432: 0xf0432, + 0xf0433: 0xf0433, + 0xf0434: 0xf0434, + 0xf0435: 0xf0435, + 0xf0436: 0xf0436, + 0xf0437: 0xf0437, + 0xf0438: 0xf0438, + 0xf0439: 0xf0439, + 0xf043a: 0xf043a, + 0xf043b: 0xf043b, + 0xf043c: 0xf043c, + 0xf043d: 0xf043d, + 0xf043e: 0xf043e, + 0xf043f: 0xf043f, + 0xf0440: 0xf0440, + 0xf0441: 0xf0441, + 0xf0442: 0xf0442, + 0xf0443: 0xf0443, + 0xf0444: 0xf0444, + 0xf0445: 0xf0445, + 0xf0446: 0xf0446, + 0xf0447: 0xf0447, + 0xf0448: 0xf0448, + 0xf0449: 0xf0449, + 0xf044a: 0xf044a, + 0xf044b: 0xf044b, + 0xf044c: 0xf044c, + 0xf044d: 0xf044d, + 0xf044e: 0xf044e, + 0xf044f: 0xf044f, + 0xf0450: 0xf0450, + 0xf0451: 0xf0451, + 0xf0452: 0xf0452, + 0xf0453: 0xf0453, + 0xf0454: 0xf0454, + 0xf0455: 0xf0455, + 0xf0456: 0xf0456, + 0xf0457: 0xf0457, + 0xf0458: 0xf0458, + 0xf0459: 0xf0459, + 0xf045a: 0xf045a, + 0xf045b: 0xf045b, + 0xf045c: 0xf045c, + 0xf045d: 0xf045d, + 0xf045e: 0xf045e, + 0xf045f: 0xf045f, + 0xf0460: 0xf0460, + 0xf0461: 0xf0461, + 0xf0462: 0xf0462, + 0xf0463: 0xf0463, + 0xf0464: 0xf0464, + 0xf0465: 0xf0465, + 0xf0466: 0xf0466, + 0xf0467: 0xf0467, + 0xf0468: 0xf0468, + 0xf0469: 0xf0469, + 0xf046a: 0xf046a, + 0xf046b: 0xf046b, + 0xf046c: 0xf046c, + 0xf046d: 0xf046d, + 0xf046e: 0xf046e, + 0xf046f: 0xf046f, + 0xf0470: 0xf0470, + 0xf0471: 0xf0471, + 0xf0472: 0xf0472, + 0xf0473: 0xf0473, + 0xf0474: 0xf0474, + 0xf0475: 0xf0475, + 0xf0476: 0xf0476, + 0xf0477: 0xf0477, + 0xf0478: 0xf0478, + 0xf0479: 0xf0479, + 0xf047a: 0xf047a, + 0xf047b: 0xf047b, + 0xf047c: 0xf047c, + 0xf047d: 0xf047d, + 0xf047e: 0xf047e, + 0xf047f: 0xf047f, + 0xf0480: 0xf0480, + 0xf0481: 0xf0481, + 0xf0482: 0xf0482, + 0xf0483: 0xf0483, + 0xf0484: 0xf0484, + 0xf0485: 0xf0485, + 0xf0486: 0xf0486, + 0xf0487: 0xf0487, + 0xf0488: 0xf0488, + 0xf0489: 0xf0489, + 0xf048a: 0xf048a, + 0xf048b: 0xf048b, + 0xf048c: 0xf048c, + 0xf048d: 0xf048d, + 0xf048e: 0xf048e, + 0xf048f: 0xf048f, + 0xf0490: 0xf0490, + 0xf0491: 0xf0491, + 0xf0492: 0xf0492, + 0xf0493: 0xf0493, + 0xf0494: 0xf0494, + 0xf0495: 0xf0495, + 0xf0496: 0xf0496, + 0xf0497: 0xf0497, + 0xf0498: 0xf0498, + 0xf0499: 0xf0499, + 0xf049a: 0xf049a, + 0xf049b: 0xf049b, + 0xf049c: 0xf049c, + 0xf049d: 0xf049d, + 0xf049e: 0xf049e, + 0xf049f: 0xf049f, + 0xf04a0: 0xf04a0, + 0xf04a1: 0xf04a1, + 0xf04a2: 0xf04a2, + 0xf04a3: 0xf04a3, + 0xf04a4: 0xf04a4, + 0xf04a5: 0xf04a5, + 0xf04a6: 0xf04a6, + 0xf04a7: 0xf04a7, + 0xf04a8: 0xf04a8, + 0xf04a9: 0xf04a9, + 0xf04aa: 0xf04aa, + 0xf04ab: 0xf04ab, + 0xf04ac: 0xf04ac, + 0xf04ad: 0xf04ad, + 0xf04ae: 0xf04ae, + 0xf04af: 0xf04af, + 0xf04b0: 0xf04b0, + 0xf04b1: 0xf04b1, + 0xf04b2: 0xf04b2, + 0xf04b3: 0xf04b3, + 0xf04b4: 0xf04b4, + 0xf04b5: 0xf04b5, + 0xf04b6: 0xf04b6, + 0xf04b7: 0xf04b7, + 0xf04b8: 0xf04b8, + 0xf04b9: 0xf04b9, + 0xf04ba: 0xf04ba, + 0xf04bb: 0xf04bb, + 0xf04bc: 0xf04bc, + 0xf04bd: 0xf04bd, + 0xf04be: 0xf04be, + 0xf04bf: 0xf04bf, + 0xf04c0: 0xf04c0, + 0xf04c1: 0xf04c1, + 0xf04c2: 0xf04c2, + 0xf04c3: 0xf04c3, + 0xf04c4: 0xf04c4, + 0xf04c5: 0xf04c5, + 0xf04c6: 0xf04c6, + 0xf04c7: 0xf04c7, + 0xf04c8: 0xf04c8, + 0xf04c9: 0xf04c9, + 0xf04ca: 0xf04ca, + 0xf04cb: 0xf04cb, + 0xf04cc: 0xf04cc, + 0xf04cd: 0xf04cd, + 0xf04ce: 0xf04ce, + 0xf04cf: 0xf04cf, + 0xf04d0: 0xf04d0, + 0xf04d1: 0xf04d1, + 0xf04d2: 0xf04d2, + 0xf04d3: 0xf04d3, + 0xf04d4: 0xf04d4, + 0xf04d5: 0xf04d5, + 0xf04d6: 0xf04d6, + 0xf04d7: 0xf04d7, + 0xf04d8: 0xf04d8, + 0xf04d9: 0xf04d9, + 0xf04da: 0xf04da, + 0xf04db: 0xf04db, + 0xf04dc: 0xf04dc, + 0xf04dd: 0xf04dd, + 0xf04de: 0xf04de, + 0xf04df: 0xf04df, + 0xf04e0: 0xf04e0, + 0xf04e1: 0xf04e1, + 0xf04e2: 0xf04e2, + 0xf04e3: 0xf04e3, + 0xf04e4: 0xf04e4, + 0xf04e5: 0xf04e5, + 0xf04e6: 0xf04e6, + 0xf04e7: 0xf04e7, + 0xf04e8: 0xf04e8, + 0xf04e9: 0xf04e9, + 0xf04ea: 0xf04ea, + 0xf04eb: 0xf04eb, + 0xf04ec: 0xf04ec, + 0xf04ed: 0xf04ed, + 0xf04ee: 0xf04ee, + 0xf04ef: 0xf04ef, + 0xf04f0: 0xf04f0, + 0xf04f1: 0xf04f1, + 0xf04f2: 0xf04f2, + 0xf04f3: 0xf04f3, + 0xf04f4: 0xf04f4, + 0xf04f5: 0xf04f5, + 0xf04f6: 0xf04f6, + 0xf04f7: 0xf04f7, + 0xf04f8: 0xf04f8, + 0xf04f9: 0xf04f9, + 0xf04fa: 0xf04fa, + 0xf04fb: 0xf04fb, + 0xf04fc: 0xf04fc, + 0xf04fd: 0xf04fd, + 0xf04fe: 0xf04fe, + 0xf04ff: 0xf04ff, + 0xf0500: 0xf0500, + 0xf0501: 0xf0501, + 0xf0502: 0xf0502, + 0xf0503: 0xf0503, + 0xf0504: 0xf0504, + 0xf0505: 0xf0505, + 0xf0506: 0xf0506, + 0xf0507: 0xf0507, + 0xf0508: 0xf0508, + 0xf0509: 0xf0509, + 0xf050a: 0xf050a, + 0xf050b: 0xf050b, + 0xf050c: 0xf050c, + 0xf050d: 0xf050d, + 0xf050e: 0xf050e, + 0xf050f: 0xf050f, + 0xf0510: 0xf0510, + 0xf0511: 0xf0511, + 0xf0512: 0xf0512, + 0xf0513: 0xf0513, + 0xf0514: 0xf0514, + 0xf0515: 0xf0515, + 0xf0516: 0xf0516, + 0xf0517: 0xf0517, + 0xf0518: 0xf0518, + 0xf0519: 0xf0519, + 0xf051a: 0xf051a, + 0xf051b: 0xf051b, + 0xf051c: 0xf051c, + 0xf051d: 0xf051d, + 0xf051e: 0xf051e, + 0xf051f: 0xf051f, + 0xf0520: 0xf0520, + 0xf0521: 0xf0521, + 0xf0522: 0xf0522, + 0xf0523: 0xf0523, + 0xf0524: 0xf0524, + 0xf0525: 0xf0525, + 0xf0526: 0xf0526, + 0xf0527: 0xf0527, + 0xf0528: 0xf0528, + 0xf0529: 0xf0529, + 0xf052a: 0xf052a, + 0xf052b: 0xf052b, + 0xf052c: 0xf052c, + 0xf052d: 0xf052d, + 0xf052e: 0xf052e, + 0xf052f: 0xf052f, + 0xf0530: 0xf0530, + 0xf0531: 0xf0531, + 0xf0532: 0xf0532, + 0xf0533: 0xf0533, + 0xf0534: 0xf0534, + 0xf0535: 0xf0535, + 0xf0536: 0xf0536, + 0xf0537: 0xf0537, + 0xf0538: 0xf0538, + 0xf0539: 0xf0539, + 0xf053a: 0xf053a, + 0xf053b: 0xf053b, + 0xf053c: 0xf053c, + 0xf053d: 0xf053d, + 0xf053e: 0xf053e, + 0xf053f: 0xf053f, + 0xf0540: 0xf0540, + 0xf0541: 0xf0541, + 0xf0542: 0xf0542, + 0xf0543: 0xf0543, + 0xf0544: 0xf0544, + 0xf0545: 0xf0545, + 0xf0546: 0xf0546, + 0xf0547: 0xf0547, + 0xf0548: 0xf0548, + 0xf0549: 0xf0549, + 0xf054a: 0xf054a, + 0xf054b: 0xf054b, + 0xf054c: 0xf054c, + 0xf054d: 0xf054d, + 0xf054e: 0xf054e, + 0xf054f: 0xf054f, + 0xf0550: 0xf0550, + 0xf0551: 0xf0551, + 0xf0552: 0xf0552, + 0xf0553: 0xf0553, + 0xf0554: 0xf0554, + 0xf0555: 0xf0555, + 0xf0556: 0xf0556, + 0xf0557: 0xf0557, + 0xf0558: 0xf0558, + 0xf0559: 0xf0559, + 0xf055a: 0xf055a, + 0xf055b: 0xf055b, + 0xf055c: 0xf055c, + 0xf055d: 0xf055d, + 0xf055e: 0xf055e, + 0xf055f: 0xf055f, + 0xf0560: 0xf0560, + 0xf0561: 0xf0561, + 0xf0562: 0xf0562, + 0xf0563: 0xf0563, + 0xf0564: 0xf0564, + 0xf0565: 0xf0565, + 0xf0566: 0xf0566, + 0xf0567: 0xf0567, + 0xf0568: 0xf0568, + 0xf0569: 0xf0569, + 0xf056a: 0xf056a, + 0xf056b: 0xf056b, + 0xf056c: 0xf056c, + 0xf056d: 0xf056d, + 0xf056e: 0xf056e, + 0xf056f: 0xf056f, + 0xf0570: 0xf0570, + 0xf0571: 0xf0571, + 0xf0572: 0xf0572, + 0xf0573: 0xf0573, + 0xf0574: 0xf0574, + 0xf0575: 0xf0575, + 0xf0576: 0xf0576, + 0xf0577: 0xf0577, + 0xf0578: 0xf0578, + 0xf0579: 0xf0579, + 0xf057a: 0xf057a, + 0xf057b: 0xf057b, + 0xf057c: 0xf057c, + 0xf057d: 0xf057d, + 0xf057e: 0xf057e, + 0xf057f: 0xf057f, + 0xf0580: 0xf0580, + 0xf0581: 0xf0581, + 0xf0582: 0xf0582, + 0xf0583: 0xf0583, + 0xf0584: 0xf0584, + 0xf0585: 0xf0585, + 0xf0586: 0xf0586, + 0xf0587: 0xf0587, + 0xf0588: 0xf0588, + 0xf0589: 0xf0589, + 0xf058a: 0xf058a, + 0xf058b: 0xf058b, + 0xf058c: 0xf058c, + 0xf058d: 0xf058d, + 0xf058e: 0xf058e, + 0xf058f: 0xf058f, + 0xf0590: 0xf0590, + 0xf0591: 0xf0591, + 0xf0592: 0xf0592, + 0xf0593: 0xf0593, + 0xf0594: 0xf0594, + 0xf0595: 0xf0595, + 0xf0596: 0xf0596, + 0xf0597: 0xf0597, + 0xf0598: 0xf0598, + 0xf0599: 0xf0599, + 0xf059a: 0xf059a, + 0xf059b: 0xf059b, + 0xf059c: 0xf059c, + 0xf059d: 0xf059d, + 0xf059e: 0xf059e, + 0xf059f: 0xf059f, + 0xf05a0: 0xf05a0, + 0xf05a1: 0xf05a1, + 0xf05a2: 0xf05a2, + 0xf05a3: 0xf05a3, + 0xf05a4: 0xf05a4, + 0xf05a5: 0xf05a5, + 0xf05a6: 0xf05a6, + 0xf05a7: 0xf05a7, + 0xf05a8: 0xf05a8, + 0xf05a9: 0xf05a9, + 0xf05aa: 0xf05aa, + 0xf05ab: 0xf05ab, + 0xf05ac: 0xf05ac, + 0xf05ad: 0xf05ad, + 0xf05ae: 0xf05ae, + 0xf05af: 0xf05af, + 0xf05b0: 0xf05b0, + 0xf05b1: 0xf05b1, + 0xf05b2: 0xf05b2, + 0xf05b3: 0xf05b3, + 0xf05b4: 0xf05b4, + 0xf05b5: 0xf05b5, + 0xf05b6: 0xf05b6, + 0xf05b7: 0xf05b7, + 0xf05b8: 0xf05b8, + 0xf05b9: 0xf05b9, + 0xf05ba: 0xf05ba, + 0xf05bb: 0xf05bb, + 0xf05bc: 0xf05bc, + 0xf05bd: 0xf05bd, + 0xf05be: 0xf05be, + 0xf05bf: 0xf05bf, + 0xf05c0: 0xf05c0, + 0xf05c1: 0xf05c1, + 0xf05c2: 0xf05c2, + 0xf05c3: 0xf05c3, + 0xf05c4: 0xf05c4, + 0xf05c5: 0xf05c5, + 0xf05c6: 0xf05c6, + 0xf05c7: 0xf05c7, + 0xf05c8: 0xf05c8, + 0xf05c9: 0xf05c9, + 0xf05ca: 0xf05ca, + 0xf05cb: 0xf05cb, + 0xf05cc: 0xf05cc, + 0xf05cd: 0xf05cd, + 0xf05ce: 0xf05ce, + 0xf05cf: 0xf05cf, + 0xf05d0: 0xf05d0, + 0xf05d1: 0xf05d1, + 0xf05d2: 0xf05d2, + 0xf05d3: 0xf05d3, + 0xf05d4: 0xf05d4, + 0xf05d5: 0xf05d5, + 0xf05d6: 0xf05d6, + 0xf05d7: 0xf05d7, + 0xf05d8: 0xf05d8, + 0xf05d9: 0xf05d9, + 0xf05da: 0xf05da, + 0xf05db: 0xf05db, + 0xf05dc: 0xf05dc, + 0xf05dd: 0xf05dd, + 0xf05de: 0xf05de, + 0xf05df: 0xf05df, + 0xf05e0: 0xf05e0, + 0xf05e1: 0xf05e1, + 0xf05e2: 0xf05e2, + 0xf05e3: 0xf05e3, + 0xf05e4: 0xf05e4, + 0xf05e5: 0xf05e5, + 0xf05e6: 0xf05e6, + 0xf05e7: 0xf05e7, + 0xf05e8: 0xf05e8, + 0xf05e9: 0xf05e9, + 0xf05ea: 0xf05ea, + 0xf05eb: 0xf05eb, + 0xf05ec: 0xf05ec, + 0xf05ed: 0xf05ed, + 0xf05ee: 0xf05ee, + 0xf05ef: 0xf05ef, + 0xf05f0: 0xf05f0, + 0xf05f1: 0xf05f1, + 0xf05f2: 0xf05f2, + 0xf05f3: 0xf05f3, + 0xf05f4: 0xf05f4, + 0xf05f5: 0xf05f5, + 0xf05f6: 0xf05f6, + 0xf05f7: 0xf05f7, + 0xf05f8: 0xf05f8, + 0xf05f9: 0xf05f9, + 0xf05fa: 0xf05fa, + 0xf05fb: 0xf05fb, + 0xf05fc: 0xf05fc, + 0xf05fd: 0xf05fd, + 0xf05fe: 0xf05fe, + 0xf05ff: 0xf05ff, + 0xf0600: 0xf0600, + 0xf0601: 0xf0601, + 0xf0602: 0xf0602, + 0xf0603: 0xf0603, + 0xf0604: 0xf0604, + 0xf0605: 0xf0605, + 0xf0606: 0xf0606, + 0xf0607: 0xf0607, + 0xf0608: 0xf0608, + 0xf0609: 0xf0609, + 0xf060a: 0xf060a, + 0xf060b: 0xf060b, + 0xf060c: 0xf060c, + 0xf060d: 0xf060d, + 0xf060e: 0xf060e, + 0xf060f: 0xf060f, + 0xf0610: 0xf0610, + 0xf0611: 0xf0611, + 0xf0612: 0xf0612, + 0xf0613: 0xf0613, + 0xf0614: 0xf0614, + 0xf0615: 0xf0615, + 0xf0616: 0xf0616, + 0xf0617: 0xf0617, + 0xf0618: 0xf0618, + 0xf0619: 0xf0619, + 0xf061a: 0xf061a, + 0xf061b: 0xf061b, + 0xf061c: 0xf061c, + 0xf061d: 0xf061d, + 0xf061e: 0xf061e, + 0xf061f: 0xf061f, + 0xf0620: 0xf0620, + 0xf0621: 0xf0621, + 0xf0622: 0xf0622, + 0xf0623: 0xf0623, + 0xf0624: 0xf0624, + 0xf0625: 0xf0625, + 0xf0626: 0xf0626, + 0xf0627: 0xf0627, + 0xf0628: 0xf0628, + 0xf0629: 0xf0629, + 0xf062a: 0xf062a, + 0xf062b: 0xf062b, + 0xf062c: 0xf062c, + 0xf062d: 0xf062d, + 0xf062e: 0xf062e, + 0xf062f: 0xf062f, + 0xf0630: 0xf0630, + 0xf0631: 0xf0631, + 0xf0632: 0xf0632, + 0xf0633: 0xf0633, + 0xf0634: 0xf0634, + 0xf0635: 0xf0635, + 0xf0636: 0xf0636, + 0xf0637: 0xf0637, + 0xf0638: 0xf0638, + 0xf0639: 0xf0639, + 0xf063a: 0xf063a, + 0xf063b: 0xf063b, + 0xf063c: 0xf063c, + 0xf063d: 0xf063d, + 0xf063e: 0xf063e, + 0xf063f: 0xf063f, + 0xf0640: 0xf0640, + 0xf0641: 0xf0641, + 0xf0642: 0xf0642, + 0xf0643: 0xf0643, + 0xf0644: 0xf0644, + 0xf0645: 0xf0645, + 0xf0646: 0xf0646, + 0xf0647: 0xf0647, + 0xf0648: 0xf0648, + 0xf0649: 0xf0649, + 0xf064a: 0xf064a, + 0xf064b: 0xf064b, + 0xf064c: 0xf064c, + 0xf064d: 0xf064d, + 0xf064e: 0xf064e, + 0xf064f: 0xf064f, + 0xf0650: 0xf0650, + 0xf0651: 0xf0651, + 0xf0652: 0xf0652, + 0xf0653: 0xf0653, + 0xf0654: 0xf0654, + 0xf0655: 0xf0655, + 0xf0656: 0xf0656, + 0xf0657: 0xf0657, + 0xf0658: 0xf0658, + 0xf0659: 0xf0659, + 0xf065a: 0xf065a, + 0xf065b: 0xf065b, + 0xf065c: 0xf065c, + 0xf065d: 0xf065d, + 0xf065e: 0xf065e, + 0xf065f: 0xf065f, + 0xf0660: 0xf0660, + 0xf0661: 0xf0661, + 0xf0662: 0xf0662, + 0xf0663: 0xf0663, + 0xf0664: 0xf0664, + 0xf0665: 0xf0665, + 0xf0666: 0xf0666, + 0xf0667: 0xf0667, + 0xf0668: 0xf0668, + 0xf0669: 0xf0669, + 0xf066a: 0xf066a, + 0xf066b: 0xf066b, + 0xf066c: 0xf066c, + 0xf066d: 0xf066d, + 0xf066e: 0xf066e, + 0xf066f: 0xf066f, + 0xf0670: 0xf0670, + 0xf0671: 0xf0671, + 0xf0672: 0xf0672, + 0xf0673: 0xf0673, + 0xf0674: 0xf0674, + 0xf0675: 0xf0675, + 0xf0676: 0xf0676, + 0xf0677: 0xf0677, + 0xf0678: 0xf0678, + 0xf0679: 0xf0679, + 0xf067a: 0xf067a, + 0xf067b: 0xf067b, + 0xf067c: 0xf067c, + 0xf067d: 0xf067d, + 0xf067e: 0xf067e, + 0xf067f: 0xf067f, + 0xf0680: 0xf0680, + 0xf0681: 0xf0681, + 0xf0682: 0xf0682, + 0xf0683: 0xf0683, + 0xf0684: 0xf0684, + 0xf0685: 0xf0685, + 0xf0686: 0xf0686, + 0xf0687: 0xf0687, + 0xf0688: 0xf0688, + 0xf0689: 0xf0689, + 0xf068a: 0xf068a, + 0xf068b: 0xf068b, + 0xf068c: 0xf068c, + 0xf068d: 0xf068d, + 0xf068e: 0xf068e, + 0xf068f: 0xf068f, + 0xf0690: 0xf0690, + 0xf0691: 0xf0691, + 0xf0692: 0xf0692, + 0xf0693: 0xf0693, + 0xf0694: 0xf0694, + 0xf0695: 0xf0695, + 0xf0696: 0xf0696, + 0xf0697: 0xf0697, + 0xf0698: 0xf0698, + 0xf0699: 0xf0699, + 0xf069a: 0xf069a, + 0xf069b: 0xf069b, + 0xf069c: 0xf069c, + 0xf069d: 0xf069d, + 0xf069e: 0xf069e, + 0xf069f: 0xf069f, + 0xf06a0: 0xf06a0, + 0xf06a1: 0xf06a1, + 0xf06a2: 0xf06a2, + 0xf06a3: 0xf06a3, + 0xf06a4: 0xf06a4, + 0xf06a5: 0xf06a5, + 0xf06a6: 0xf06a6, + 0xf06a7: 0xf06a7, + 0xf06a8: 0xf06a8, + 0xf06a9: 0xf06a9, + 0xf06aa: 0xf06aa, + 0xf06ab: 0xf06ab, + 0xf06ac: 0xf06ac, + 0xf06ad: 0xf06ad, + 0xf06ae: 0xf06ae, + 0xf06af: 0xf06af, + 0xf06b0: 0xf06b0, + 0xf06b1: 0xf06b1, + 0xf06b2: 0xf06b2, + 0xf06b3: 0xf06b3, + 0xf06b4: 0xf06b4, + 0xf06b5: 0xf06b5, + 0xf06b6: 0xf06b6, + 0xf06b7: 0xf06b7, + 0xf06b8: 0xf06b8, + 0xf06b9: 0xf06b9, + 0xf06ba: 0xf06ba, + 0xf06bb: 0xf06bb, + 0xf06bc: 0xf06bc, + 0xf06bd: 0xf06bd, + 0xf06be: 0xf06be, + 0xf06bf: 0xf06bf, + 0xf06c0: 0xf06c0, + 0xf06c1: 0xf06c1, + 0xf06c2: 0xf06c2, + 0xf06c3: 0xf06c3, + 0xf06c4: 0xf06c4, + 0xf06c5: 0xf06c5, + 0xf06c6: 0xf06c6, + 0xf06c7: 0xf06c7, + 0xf06c8: 0xf06c8, + 0xf06c9: 0xf06c9, + 0xf06ca: 0xf06ca, + 0xf06cb: 0xf06cb, + 0xf06cc: 0xf06cc, + 0xf06cd: 0xf06cd, + 0xf06ce: 0xf06ce, + 0xf06cf: 0xf06cf, + 0xf06d0: 0xf06d0, + 0xf06d1: 0xf06d1, + 0xf06d2: 0xf06d2, + 0xf06d3: 0xf06d3, + 0xf06d4: 0xf06d4, + 0xf06d5: 0xf06d5, + 0xf06d6: 0xf06d6, + 0xf06d7: 0xf06d7, + 0xf06d8: 0xf06d8, + 0xf06d9: 0xf06d9, + 0xf06da: 0xf06da, + 0xf06db: 0xf06db, + 0xf06dc: 0xf06dc, + 0xf06dd: 0xf06dd, + 0xf06de: 0xf06de, + 0xf06df: 0xf06df, + 0xf06e0: 0xf06e0, + 0xf06e1: 0xf06e1, + 0xf06e2: 0xf06e2, + 0xf06e3: 0xf06e3, + 0xf06e4: 0xf06e4, + 0xf06e5: 0xf06e5, + 0xf06e6: 0xf06e6, + 0xf06e7: 0xf06e7, + 0xf06e8: 0xf06e8, + 0xf06e9: 0xf06e9, + 0xf06ea: 0xf06ea, + 0xf06eb: 0xf06eb, + 0xf06ec: 0xf06ec, + 0xf06ed: 0xf06ed, + 0xf06ee: 0xf06ee, + 0xf06ef: 0xf06ef, + 0xf06f0: 0xf06f0, + 0xf06f1: 0xf06f1, + 0xf06f2: 0xf06f2, + 0xf06f3: 0xf06f3, + 0xf06f4: 0xf06f4, + 0xf06f5: 0xf06f5, + 0xf06f6: 0xf06f6, + 0xf06f7: 0xf06f7, + 0xf06f8: 0xf06f8, + 0xf06f9: 0xf06f9, + 0xf06fa: 0xf06fa, + 0xf06fb: 0xf06fb, + 0xf06fc: 0xf06fc, + 0xf06fd: 0xf06fd, + 0xf06fe: 0xf06fe, + 0xf06ff: 0xf06ff, + 0xf0700: 0xf0700, + 0xf0701: 0xf0701, + 0xf0702: 0xf0702, + 0xf0703: 0xf0703, + 0xf0704: 0xf0704, + 0xf0705: 0xf0705, + 0xf0706: 0xf0706, + 0xf0707: 0xf0707, + 0xf0708: 0xf0708, + 0xf0709: 0xf0709, + 0xf070a: 0xf070a, + 0xf070b: 0xf070b, + 0xf070c: 0xf070c, + 0xf070d: 0xf070d, + 0xf070e: 0xf070e, + 0xf070f: 0xf070f, + 0xf0710: 0xf0710, + 0xf0711: 0xf0711, + 0xf0712: 0xf0712, + 0xf0713: 0xf0713, + 0xf0714: 0xf0714, + 0xf0715: 0xf0715, + 0xf0716: 0xf0716, + 0xf0717: 0xf0717, + 0xf0718: 0xf0718, + 0xf0719: 0xf0719, + 0xf071a: 0xf071a, + 0xf071b: 0xf071b, + 0xf071c: 0xf071c, + 0xf071d: 0xf071d, + 0xf071e: 0xf071e, + 0xf071f: 0xf071f, + 0xf0720: 0xf0720, + 0xf0721: 0xf0721, + 0xf0722: 0xf0722, + 0xf0723: 0xf0723, + 0xf0724: 0xf0724, + 0xf0725: 0xf0725, + 0xf0726: 0xf0726, + 0xf0727: 0xf0727, + 0xf0728: 0xf0728, + 0xf0729: 0xf0729, + 0xf072a: 0xf072a, + 0xf072b: 0xf072b, + 0xf072c: 0xf072c, + 0xf072d: 0xf072d, + 0xf072e: 0xf072e, + 0xf072f: 0xf072f, + 0xf0730: 0xf0730, + 0xf0731: 0xf0731, + 0xf0732: 0xf0732, + 0xf0733: 0xf0733, + 0xf0734: 0xf0734, + 0xf0735: 0xf0735, + 0xf0736: 0xf0736, + 0xf0737: 0xf0737, + 0xf0738: 0xf0738, + 0xf0739: 0xf0739, + 0xf073a: 0xf073a, + 0xf073b: 0xf073b, + 0xf073c: 0xf073c, + 0xf073d: 0xf073d, + 0xf073e: 0xf073e, + 0xf073f: 0xf073f, + 0xf0740: 0xf0740, + 0xf0741: 0xf0741, + 0xf0742: 0xf0742, + 0xf0743: 0xf0743, + 0xf0744: 0xf0744, + 0xf0745: 0xf0745, + 0xf0746: 0xf0746, + 0xf0747: 0xf0747, + 0xf0748: 0xf0748, + 0xf0749: 0xf0749, + 0xf074a: 0xf074a, + 0xf074b: 0xf074b, + 0xf074c: 0xf074c, + 0xf074d: 0xf074d, + 0xf074e: 0xf074e, + 0xf074f: 0xf074f, + 0xf0750: 0xf0750, + 0xf0751: 0xf0751, + 0xf0752: 0xf0752, + 0xf0753: 0xf0753, + 0xf0754: 0xf0754, + 0xf0755: 0xf0755, + 0xf0756: 0xf0756, + 0xf0757: 0xf0757, + 0xf0758: 0xf0758, + 0xf0759: 0xf0759, + 0xf075a: 0xf075a, + 0xf075b: 0xf075b, + 0xf075c: 0xf075c, + 0xf075d: 0xf075d, + 0xf075e: 0xf075e, + 0xf075f: 0xf075f, + 0xf0760: 0xf0760, + 0xf0761: 0xf0761, + 0xf0762: 0xf0762, + 0xf0763: 0xf0763, + 0xf0764: 0xf0764, + 0xf0765: 0xf0765, + 0xf0766: 0xf0766, + 0xf0767: 0xf0767, + 0xf0768: 0xf0768, + 0xf0769: 0xf0769, + 0xf076a: 0xf076a, + 0xf076b: 0xf076b, + 0xf076c: 0xf076c, + 0xf076d: 0xf076d, + 0xf076e: 0xf076e, + 0xf076f: 0xf076f, + 0xf0770: 0xf0770, + 0xf0771: 0xf0771, + 0xf0772: 0xf0772, + 0xf0773: 0xf0773, + 0xf0774: 0xf0774, + 0xf0775: 0xf0775, + 0xf0776: 0xf0776, + 0xf0777: 0xf0777, + 0xf0778: 0xf0778, + 0xf0779: 0xf0779, + 0xf077a: 0xf077a, + 0xf077b: 0xf077b, + 0xf077c: 0xf077c, + 0xf077d: 0xf077d, + 0xf077e: 0xf077e, + 0xf077f: 0xf077f, + 0xf0780: 0xf0780, + 0xf0781: 0xf0781, + 0xf0782: 0xf0782, + 0xf0783: 0xf0783, + 0xf0784: 0xf0784, + 0xf0785: 0xf0785, + 0xf0786: 0xf0786, + 0xf0787: 0xf0787, + 0xf0788: 0xf0788, + 0xf0789: 0xf0789, + 0xf078a: 0xf078a, + 0xf078b: 0xf078b, + 0xf078c: 0xf078c, + 0xf078d: 0xf078d, + 0xf078e: 0xf078e, + 0xf078f: 0xf078f, + 0xf0790: 0xf0790, + 0xf0791: 0xf0791, + 0xf0792: 0xf0792, + 0xf0793: 0xf0793, + 0xf0794: 0xf0794, + 0xf0795: 0xf0795, + 0xf0796: 0xf0796, + 0xf0797: 0xf0797, + 0xf0798: 0xf0798, + 0xf0799: 0xf0799, + 0xf079a: 0xf079a, + 0xf079b: 0xf079b, + 0xf079c: 0xf079c, + 0xf079d: 0xf079d, + 0xf079e: 0xf079e, + 0xf079f: 0xf079f, + 0xf07a0: 0xf07a0, + 0xf07a1: 0xf07a1, + 0xf07a2: 0xf07a2, + 0xf07a3: 0xf07a3, + 0xf07a4: 0xf07a4, + 0xf07a5: 0xf07a5, + 0xf07a6: 0xf07a6, + 0xf07a7: 0xf07a7, + 0xf07a8: 0xf07a8, + 0xf07a9: 0xf07a9, + 0xf07aa: 0xf07aa, + 0xf07ab: 0xf07ab, + 0xf07ac: 0xf07ac, + 0xf07ad: 0xf07ad, + 0xf07ae: 0xf07ae, + 0xf07af: 0xf07af, + 0xf07b0: 0xf07b0, + 0xf07b1: 0xf07b1, + 0xf07b2: 0xf07b2, + 0xf07b3: 0xf07b3, + 0xf07b4: 0xf07b4, + 0xf07b5: 0xf07b5, + 0xf07b6: 0xf07b6, + 0xf07b7: 0xf07b7, + 0xf07b8: 0xf07b8, + 0xf07b9: 0xf07b9, + 0xf07ba: 0xf07ba, + 0xf07bb: 0xf07bb, + 0xf07bc: 0xf07bc, + 0xf07bd: 0xf07bd, + 0xf07be: 0xf07be, + 0xf07bf: 0xf07bf, + 0xf07c0: 0xf07c0, + 0xf07c1: 0xf07c1, + 0xf07c2: 0xf07c2, + 0xf07c3: 0xf07c3, + 0xf07c4: 0xf07c4, + 0xf07c5: 0xf07c5, + 0xf07c6: 0xf07c6, + 0xf07c7: 0xf07c7, + 0xf07c8: 0xf07c8, + 0xf07c9: 0xf07c9, + 0xf07ca: 0xf07ca, + 0xf07cb: 0xf07cb, + 0xf07cc: 0xf07cc, + 0xf07cd: 0xf07cd, + 0xf07ce: 0xf07ce, + 0xf07cf: 0xf07cf, + 0xf07d0: 0xf07d0, + 0xf07d1: 0xf07d1, + 0xf07d2: 0xf07d2, + 0xf07d3: 0xf07d3, + 0xf07d4: 0xf07d4, + 0xf07d5: 0xf07d5, + 0xf07d6: 0xf07d6, + 0xf07d7: 0xf07d7, + 0xf07d8: 0xf07d8, + 0xf07d9: 0xf07d9, + 0xf07da: 0xf07da, + 0xf07db: 0xf07db, + 0xf07dc: 0xf07dc, + 0xf07dd: 0xf07dd, + 0xf07de: 0xf07de, + 0xf07df: 0xf07df, + 0xf07e0: 0xf07e0, + 0xf07e1: 0xf07e1, + 0xf07e2: 0xf07e2, + 0xf07e3: 0xf07e3, + 0xf07e4: 0xf07e4, + 0xf07e5: 0xf07e5, + 0xf07e6: 0xf07e6, + 0xf07e7: 0xf07e7, + 0xf07e8: 0xf07e8, + 0xf07e9: 0xf07e9, + 0xf07ea: 0xf07ea, + 0xf07eb: 0xf07eb, + 0xf07ec: 0xf07ec, + 0xf07ed: 0xf07ed, + 0xf07ee: 0xf07ee, + 0xf07ef: 0xf07ef, + 0xf07f0: 0xf07f0, + 0xf07f1: 0xf07f1, + 0xf07f2: 0xf07f2, + 0xf07f3: 0xf07f3, + 0xf07f4: 0xf07f4, + 0xf07f5: 0xf07f5, + 0xf07f6: 0xf07f6, + 0xf07f7: 0xf07f7, + 0xf07f8: 0xf07f8, + 0xf07f9: 0xf07f9, + 0xf07fa: 0xf07fa, + 0xf07fb: 0xf07fb, + 0xf07fc: 0xf07fc, + 0xf07fd: 0xf07fd, + 0xf07fe: 0xf07fe, + 0xf07ff: 0xf07ff, + 0xf0800: 0xf0800, + 0xf0801: 0xf0801, + 0xf0802: 0xf0802, + 0xf0803: 0xf0803, + 0xf0804: 0xf0804, + 0xf0805: 0xf0805, + 0xf0806: 0xf0806, + 0xf0807: 0xf0807, + 0xf0808: 0xf0808, + 0xf0809: 0xf0809, + 0xf080a: 0xf080a, + 0xf080b: 0xf080b, + 0xf080c: 0xf080c, + 0xf080d: 0xf080d, + 0xf080e: 0xf080e, + 0xf080f: 0xf080f, + 0xf0810: 0xf0810, + 0xf0811: 0xf0811, + 0xf0812: 0xf0812, + 0xf0813: 0xf0813, + 0xf0814: 0xf0814, + 0xf0815: 0xf0815, + 0xf0816: 0xf0816, + 0xf0817: 0xf0817, + 0xf0818: 0xf0818, + 0xf0819: 0xf0819, + 0xf081a: 0xf081a, + 0xf081b: 0xf081b, + 0xf081c: 0xf081c, + 0xf081d: 0xf081d, + 0xf081e: 0xf081e, + 0xf081f: 0xf081f, + 0xf0820: 0xf0820, + 0xf0821: 0xf0821, + 0xf0822: 0xf0822, + 0xf0823: 0xf0823, + 0xf0824: 0xf0824, + 0xf0825: 0xf0825, + 0xf0826: 0xf0826, + 0xf0827: 0xf0827, + 0xf0828: 0xf0828, + 0xf0829: 0xf0829, + 0xf082a: 0xf082a, + 0xf082b: 0xf082b, + 0xf082c: 0xf082c, + 0xf082d: 0xf082d, + 0xf082e: 0xf082e, + 0xf082f: 0xf082f, + 0xf0830: 0xf0830, + 0xf0831: 0xf0831, + 0xf0832: 0xf0832, + 0xf0833: 0xf0833, + 0xf0834: 0xf0834, + 0xf0835: 0xf0835, + 0xf0836: 0xf0836, + 0xf0837: 0xf0837, + 0xf0838: 0xf0838, + 0xf0839: 0xf0839, + 0xf083a: 0xf083a, + 0xf083b: 0xf083b, + 0xf083c: 0xf083c, + 0xf083d: 0xf083d, + 0xf083e: 0xf083e, + 0xf083f: 0xf083f, + 0xf0840: 0xf0840, + 0xf0841: 0xf0841, + 0xf0842: 0xf0842, + 0xf0843: 0xf0843, + 0xf0844: 0xf0844, + 0xf0845: 0xf0845, + 0xf0846: 0xf0846, + 0xf0847: 0xf0847, + 0xf0848: 0xf0848, + 0xf0849: 0xf0849, + 0xf084a: 0xf084a, + 0xf084b: 0xf084b, + 0xf084c: 0xf084c, + 0xf084d: 0xf084d, + 0xf084e: 0xf084e, + 0xf084f: 0xf084f, + 0xf0850: 0xf0850, + 0xf0851: 0xf0851, + 0xf0852: 0xf0852, + 0xf0853: 0xf0853, + 0xf0854: 0xf0854, + 0xf0855: 0xf0855, + 0xf0856: 0xf0856, + 0xf0857: 0xf0857, + 0xf0858: 0xf0858, + 0xf0859: 0xf0859, + 0xf085a: 0xf085a, + 0xf085b: 0xf085b, + 0xf085c: 0xf085c, + 0xf085d: 0xf085d, + 0xf085e: 0xf085e, + 0xf085f: 0xf085f, + 0xf0860: 0xf0860, + 0xf0861: 0xf0861, + 0xf0862: 0xf0862, + 0xf0863: 0xf0863, + 0xf0864: 0xf0864, + 0xf0865: 0xf0865, + 0xf0866: 0xf0866, + 0xf0867: 0xf0867, + 0xf0868: 0xf0868, + 0xf0869: 0xf0869, + 0xf086a: 0xf086a, + 0xf086b: 0xf086b, + 0xf086c: 0xf086c, + 0xf086d: 0xf086d, + 0xf086e: 0xf086e, + 0xf086f: 0xf086f, + 0xf0870: 0xf0870, + 0xf0871: 0xf0871, + 0xf0872: 0xf0872, + 0xf0873: 0xf0873, + 0xf0874: 0xf0874, + 0xf0875: 0xf0875, + 0xf0876: 0xf0876, + 0xf0877: 0xf0877, + 0xf0878: 0xf0878, + 0xf0879: 0xf0879, + 0xf087a: 0xf087a, + 0xf087b: 0xf087b, + 0xf087c: 0xf087c, + 0xf087d: 0xf087d, + 0xf087e: 0xf087e, + 0xf087f: 0xf087f, + 0xf0880: 0xf0880, + 0xf0881: 0xf0881, + 0xf0882: 0xf0882, + 0xf0883: 0xf0883, + 0xf0884: 0xf0884, + 0xf0885: 0xf0885, + 0xf0886: 0xf0886, + 0xf0887: 0xf0887, + 0xf0888: 0xf0888, + 0xf0889: 0xf0889, + 0xf088a: 0xf088a, + 0xf088b: 0xf088b, + 0xf088c: 0xf088c, + 0xf088d: 0xf088d, + 0xf088e: 0xf088e, + 0xf088f: 0xf088f, + 0xf0890: 0xf0890, + 0xf0891: 0xf0891, + 0xf0892: 0xf0892, + 0xf0893: 0xf0893, + 0xf0894: 0xf0894, + 0xf0895: 0xf0895, + 0xf0896: 0xf0896, + 0xf0897: 0xf0897, + 0xf0898: 0xf0898, + 0xf0899: 0xf0899, + 0xf089a: 0xf089a, + 0xf089b: 0xf089b, + 0xf089c: 0xf089c, + 0xf089d: 0xf089d, + 0xf089e: 0xf089e, + 0xf089f: 0xf089f, + 0xf08a0: 0xf08a0, + 0xf08a1: 0xf08a1, + 0xf08a2: 0xf08a2, + 0xf08a3: 0xf08a3, + 0xf08a4: 0xf08a4, + 0xf08a5: 0xf08a5, + 0xf08a6: 0xf08a6, + 0xf08a7: 0xf08a7, + 0xf08a8: 0xf08a8, + 0xf08a9: 0xf08a9, + 0xf08aa: 0xf08aa, + 0xf08ab: 0xf08ab, + 0xf08ac: 0xf08ac, + 0xf08ad: 0xf08ad, + 0xf08ae: 0xf08ae, + 0xf08af: 0xf08af, + 0xf08b0: 0xf08b0, + 0xf08b1: 0xf08b1, + 0xf08b2: 0xf08b2, + 0xf08b3: 0xf08b3, + 0xf08b4: 0xf08b4, + 0xf08b5: 0xf08b5, + 0xf08b6: 0xf08b6, + 0xf08b7: 0xf08b7, + 0xf08b8: 0xf08b8, + 0xf08b9: 0xf08b9, + 0xf08ba: 0xf08ba, + 0xf08bb: 0xf08bb, + 0xf08bc: 0xf08bc, + 0xf08bd: 0xf08bd, + 0xf08be: 0xf08be, + 0xf08bf: 0xf08bf, + 0xf08c0: 0xf08c0, + 0xf08c1: 0xf08c1, + 0xf08c2: 0xf08c2, + 0xf08c3: 0xf08c3, + 0xf08c4: 0xf08c4, + 0xf08c5: 0xf08c5, + 0xf08c6: 0xf08c6, + 0xf08c7: 0xf08c7, + 0xf08c8: 0xf08c8, + 0xf08c9: 0xf08c9, + 0xf08ca: 0xf08ca, + 0xf08cb: 0xf08cb, + 0xf08cc: 0xf08cc, + 0xf08cd: 0xf08cd, + 0xf08ce: 0xf08ce, + 0xf08cf: 0xf08cf, + 0xf08d0: 0xf08d0, + 0xf08d1: 0xf08d1, + 0xf08d2: 0xf08d2, + 0xf08d3: 0xf08d3, + 0xf08d4: 0xf08d4, + 0xf08d5: 0xf08d5, + 0xf08d6: 0xf08d6, + 0xf08d7: 0xf08d7, + 0xf08d8: 0xf08d8, + 0xf08d9: 0xf08d9, + 0xf08da: 0xf08da, + 0xf08db: 0xf08db, + 0xf08dc: 0xf08dc, + 0xf08dd: 0xf08dd, + 0xf08de: 0xf08de, + 0xf08df: 0xf08df, + 0xf08e0: 0xf08e0, + 0xf08e1: 0xf08e1, + 0xf08e2: 0xf08e2, + 0xf08e3: 0xf08e3, + 0xf08e4: 0xf08e4, + 0xf08e5: 0xf08e5, + 0xf08e6: 0xf08e6, + 0xf08e7: 0xf08e7, + 0xf08e8: 0xf08e8, + 0xf08e9: 0xf08e9, + 0xf08ea: 0xf08ea, + 0xf08eb: 0xf08eb, + 0xf08ec: 0xf08ec, + 0xf08ed: 0xf08ed, + 0xf08ee: 0xf08ee, + 0xf08ef: 0xf08ef, + 0xf08f0: 0xf08f0, + 0xf08f1: 0xf08f1, + 0xf08f2: 0xf08f2, + 0xf08f3: 0xf08f3, + 0xf08f4: 0xf08f4, + 0xf08f5: 0xf08f5, + 0xf08f6: 0xf08f6, + 0xf08f7: 0xf08f7, + 0xf08f8: 0xf08f8, + 0xf08f9: 0xf08f9, + 0xf08fa: 0xf08fa, + 0xf08fb: 0xf08fb, + 0xf08fc: 0xf08fc, + 0xf08fd: 0xf08fd, + 0xf08fe: 0xf08fe, + 0xf08ff: 0xf08ff, + 0xf0900: 0xf0900, + 0xf0901: 0xf0901, + 0xf0902: 0xf0902, + 0xf0903: 0xf0903, + 0xf0904: 0xf0904, + 0xf0905: 0xf0905, + 0xf0906: 0xf0906, + 0xf0907: 0xf0907, + 0xf0908: 0xf0908, + 0xf0909: 0xf0909, + 0xf090a: 0xf090a, + 0xf090b: 0xf090b, + 0xf090c: 0xf090c, + 0xf090d: 0xf090d, + 0xf090e: 0xf090e, + 0xf090f: 0xf090f, + 0xf0910: 0xf0910, + 0xf0911: 0xf0911, + 0xf0912: 0xf0912, + 0xf0913: 0xf0913, + 0xf0914: 0xf0914, + 0xf0915: 0xf0915, + 0xf0916: 0xf0916, + 0xf0917: 0xf0917, + 0xf0918: 0xf0918, + 0xf0919: 0xf0919, + 0xf091a: 0xf091a, + 0xf091b: 0xf091b, + 0xf091c: 0xf091c, + 0xf091d: 0xf091d, + 0xf091e: 0xf091e, + 0xf091f: 0xf091f, + 0xf0920: 0xf0920, + 0xf0921: 0xf0921, + 0xf0922: 0xf0922, + 0xf0923: 0xf0923, + 0xf0924: 0xf0924, + 0xf0925: 0xf0925, + 0xf0926: 0xf0926, + 0xf0927: 0xf0927, + 0xf0928: 0xf0928, + 0xf0929: 0xf0929, + 0xf092a: 0xf092a, + 0xf092b: 0xf092b, + 0xf092c: 0xf092c, + 0xf092d: 0xf092d, + 0xf092e: 0xf092e, + 0xf092f: 0xf092f, + 0xf0930: 0xf0930, + 0xf0931: 0xf0931, + 0xf0932: 0xf0932, + 0xf0933: 0xf0933, + 0xf0934: 0xf0934, + 0xf0935: 0xf0935, + 0xf0936: 0xf0936, + 0xf0937: 0xf0937, + 0xf0938: 0xf0938, + 0xf0939: 0xf0939, + 0xf093a: 0xf093a, + 0xf093b: 0xf093b, + 0xf093c: 0xf093c, + 0xf093d: 0xf093d, + 0xf093e: 0xf093e, + 0xf093f: 0xf093f, + 0xf0940: 0xf0940, + 0xf0941: 0xf0941, + 0xf0942: 0xf0942, + 0xf0943: 0xf0943, + 0xf0944: 0xf0944, + 0xf0945: 0xf0945, + 0xf0946: 0xf0946, + 0xf0947: 0xf0947, + 0xf0948: 0xf0948, + 0xf0949: 0xf0949, + 0xf094a: 0xf094a, + 0xf094b: 0xf094b, + 0xf094c: 0xf094c, + 0xf094d: 0xf094d, + 0xf094e: 0xf094e, + 0xf094f: 0xf094f, + 0xf0950: 0xf0950, + 0xf0951: 0xf0951, + 0xf0952: 0xf0952, + 0xf0953: 0xf0953, + 0xf0954: 0xf0954, + 0xf0955: 0xf0955, + 0xf0956: 0xf0956, + 0xf0957: 0xf0957, + 0xf0958: 0xf0958, + 0xf0959: 0xf0959, + 0xf095a: 0xf095a, + 0xf095b: 0xf095b, + 0xf095c: 0xf095c, + 0xf095d: 0xf095d, + 0xf095e: 0xf095e, + 0xf095f: 0xf095f, + 0xf0960: 0xf0960, + 0xf0961: 0xf0961, + 0xf0962: 0xf0962, + 0xf0963: 0xf0963, + 0xf0964: 0xf0964, + 0xf0965: 0xf0965, + 0xf0966: 0xf0966, + 0xf0967: 0xf0967, + 0xf0968: 0xf0968, + 0xf0969: 0xf0969, + 0xf096a: 0xf096a, + 0xf096b: 0xf096b, + 0xf096c: 0xf096c, + 0xf096d: 0xf096d, + 0xf096e: 0xf096e, + 0xf096f: 0xf096f, + 0xf0970: 0xf0970, + 0xf0971: 0xf0971, + 0xf0972: 0xf0972, + 0xf0973: 0xf0973, + 0xf0974: 0xf0974, + 0xf0975: 0xf0975, + 0xf0976: 0xf0976, + 0xf0977: 0xf0977, + 0xf0978: 0xf0978, + 0xf0979: 0xf0979, + 0xf097a: 0xf097a, + 0xf097b: 0xf097b, + 0xf097c: 0xf097c, + 0xf097d: 0xf097d, + 0xf097e: 0xf097e, + 0xf097f: 0xf097f, + 0xf0980: 0xf0980, + 0xf0981: 0xf0981, + 0xf0982: 0xf0982, + 0xf0983: 0xf0983, + 0xf0984: 0xf0984, + 0xf0985: 0xf0985, + 0xf0986: 0xf0986, + 0xf0987: 0xf0987, + 0xf0988: 0xf0988, + 0xf0989: 0xf0989, + 0xf098a: 0xf098a, + 0xf098b: 0xf098b, + 0xf098c: 0xf098c, + 0xf098d: 0xf098d, + 0xf098e: 0xf098e, + 0xf098f: 0xf098f, + 0xf0990: 0xf0990, + 0xf0991: 0xf0991, + 0xf0992: 0xf0992, + 0xf0993: 0xf0993, + 0xf0994: 0xf0994, + 0xf0995: 0xf0995, + 0xf0996: 0xf0996, + 0xf0997: 0xf0997, + 0xf0998: 0xf0998, + 0xf0999: 0xf0999, + 0xf099a: 0xf099a, + 0xf099b: 0xf099b, + 0xf099c: 0xf099c, + 0xf099d: 0xf099d, + 0xf099e: 0xf099e, + 0xf099f: 0xf099f, + 0xf09a0: 0xf09a0, + 0xf09a1: 0xf09a1, + 0xf09a2: 0xf09a2, + 0xf09a3: 0xf09a3, + 0xf09a4: 0xf09a4, + 0xf09a5: 0xf09a5, + 0xf09a6: 0xf09a6, + 0xf09a7: 0xf09a7, + 0xf09a8: 0xf09a8, + 0xf09a9: 0xf09a9, + 0xf09aa: 0xf09aa, + 0xf09ab: 0xf09ab, + 0xf09ac: 0xf09ac, + 0xf09ad: 0xf09ad, + 0xf09ae: 0xf09ae, + 0xf09af: 0xf09af, + 0xf09b0: 0xf09b0, + 0xf09b1: 0xf09b1, + 0xf09b2: 0xf09b2, + 0xf09b3: 0xf09b3, + 0xf09b4: 0xf09b4, + 0xf09b5: 0xf09b5, + 0xf09b6: 0xf09b6, + 0xf09b7: 0xf09b7, + 0xf09b8: 0xf09b8, + 0xf09b9: 0xf09b9, + 0xf09ba: 0xf09ba, + 0xf09bb: 0xf09bb, + 0xf09bc: 0xf09bc, + 0xf09bd: 0xf09bd, + 0xf09be: 0xf09be, + 0xf09bf: 0xf09bf, + 0xf09c0: 0xf09c0, + 0xf09c1: 0xf09c1, + 0xf09c2: 0xf09c2, + 0xf09c3: 0xf09c3, + 0xf09c4: 0xf09c4, + 0xf09c5: 0xf09c5, + 0xf09c6: 0xf09c6, + 0xf09c7: 0xf09c7, + 0xf09c8: 0xf09c8, + 0xf09c9: 0xf09c9, + 0xf09ca: 0xf09ca, + 0xf09cb: 0xf09cb, + 0xf09cc: 0xf09cc, + 0xf09cd: 0xf09cd, + 0xf09ce: 0xf09ce, + 0xf09cf: 0xf09cf, + 0xf09d0: 0xf09d0, + 0xf09d1: 0xf09d1, + 0xf09d2: 0xf09d2, + 0xf09d3: 0xf09d3, + 0xf09d4: 0xf09d4, + 0xf09d5: 0xf09d5, + 0xf09d6: 0xf09d6, + 0xf09d7: 0xf09d7, + 0xf09d8: 0xf09d8, + 0xf09d9: 0xf09d9, + 0xf09da: 0xf09da, + 0xf09db: 0xf09db, + 0xf09dc: 0xf09dc, + 0xf09dd: 0xf09dd, + 0xf09de: 0xf09de, + 0xf09df: 0xf09df, + 0xf09e0: 0xf09e0, + 0xf09e1: 0xf09e1, + 0xf09e2: 0xf09e2, + 0xf09e3: 0xf09e3, + 0xf09e4: 0xf09e4, + 0xf09e5: 0xf09e5, + 0xf09e6: 0xf09e6, + 0xf09e7: 0xf09e7, + 0xf09e8: 0xf09e8, + 0xf09e9: 0xf09e9, + 0xf09ea: 0xf09ea, + 0xf09eb: 0xf09eb, + 0xf09ec: 0xf09ec, + 0xf09ed: 0xf09ed, + 0xf09ee: 0xf09ee, + 0xf09ef: 0xf09ef, + 0xf09f0: 0xf09f0, + 0xf09f1: 0xf09f1, + 0xf09f2: 0xf09f2, + 0xf09f3: 0xf09f3, + 0xf09f4: 0xf09f4, + 0xf09f5: 0xf09f5, + 0xf09f6: 0xf09f6, + 0xf09f7: 0xf09f7, + 0xf09f8: 0xf09f8, + 0xf09f9: 0xf09f9, + 0xf09fa: 0xf09fa, + 0xf09fb: 0xf09fb, + 0xf09fc: 0xf09fc, + 0xf09fd: 0xf09fd, + 0xf09fe: 0xf09fe, + 0xf09ff: 0xf09ff, + 0xf0a00: 0xf0a00, + 0xf0a01: 0xf0a01, + 0xf0a02: 0xf0a02, + 0xf0a03: 0xf0a03, + 0xf0a04: 0xf0a04, + 0xf0a05: 0xf0a05, + 0xf0a06: 0xf0a06, + 0xf0a07: 0xf0a07, + 0xf0a08: 0xf0a08, + 0xf0a09: 0xf0a09, + 0xf0a0a: 0xf0a0a, + 0xf0a0b: 0xf0a0b, + 0xf0a0c: 0xf0a0c, + 0xf0a0d: 0xf0a0d, + 0xf0a0e: 0xf0a0e, + 0xf0a0f: 0xf0a0f, + 0xf0a10: 0xf0a10, + 0xf0a11: 0xf0a11, + 0xf0a12: 0xf0a12, + 0xf0a13: 0xf0a13, + 0xf0a14: 0xf0a14, + 0xf0a15: 0xf0a15, + 0xf0a16: 0xf0a16, + 0xf0a17: 0xf0a17, + 0xf0a18: 0xf0a18, + 0xf0a19: 0xf0a19, + 0xf0a1a: 0xf0a1a, + 0xf0a1b: 0xf0a1b, + 0xf0a1c: 0xf0a1c, + 0xf0a1d: 0xf0a1d, + 0xf0a1e: 0xf0a1e, + 0xf0a1f: 0xf0a1f, + 0xf0a20: 0xf0a20, + 0xf0a21: 0xf0a21, + 0xf0a22: 0xf0a22, + 0xf0a23: 0xf0a23, + 0xf0a24: 0xf0a24, + 0xf0a25: 0xf0a25, + 0xf0a26: 0xf0a26, + 0xf0a27: 0xf0a27, + 0xf0a28: 0xf0a28, + 0xf0a29: 0xf0a29, + 0xf0a2a: 0xf0a2a, + 0xf0a2b: 0xf0a2b, + 0xf0a2c: 0xf0a2c, + 0xf0a2d: 0xf0a2d, + 0xf0a2e: 0xf0a2e, + 0xf0a2f: 0xf0a2f, + 0xf0a30: 0xf0a30, + 0xf0a31: 0xf0a31, + 0xf0a32: 0xf0a32, + 0xf0a33: 0xf0a33, + 0xf0a34: 0xf0a34, + 0xf0a35: 0xf0a35, + 0xf0a36: 0xf0a36, + 0xf0a37: 0xf0a37, + 0xf0a38: 0xf0a38, + 0xf0a39: 0xf0a39, + 0xf0a3a: 0xf0a3a, + 0xf0a3b: 0xf0a3b, + 0xf0a3c: 0xf0a3c, + 0xf0a3d: 0xf0a3d, + 0xf0a3e: 0xf0a3e, + 0xf0a3f: 0xf0a3f, + 0xf0a40: 0xf0a40, + 0xf0a41: 0xf0a41, + 0xf0a42: 0xf0a42, + 0xf0a43: 0xf0a43, + 0xf0a44: 0xf0a44, + 0xf0a45: 0xf0a45, + 0xf0a46: 0xf0a46, + 0xf0a47: 0xf0a47, + 0xf0a48: 0xf0a48, + 0xf0a49: 0xf0a49, + 0xf0a4a: 0xf0a4a, + 0xf0a4b: 0xf0a4b, + 0xf0a4c: 0xf0a4c, + 0xf0a4d: 0xf0a4d, + 0xf0a4e: 0xf0a4e, + 0xf0a4f: 0xf0a4f, + 0xf0a50: 0xf0a50, + 0xf0a51: 0xf0a51, + 0xf0a52: 0xf0a52, + 0xf0a53: 0xf0a53, + 0xf0a54: 0xf0a54, + 0xf0a55: 0xf0a55, + 0xf0a56: 0xf0a56, + 0xf0a57: 0xf0a57, + 0xf0a58: 0xf0a58, + 0xf0a59: 0xf0a59, + 0xf0a5a: 0xf0a5a, + 0xf0a5b: 0xf0a5b, + 0xf0a5c: 0xf0a5c, + 0xf0a5d: 0xf0a5d, + 0xf0a5e: 0xf0a5e, + 0xf0a5f: 0xf0a5f, + 0xf0a60: 0xf0a60, + 0xf0a61: 0xf0a61, + 0xf0a62: 0xf0a62, + 0xf0a63: 0xf0a63, + 0xf0a64: 0xf0a64, + 0xf0a65: 0xf0a65, + 0xf0a66: 0xf0a66, + 0xf0a67: 0xf0a67, + 0xf0a68: 0xf0a68, + 0xf0a69: 0xf0a69, + 0xf0a6a: 0xf0a6a, + 0xf0a6b: 0xf0a6b, + 0xf0a6c: 0xf0a6c, + 0xf0a6d: 0xf0a6d, + 0xf0a6e: 0xf0a6e, + 0xf0a6f: 0xf0a6f, + 0xf0a70: 0xf0a70, + 0xf0a71: 0xf0a71, + 0xf0a72: 0xf0a72, + 0xf0a73: 0xf0a73, + 0xf0a74: 0xf0a74, + 0xf0a75: 0xf0a75, + 0xf0a76: 0xf0a76, + 0xf0a77: 0xf0a77, + 0xf0a78: 0xf0a78, + 0xf0a79: 0xf0a79, + 0xf0a7a: 0xf0a7a, + 0xf0a7b: 0xf0a7b, + 0xf0a7c: 0xf0a7c, + 0xf0a7d: 0xf0a7d, + 0xf0a7e: 0xf0a7e, + 0xf0a7f: 0xf0a7f, + 0xf0a80: 0xf0a80, + 0xf0a81: 0xf0a81, + 0xf0a82: 0xf0a82, + 0xf0a83: 0xf0a83, + 0xf0a84: 0xf0a84, + 0xf0a85: 0xf0a85, + 0xf0a86: 0xf0a86, + 0xf0a87: 0xf0a87, + 0xf0a88: 0xf0a88, + 0xf0a89: 0xf0a89, + 0xf0a8a: 0xf0a8a, + 0xf0a8b: 0xf0a8b, + 0xf0a8c: 0xf0a8c, + 0xf0a8d: 0xf0a8d, + 0xf0a8e: 0xf0a8e, + 0xf0a8f: 0xf0a8f, + 0xf0a90: 0xf0a90, + 0xf0a91: 0xf0a91, + 0xf0a92: 0xf0a92, + 0xf0a93: 0xf0a93, + 0xf0a94: 0xf0a94, + 0xf0a95: 0xf0a95, + 0xf0a96: 0xf0a96, + 0xf0a97: 0xf0a97, + 0xf0a98: 0xf0a98, + 0xf0a99: 0xf0a99, + 0xf0a9a: 0xf0a9a, + 0xf0a9b: 0xf0a9b, + 0xf0a9c: 0xf0a9c, + 0xf0a9d: 0xf0a9d, + 0xf0a9e: 0xf0a9e, + 0xf0a9f: 0xf0a9f, + 0xf0aa0: 0xf0aa0, + 0xf0aa1: 0xf0aa1, + 0xf0aa2: 0xf0aa2, + 0xf0aa3: 0xf0aa3, + 0xf0aa4: 0xf0aa4, + 0xf0aa5: 0xf0aa5, + 0xf0aa6: 0xf0aa6, + 0xf0aa7: 0xf0aa7, + 0xf0aa8: 0xf0aa8, + 0xf0aa9: 0xf0aa9, + 0xf0aaa: 0xf0aaa, + 0xf0aab: 0xf0aab, + 0xf0aac: 0xf0aac, + 0xf0aad: 0xf0aad, + 0xf0aae: 0xf0aae, + 0xf0aaf: 0xf0aaf, + 0xf0ab0: 0xf0ab0, + 0xf0ab1: 0xf0ab1, + 0xf0ab2: 0xf0ab2, + 0xf0ab3: 0xf0ab3, + 0xf0ab4: 0xf0ab4, + 0xf0ab5: 0xf0ab5, + 0xf0ab6: 0xf0ab6, + 0xf0ab7: 0xf0ab7, + 0xf0ab8: 0xf0ab8, + 0xf0ab9: 0xf0ab9, + 0xf0aba: 0xf0aba, + 0xf0abb: 0xf0abb, + 0xf0abc: 0xf0abc, + 0xf0abd: 0xf0abd, + 0xf0abe: 0xf0abe, + 0xf0abf: 0xf0abf, + 0xf0ac0: 0xf0ac0, + 0xf0ac1: 0xf0ac1, + 0xf0ac2: 0xf0ac2, + 0xf0ac3: 0xf0ac3, + 0xf0ac4: 0xf0ac4, + 0xf0ac5: 0xf0ac5, + 0xf0ac6: 0xf0ac6, + 0xf0ac7: 0xf0ac7, + 0xf0ac8: 0xf0ac8, + 0xf0ac9: 0xf0ac9, + 0xf0aca: 0xf0aca, + 0xf0acb: 0xf0acb, + 0xf0acc: 0xf0acc, + 0xf0acd: 0xf0acd, + 0xf0ace: 0xf0ace, + 0xf0acf: 0xf0acf, + 0xf0ad0: 0xf0ad0, + 0xf0ad1: 0xf0ad1, + 0xf0ad2: 0xf0ad2, + 0xf0ad3: 0xf0ad3, + 0xf0ad4: 0xf0ad4, + 0xf0ad5: 0xf0ad5, + 0xf0ad6: 0xf0ad6, + 0xf0ad7: 0xf0ad7, + 0xf0ad8: 0xf0ad8, + 0xf0ad9: 0xf0ad9, + 0xf0ada: 0xf0ada, + 0xf0adb: 0xf0adb, + 0xf0adc: 0xf0adc, + 0xf0add: 0xf0add, + 0xf0ade: 0xf0ade, + 0xf0adf: 0xf0adf, + 0xf0ae0: 0xf0ae0, + 0xf0ae1: 0xf0ae1, + 0xf0ae2: 0xf0ae2, + 0xf0ae3: 0xf0ae3, + 0xf0ae4: 0xf0ae4, + 0xf0ae5: 0xf0ae5, + 0xf0ae6: 0xf0ae6, + 0xf0ae7: 0xf0ae7, + 0xf0ae8: 0xf0ae8, + 0xf0ae9: 0xf0ae9, + 0xf0aea: 0xf0aea, + 0xf0aeb: 0xf0aeb, + 0xf0aec: 0xf0aec, + 0xf0aed: 0xf0aed, + 0xf0aee: 0xf0aee, + 0xf0aef: 0xf0aef, + 0xf0af0: 0xf0af0, + 0xf0af1: 0xf0af1, + 0xf0af2: 0xf0af2, + 0xf0af3: 0xf0af3, + 0xf0af4: 0xf0af4, + 0xf0af5: 0xf0af5, + 0xf0af6: 0xf0af6, + 0xf0af7: 0xf0af7, + 0xf0af8: 0xf0af8, + 0xf0af9: 0xf0af9, + 0xf0afa: 0xf0afa, + 0xf0afb: 0xf0afb, + 0xf0afc: 0xf0afc, + 0xf0afd: 0xf0afd, + 0xf0afe: 0xf0afe, + 0xf0aff: 0xf0aff, + 0xf0b00: 0xf0b00, + 0xf0b01: 0xf0b01, + 0xf0b02: 0xf0b02, + 0xf0b03: 0xf0b03, + 0xf0b04: 0xf0b04, + 0xf0b05: 0xf0b05, + 0xf0b06: 0xf0b06, + 0xf0b07: 0xf0b07, + 0xf0b08: 0xf0b08, + 0xf0b09: 0xf0b09, + 0xf0b0a: 0xf0b0a, + 0xf0b0b: 0xf0b0b, + 0xf0b0c: 0xf0b0c, + 0xf0b0d: 0xf0b0d, + 0xf0b0e: 0xf0b0e, + 0xf0b0f: 0xf0b0f, + 0xf0b10: 0xf0b10, + 0xf0b11: 0xf0b11, + 0xf0b12: 0xf0b12, + 0xf0b13: 0xf0b13, + 0xf0b14: 0xf0b14, + 0xf0b15: 0xf0b15, + 0xf0b16: 0xf0b16, + 0xf0b17: 0xf0b17, + 0xf0b18: 0xf0b18, + 0xf0b19: 0xf0b19, + 0xf0b1a: 0xf0b1a, + 0xf0b1b: 0xf0b1b, + 0xf0b1c: 0xf0b1c, + 0xf0b1d: 0xf0b1d, + 0xf0b1e: 0xf0b1e, + 0xf0b1f: 0xf0b1f, + 0xf0b20: 0xf0b20, + 0xf0b21: 0xf0b21, + 0xf0b22: 0xf0b22, + 0xf0b23: 0xf0b23, + 0xf0b24: 0xf0b24, + 0xf0b25: 0xf0b25, + 0xf0b26: 0xf0b26, + 0xf0b27: 0xf0b27, + 0xf0b28: 0xf0b28, + 0xf0b29: 0xf0b29, + 0xf0b2a: 0xf0b2a, + 0xf0b2b: 0xf0b2b, + 0xf0b2c: 0xf0b2c, + 0xf0b2d: 0xf0b2d, + 0xf0b2e: 0xf0b2e, + 0xf0b2f: 0xf0b2f, + 0xf0b30: 0xf0b30, + 0xf0b31: 0xf0b31, + 0xf0b32: 0xf0b32, + 0xf0b33: 0xf0b33, + 0xf0b34: 0xf0b34, + 0xf0b35: 0xf0b35, + 0xf0b36: 0xf0b36, + 0xf0b37: 0xf0b37, + 0xf0b38: 0xf0b38, + 0xf0b39: 0xf0b39, + 0xf0b3a: 0xf0b3a, + 0xf0b3b: 0xf0b3b, + 0xf0b3c: 0xf0b3c, + 0xf0b3d: 0xf0b3d, + 0xf0b3e: 0xf0b3e, + 0xf0b3f: 0xf0b3f, + 0xf0b40: 0xf0b40, + 0xf0b41: 0xf0b41, + 0xf0b42: 0xf0b42, + 0xf0b43: 0xf0b43, + 0xf0b44: 0xf0b44, + 0xf0b45: 0xf0b45, + 0xf0b46: 0xf0b46, + 0xf0b47: 0xf0b47, + 0xf0b48: 0xf0b48, + 0xf0b49: 0xf0b49, + 0xf0b4a: 0xf0b4a, + 0xf0b4b: 0xf0b4b, + 0xf0b4c: 0xf0b4c, + 0xf0b4d: 0xf0b4d, + 0xf0b4e: 0xf0b4e, + 0xf0b4f: 0xf0b4f, + 0xf0b50: 0xf0b50, + 0xf0b51: 0xf0b51, + 0xf0b52: 0xf0b52, + 0xf0b53: 0xf0b53, + 0xf0b54: 0xf0b54, + 0xf0b55: 0xf0b55, + 0xf0b56: 0xf0b56, + 0xf0b57: 0xf0b57, + 0xf0b58: 0xf0b58, + 0xf0b59: 0xf0b59, + 0xf0b5a: 0xf0b5a, + 0xf0b5b: 0xf0b5b, + 0xf0b5c: 0xf0b5c, + 0xf0b5d: 0xf0b5d, + 0xf0b5e: 0xf0b5e, + 0xf0b5f: 0xf0b5f, + 0xf0b60: 0xf0b60, + 0xf0b61: 0xf0b61, + 0xf0b62: 0xf0b62, + 0xf0b63: 0xf0b63, + 0xf0b64: 0xf0b64, + 0xf0b65: 0xf0b65, + 0xf0b66: 0xf0b66, + 0xf0b67: 0xf0b67, + 0xf0b68: 0xf0b68, + 0xf0b69: 0xf0b69, + 0xf0b6a: 0xf0b6a, + 0xf0b6b: 0xf0b6b, + 0xf0b6c: 0xf0b6c, + 0xf0b6d: 0xf0b6d, + 0xf0b6e: 0xf0b6e, + 0xf0b6f: 0xf0b6f, + 0xf0b70: 0xf0b70, + 0xf0b71: 0xf0b71, + 0xf0b72: 0xf0b72, + 0xf0b73: 0xf0b73, + 0xf0b74: 0xf0b74, + 0xf0b75: 0xf0b75, + 0xf0b76: 0xf0b76, + 0xf0b77: 0xf0b77, + 0xf0b78: 0xf0b78, + 0xf0b79: 0xf0b79, + 0xf0b7a: 0xf0b7a, + 0xf0b7b: 0xf0b7b, + 0xf0b7c: 0xf0b7c, + 0xf0b7d: 0xf0b7d, + 0xf0b7e: 0xf0b7e, + 0xf0b7f: 0xf0b7f, + 0xf0b80: 0xf0b80, + 0xf0b81: 0xf0b81, + 0xf0b82: 0xf0b82, + 0xf0b83: 0xf0b83, + 0xf0b84: 0xf0b84, + 0xf0b85: 0xf0b85, + 0xf0b86: 0xf0b86, + 0xf0b87: 0xf0b87, + 0xf0b88: 0xf0b88, + 0xf0b89: 0xf0b89, + 0xf0b8a: 0xf0b8a, + 0xf0b8b: 0xf0b8b, + 0xf0b8c: 0xf0b8c, + 0xf0b8d: 0xf0b8d, + 0xf0b8e: 0xf0b8e, + 0xf0b8f: 0xf0b8f, + 0xf0b90: 0xf0b90, + 0xf0b91: 0xf0b91, + 0xf0b92: 0xf0b92, + 0xf0b93: 0xf0b93, + 0xf0b94: 0xf0b94, + 0xf0b95: 0xf0b95, + 0xf0b96: 0xf0b96, + 0xf0b97: 0xf0b97, + 0xf0b98: 0xf0b98, + 0xf0b99: 0xf0b99, + 0xf0b9a: 0xf0b9a, + 0xf0b9b: 0xf0b9b, + 0xf0b9c: 0xf0b9c, + 0xf0b9d: 0xf0b9d, + 0xf0b9e: 0xf0b9e, + 0xf0b9f: 0xf0b9f, + 0xf0ba0: 0xf0ba0, + 0xf0ba1: 0xf0ba1, + 0xf0ba2: 0xf0ba2, + 0xf0ba3: 0xf0ba3, + 0xf0ba4: 0xf0ba4, + 0xf0ba5: 0xf0ba5, + 0xf0ba6: 0xf0ba6, + 0xf0ba7: 0xf0ba7, + 0xf0ba8: 0xf0ba8, + 0xf0ba9: 0xf0ba9, + 0xf0baa: 0xf0baa, + 0xf0bab: 0xf0bab, + 0xf0bac: 0xf0bac, + 0xf0bad: 0xf0bad, + 0xf0bae: 0xf0bae, + 0xf0baf: 0xf0baf, + 0xf0bb0: 0xf0bb0, + 0xf0bb1: 0xf0bb1, + 0xf0bb2: 0xf0bb2, + 0xf0bb3: 0xf0bb3, + 0xf0bb4: 0xf0bb4, + 0xf0bb5: 0xf0bb5, + 0xf0bb6: 0xf0bb6, + 0xf0bb7: 0xf0bb7, + 0xf0bb8: 0xf0bb8, + 0xf0bb9: 0xf0bb9, + 0xf0bba: 0xf0bba, + 0xf0bbb: 0xf0bbb, + 0xf0bbc: 0xf0bbc, + 0xf0bbd: 0xf0bbd, + 0xf0bbe: 0xf0bbe, + 0xf0bbf: 0xf0bbf, + 0xf0bc0: 0xf0bc0, + 0xf0bc1: 0xf0bc1, + 0xf0bc2: 0xf0bc2, + 0xf0bc3: 0xf0bc3, + 0xf0bc4: 0xf0bc4, + 0xf0bc5: 0xf0bc5, + 0xf0bc6: 0xf0bc6, + 0xf0bc7: 0xf0bc7, + 0xf0bc8: 0xf0bc8, + 0xf0bc9: 0xf0bc9, + 0xf0bca: 0xf0bca, + 0xf0bcb: 0xf0bcb, + 0xf0bcc: 0xf0bcc, + 0xf0bcd: 0xf0bcd, + 0xf0bce: 0xf0bce, + 0xf0bcf: 0xf0bcf, + 0xf0bd0: 0xf0bd0, + 0xf0bd1: 0xf0bd1, + 0xf0bd2: 0xf0bd2, + 0xf0bd3: 0xf0bd3, + 0xf0bd4: 0xf0bd4, + 0xf0bd5: 0xf0bd5, + 0xf0bd6: 0xf0bd6, + 0xf0bd7: 0xf0bd7, + 0xf0bd8: 0xf0bd8, + 0xf0bd9: 0xf0bd9, + 0xf0bda: 0xf0bda, + 0xf0bdb: 0xf0bdb, + 0xf0bdc: 0xf0bdc, + 0xf0bdd: 0xf0bdd, + 0xf0bde: 0xf0bde, + 0xf0bdf: 0xf0bdf, + 0xf0be0: 0xf0be0, + 0xf0be1: 0xf0be1, + 0xf0be2: 0xf0be2, + 0xf0be3: 0xf0be3, + 0xf0be4: 0xf0be4, + 0xf0be5: 0xf0be5, + 0xf0be6: 0xf0be6, + 0xf0be7: 0xf0be7, + 0xf0be8: 0xf0be8, + 0xf0be9: 0xf0be9, + 0xf0bea: 0xf0bea, + 0xf0beb: 0xf0beb, + 0xf0bec: 0xf0bec, + 0xf0bed: 0xf0bed, + 0xf0bee: 0xf0bee, + 0xf0bef: 0xf0bef, + 0xf0bf0: 0xf0bf0, + 0xf0bf1: 0xf0bf1, + 0xf0bf2: 0xf0bf2, + 0xf0bf3: 0xf0bf3, + 0xf0bf4: 0xf0bf4, + 0xf0bf5: 0xf0bf5, + 0xf0bf6: 0xf0bf6, + 0xf0bf7: 0xf0bf7, + 0xf0bf8: 0xf0bf8, + 0xf0bf9: 0xf0bf9, + 0xf0bfa: 0xf0bfa, + 0xf0bfb: 0xf0bfb, + 0xf0bfc: 0xf0bfc, + 0xf0bfd: 0xf0bfd, + 0xf0bfe: 0xf0bfe, + 0xf0bff: 0xf0bff, + 0xf0c00: 0xf0c00, + 0xf0c01: 0xf0c01, + 0xf0c02: 0xf0c02, + 0xf0c03: 0xf0c03, + 0xf0c04: 0xf0c04, + 0xf0c05: 0xf0c05, + 0xf0c06: 0xf0c06, + 0xf0c07: 0xf0c07, + 0xf0c08: 0xf0c08, + 0xf0c09: 0xf0c09, + 0xf0c0a: 0xf0c0a, + 0xf0c0b: 0xf0c0b, + 0xf0c0c: 0xf0c0c, + 0xf0c0d: 0xf0c0d, + 0xf0c0e: 0xf0c0e, + 0xf0c0f: 0xf0c0f, + 0xf0c10: 0xf0c10, + 0xf0c11: 0xf0c11, + 0xf0c12: 0xf0c12, + 0xf0c13: 0xf0c13, + 0xf0c14: 0xf0c14, + 0xf0c15: 0xf0c15, + 0xf0c16: 0xf0c16, + 0xf0c17: 0xf0c17, + 0xf0c18: 0xf0c18, + 0xf0c19: 0xf0c19, + 0xf0c1a: 0xf0c1a, + 0xf0c1b: 0xf0c1b, + 0xf0c1c: 0xf0c1c, + 0xf0c1d: 0xf0c1d, + 0xf0c1e: 0xf0c1e, + 0xf0c1f: 0xf0c1f, + 0xf0c20: 0xf0c20, + 0xf0c21: 0xf0c21, + 0xf0c22: 0xf0c22, + 0xf0c23: 0xf0c23, + 0xf0c24: 0xf0c24, + 0xf0c25: 0xf0c25, + 0xf0c26: 0xf0c26, + 0xf0c27: 0xf0c27, + 0xf0c28: 0xf0c28, + 0xf0c29: 0xf0c29, + 0xf0c2a: 0xf0c2a, + 0xf0c2b: 0xf0c2b, + 0xf0c2c: 0xf0c2c, + 0xf0c2d: 0xf0c2d, + 0xf0c2e: 0xf0c2e, + 0xf0c2f: 0xf0c2f, + 0xf0c30: 0xf0c30, + 0xf0c31: 0xf0c31, + 0xf0c32: 0xf0c32, + 0xf0c33: 0xf0c33, + 0xf0c34: 0xf0c34, + 0xf0c35: 0xf0c35, + 0xf0c36: 0xf0c36, + 0xf0c37: 0xf0c37, + 0xf0c38: 0xf0c38, + 0xf0c39: 0xf0c39, + 0xf0c3a: 0xf0c3a, + 0xf0c3b: 0xf0c3b, + 0xf0c3c: 0xf0c3c, + 0xf0c3d: 0xf0c3d, + 0xf0c3e: 0xf0c3e, + 0xf0c3f: 0xf0c3f, + 0xf0c40: 0xf0c40, + 0xf0c41: 0xf0c41, + 0xf0c42: 0xf0c42, + 0xf0c43: 0xf0c43, + 0xf0c44: 0xf0c44, + 0xf0c45: 0xf0c45, + 0xf0c46: 0xf0c46, + 0xf0c47: 0xf0c47, + 0xf0c48: 0xf0c48, + 0xf0c49: 0xf0c49, + 0xf0c4a: 0xf0c4a, + 0xf0c4b: 0xf0c4b, + 0xf0c4c: 0xf0c4c, + 0xf0c4d: 0xf0c4d, + 0xf0c4e: 0xf0c4e, + 0xf0c4f: 0xf0c4f, + 0xf0c50: 0xf0c50, + 0xf0c51: 0xf0c51, + 0xf0c52: 0xf0c52, + 0xf0c53: 0xf0c53, + 0xf0c54: 0xf0c54, + 0xf0c55: 0xf0c55, + 0xf0c56: 0xf0c56, + 0xf0c57: 0xf0c57, + 0xf0c58: 0xf0c58, + 0xf0c59: 0xf0c59, + 0xf0c5a: 0xf0c5a, + 0xf0c5b: 0xf0c5b, + 0xf0c5c: 0xf0c5c, + 0xf0c5d: 0xf0c5d, + 0xf0c5e: 0xf0c5e, + 0xf0c5f: 0xf0c5f, + 0xf0c60: 0xf0c60, + 0xf0c61: 0xf0c61, + 0xf0c62: 0xf0c62, + 0xf0c63: 0xf0c63, + 0xf0c64: 0xf0c64, + 0xf0c65: 0xf0c65, + 0xf0c66: 0xf0c66, + 0xf0c67: 0xf0c67, + 0xf0c68: 0xf0c68, + 0xf0c69: 0xf0c69, + 0xf0c6a: 0xf0c6a, + 0xf0c6b: 0xf0c6b, + 0xf0c6c: 0xf0c6c, + 0xf0c6d: 0xf0c6d, + 0xf0c6e: 0xf0c6e, + 0xf0c6f: 0xf0c6f, + 0xf0c70: 0xf0c70, + 0xf0c71: 0xf0c71, + 0xf0c72: 0xf0c72, + 0xf0c73: 0xf0c73, + 0xf0c74: 0xf0c74, + 0xf0c75: 0xf0c75, + 0xf0c76: 0xf0c76, + 0xf0c77: 0xf0c77, + 0xf0c78: 0xf0c78, + 0xf0c79: 0xf0c79, + 0xf0c7a: 0xf0c7a, + 0xf0c7b: 0xf0c7b, + 0xf0c7c: 0xf0c7c, + 0xf0c7d: 0xf0c7d, + 0xf0c7e: 0xf0c7e, + 0xf0c7f: 0xf0c7f, + 0xf0c80: 0xf0c80, + 0xf0c81: 0xf0c81, + 0xf0c82: 0xf0c82, + 0xf0c83: 0xf0c83, + 0xf0c84: 0xf0c84, + 0xf0c85: 0xf0c85, + 0xf0c86: 0xf0c86, + 0xf0c87: 0xf0c87, + 0xf0c88: 0xf0c88, + 0xf0c89: 0xf0c89, + 0xf0c8a: 0xf0c8a, + 0xf0c8b: 0xf0c8b, + 0xf0c8c: 0xf0c8c, + 0xf0c8d: 0xf0c8d, + 0xf0c8e: 0xf0c8e, + 0xf0c8f: 0xf0c8f, + 0xf0c90: 0xf0c90, + 0xf0c91: 0xf0c91, + 0xf0c92: 0xf0c92, + 0xf0c93: 0xf0c93, + 0xf0c94: 0xf0c94, + 0xf0c95: 0xf0c95, + 0xf0c96: 0xf0c96, + 0xf0c97: 0xf0c97, + 0xf0c98: 0xf0c98, + 0xf0c99: 0xf0c99, + 0xf0c9a: 0xf0c9a, + 0xf0c9b: 0xf0c9b, + 0xf0c9c: 0xf0c9c, + 0xf0c9d: 0xf0c9d, + 0xf0c9e: 0xf0c9e, + 0xf0c9f: 0xf0c9f, + 0xf0ca0: 0xf0ca0, + 0xf0ca1: 0xf0ca1, + 0xf0ca2: 0xf0ca2, + 0xf0ca3: 0xf0ca3, + 0xf0ca4: 0xf0ca4, + 0xf0ca5: 0xf0ca5, + 0xf0ca6: 0xf0ca6, + 0xf0ca7: 0xf0ca7, + 0xf0ca8: 0xf0ca8, + 0xf0ca9: 0xf0ca9, + 0xf0caa: 0xf0caa, + 0xf0cab: 0xf0cab, + 0xf0cac: 0xf0cac, + 0xf0cad: 0xf0cad, + 0xf0cae: 0xf0cae, + 0xf0caf: 0xf0caf, + 0xf0cb0: 0xf0cb0, + 0xf0cb1: 0xf0cb1, + 0xf0cb2: 0xf0cb2, + 0xf0cb3: 0xf0cb3, + 0xf0cb4: 0xf0cb4, + 0xf0cb5: 0xf0cb5, + 0xf0cb6: 0xf0cb6, + 0xf0cb7: 0xf0cb7, + 0xf0cb8: 0xf0cb8, + 0xf0cb9: 0xf0cb9, + 0xf0cba: 0xf0cba, + 0xf0cbb: 0xf0cbb, + 0xf0cbc: 0xf0cbc, + 0xf0cbd: 0xf0cbd, + 0xf0cbe: 0xf0cbe, + 0xf0cbf: 0xf0cbf, + 0xf0cc0: 0xf0cc0, + 0xf0cc1: 0xf0cc1, + 0xf0cc2: 0xf0cc2, + 0xf0cc3: 0xf0cc3, + 0xf0cc4: 0xf0cc4, + 0xf0cc5: 0xf0cc5, + 0xf0cc6: 0xf0cc6, + 0xf0cc7: 0xf0cc7, + 0xf0cc8: 0xf0cc8, + 0xf0cc9: 0xf0cc9, + 0xf0cca: 0xf0cca, + 0xf0ccb: 0xf0ccb, + 0xf0ccc: 0xf0ccc, + 0xf0ccd: 0xf0ccd, + 0xf0cce: 0xf0cce, + 0xf0ccf: 0xf0ccf, + 0xf0cd0: 0xf0cd0, + 0xf0cd1: 0xf0cd1, + 0xf0cd2: 0xf0cd2, + 0xf0cd3: 0xf0cd3, + 0xf0cd4: 0xf0cd4, + 0xf0cd5: 0xf0cd5, + 0xf0cd6: 0xf0cd6, + 0xf0cd7: 0xf0cd7, + 0xf0cd8: 0xf0cd8, + 0xf0cd9: 0xf0cd9, + 0xf0cda: 0xf0cda, + 0xf0cdb: 0xf0cdb, + 0xf0cdc: 0xf0cdc, + 0xf0cdd: 0xf0cdd, + 0xf0cde: 0xf0cde, + 0xf0cdf: 0xf0cdf, + 0xf0ce0: 0xf0ce0, + 0xf0ce1: 0xf0ce1, + 0xf0ce2: 0xf0ce2, + 0xf0ce3: 0xf0ce3, + 0xf0ce4: 0xf0ce4, + 0xf0ce5: 0xf0ce5, + 0xf0ce6: 0xf0ce6, + 0xf0ce7: 0xf0ce7, + 0xf0ce8: 0xf0ce8, + 0xf0ce9: 0xf0ce9, + 0xf0cea: 0xf0cea, + 0xf0ceb: 0xf0ceb, + 0xf0cec: 0xf0cec, + 0xf0ced: 0xf0ced, + 0xf0cee: 0xf0cee, + 0xf0cef: 0xf0cef, + 0xf0cf0: 0xf0cf0, + 0xf0cf1: 0xf0cf1, + 0xf0cf2: 0xf0cf2, + 0xf0cf3: 0xf0cf3, + 0xf0cf4: 0xf0cf4, + 0xf0cf5: 0xf0cf5, + 0xf0cf6: 0xf0cf6, + 0xf0cf7: 0xf0cf7, + 0xf0cf8: 0xf0cf8, + 0xf0cf9: 0xf0cf9, + 0xf0cfa: 0xf0cfa, + 0xf0cfb: 0xf0cfb, + 0xf0cfc: 0xf0cfc, + 0xf0cfd: 0xf0cfd, + 0xf0cfe: 0xf0cfe, + 0xf0cff: 0xf0cff, + 0xf0d00: 0xf0d00, + 0xf0d01: 0xf0d01, + 0xf0d02: 0xf0d02, + 0xf0d03: 0xf0d03, + 0xf0d04: 0xf0d04, + 0xf0d05: 0xf0d05, + 0xf0d06: 0xf0d06, + 0xf0d07: 0xf0d07, + 0xf0d08: 0xf0d08, + 0xf0d09: 0xf0d09, + 0xf0d0a: 0xf0d0a, + 0xf0d0b: 0xf0d0b, + 0xf0d0c: 0xf0d0c, + 0xf0d0d: 0xf0d0d, + 0xf0d0e: 0xf0d0e, + 0xf0d0f: 0xf0d0f, + 0xf0d10: 0xf0d10, + 0xf0d11: 0xf0d11, + 0xf0d12: 0xf0d12, + 0xf0d13: 0xf0d13, + 0xf0d14: 0xf0d14, + 0xf0d15: 0xf0d15, + 0xf0d16: 0xf0d16, + 0xf0d17: 0xf0d17, + 0xf0d18: 0xf0d18, + 0xf0d19: 0xf0d19, + 0xf0d1a: 0xf0d1a, + 0xf0d1b: 0xf0d1b, + 0xf0d1c: 0xf0d1c, + 0xf0d1d: 0xf0d1d, + 0xf0d1e: 0xf0d1e, + 0xf0d1f: 0xf0d1f, + 0xf0d20: 0xf0d20, + 0xf0d21: 0xf0d21, + 0xf0d22: 0xf0d22, + 0xf0d23: 0xf0d23, + 0xf0d24: 0xf0d24, + 0xf0d25: 0xf0d25, + 0xf0d26: 0xf0d26, + 0xf0d27: 0xf0d27, + 0xf0d28: 0xf0d28, + 0xf0d29: 0xf0d29, + 0xf0d2a: 0xf0d2a, + 0xf0d2b: 0xf0d2b, + 0xf0d2c: 0xf0d2c, + 0xf0d2d: 0xf0d2d, + 0xf0d2e: 0xf0d2e, + 0xf0d2f: 0xf0d2f, + 0xf0d30: 0xf0d30, + 0xf0d31: 0xf0d31, + 0xf0d32: 0xf0d32, + 0xf0d33: 0xf0d33, + 0xf0d34: 0xf0d34, + 0xf0d35: 0xf0d35, + 0xf0d36: 0xf0d36, + 0xf0d37: 0xf0d37, + 0xf0d38: 0xf0d38, + 0xf0d39: 0xf0d39, + 0xf0d3a: 0xf0d3a, + 0xf0d3b: 0xf0d3b, + 0xf0d3c: 0xf0d3c, + 0xf0d3d: 0xf0d3d, + 0xf0d3e: 0xf0d3e, + 0xf0d3f: 0xf0d3f, + 0xf0d40: 0xf0d40, + 0xf0d41: 0xf0d41, + 0xf0d42: 0xf0d42, + 0xf0d43: 0xf0d43, + 0xf0d44: 0xf0d44, + 0xf0d45: 0xf0d45, + 0xf0d46: 0xf0d46, + 0xf0d47: 0xf0d47, + 0xf0d48: 0xf0d48, + 0xf0d49: 0xf0d49, + 0xf0d4a: 0xf0d4a, + 0xf0d4b: 0xf0d4b, + 0xf0d4c: 0xf0d4c, + 0xf0d4d: 0xf0d4d, + 0xf0d4e: 0xf0d4e, + 0xf0d4f: 0xf0d4f, + 0xf0d50: 0xf0d50, + 0xf0d51: 0xf0d51, + 0xf0d52: 0xf0d52, + 0xf0d53: 0xf0d53, + 0xf0d54: 0xf0d54, + 0xf0d55: 0xf0d55, + 0xf0d56: 0xf0d56, + 0xf0d57: 0xf0d57, + 0xf0d58: 0xf0d58, + 0xf0d59: 0xf0d59, + 0xf0d5a: 0xf0d5a, + 0xf0d5b: 0xf0d5b, + 0xf0d5c: 0xf0d5c, + 0xf0d5d: 0xf0d5d, + 0xf0d5e: 0xf0d5e, + 0xf0d5f: 0xf0d5f, + 0xf0d60: 0xf0d60, + 0xf0d61: 0xf0d61, + 0xf0d62: 0xf0d62, + 0xf0d63: 0xf0d63, + 0xf0d64: 0xf0d64, + 0xf0d65: 0xf0d65, + 0xf0d66: 0xf0d66, + 0xf0d67: 0xf0d67, + 0xf0d68: 0xf0d68, + 0xf0d69: 0xf0d69, + 0xf0d6a: 0xf0d6a, + 0xf0d6b: 0xf0d6b, + 0xf0d6c: 0xf0d6c, + 0xf0d6d: 0xf0d6d, + 0xf0d6e: 0xf0d6e, + 0xf0d6f: 0xf0d6f, + 0xf0d70: 0xf0d70, + 0xf0d71: 0xf0d71, + 0xf0d72: 0xf0d72, + 0xf0d73: 0xf0d73, + 0xf0d74: 0xf0d74, + 0xf0d75: 0xf0d75, + 0xf0d76: 0xf0d76, + 0xf0d77: 0xf0d77, + 0xf0d78: 0xf0d78, + 0xf0d79: 0xf0d79, + 0xf0d7a: 0xf0d7a, + 0xf0d7b: 0xf0d7b, + 0xf0d7c: 0xf0d7c, + 0xf0d7d: 0xf0d7d, + 0xf0d7e: 0xf0d7e, + 0xf0d7f: 0xf0d7f, + 0xf0d80: 0xf0d80, + 0xf0d81: 0xf0d81, + 0xf0d82: 0xf0d82, + 0xf0d83: 0xf0d83, + 0xf0d84: 0xf0d84, + 0xf0d85: 0xf0d85, + 0xf0d86: 0xf0d86, + 0xf0d87: 0xf0d87, + 0xf0d88: 0xf0d88, + 0xf0d89: 0xf0d89, + 0xf0d8a: 0xf0d8a, + 0xf0d8b: 0xf0d8b, + 0xf0d8c: 0xf0d8c, + 0xf0d8d: 0xf0d8d, + 0xf0d8e: 0xf0d8e, + 0xf0d8f: 0xf0d8f, + 0xf0d90: 0xf0d90, + 0xf0d91: 0xf0d91, + 0xf0d92: 0xf0d92, + 0xf0d93: 0xf0d93, + 0xf0d94: 0xf0d94, + 0xf0d95: 0xf0d95, + 0xf0d96: 0xf0d96, + 0xf0d97: 0xf0d97, + 0xf0d98: 0xf0d98, + 0xf0d99: 0xf0d99, + 0xf0d9a: 0xf0d9a, + 0xf0d9b: 0xf0d9b, + 0xf0d9c: 0xf0d9c, + 0xf0d9d: 0xf0d9d, + 0xf0d9e: 0xf0d9e, + 0xf0d9f: 0xf0d9f, + 0xf0da0: 0xf0da0, + 0xf0da1: 0xf0da1, + 0xf0da2: 0xf0da2, + 0xf0da3: 0xf0da3, + 0xf0da4: 0xf0da4, + 0xf0da5: 0xf0da5, + 0xf0da6: 0xf0da6, + 0xf0da7: 0xf0da7, + 0xf0da8: 0xf0da8, + 0xf0da9: 0xf0da9, + 0xf0daa: 0xf0daa, + 0xf0dab: 0xf0dab, + 0xf0dac: 0xf0dac, + 0xf0dad: 0xf0dad, + 0xf0dae: 0xf0dae, + 0xf0daf: 0xf0daf, + 0xf0db0: 0xf0db0, + 0xf0db1: 0xf0db1, + 0xf0db2: 0xf0db2, + 0xf0db3: 0xf0db3, + 0xf0db4: 0xf0db4, + 0xf0db5: 0xf0db5, + 0xf0db6: 0xf0db6, + 0xf0db7: 0xf0db7, + 0xf0db8: 0xf0db8, + 0xf0db9: 0xf0db9, + 0xf0dba: 0xf0dba, + 0xf0dbb: 0xf0dbb, + 0xf0dbc: 0xf0dbc, + 0xf0dbd: 0xf0dbd, + 0xf0dbe: 0xf0dbe, + 0xf0dbf: 0xf0dbf, + 0xf0dc0: 0xf0dc0, + 0xf0dc1: 0xf0dc1, + 0xf0dc2: 0xf0dc2, + 0xf0dc3: 0xf0dc3, + 0xf0dc4: 0xf0dc4, + 0xf0dc5: 0xf0dc5, + 0xf0dc6: 0xf0dc6, + 0xf0dc7: 0xf0dc7, + 0xf0dc8: 0xf0dc8, + 0xf0dc9: 0xf0dc9, + 0xf0dca: 0xf0dca, + 0xf0dcb: 0xf0dcb, + 0xf0dcc: 0xf0dcc, + 0xf0dcd: 0xf0dcd, + 0xf0dce: 0xf0dce, + 0xf0dcf: 0xf0dcf, + 0xf0dd0: 0xf0dd0, + 0xf0dd1: 0xf0dd1, + 0xf0dd2: 0xf0dd2, + 0xf0dd3: 0xf0dd3, + 0xf0dd4: 0xf0dd4, + 0xf0dd5: 0xf0dd5, + 0xf0dd6: 0xf0dd6, + 0xf0dd7: 0xf0dd7, + 0xf0dd8: 0xf0dd8, + 0xf0dd9: 0xf0dd9, + 0xf0dda: 0xf0dda, + 0xf0ddb: 0xf0ddb, + 0xf0ddc: 0xf0ddc, + 0xf0ddd: 0xf0ddd, + 0xf0dde: 0xf0dde, + 0xf0ddf: 0xf0ddf, + 0xf0de0: 0xf0de0, + 0xf0de1: 0xf0de1, + 0xf0de2: 0xf0de2, + 0xf0de3: 0xf0de3, + 0xf0de4: 0xf0de4, + 0xf0de5: 0xf0de5, + 0xf0de6: 0xf0de6, + 0xf0de7: 0xf0de7, + 0xf0de8: 0xf0de8, + 0xf0de9: 0xf0de9, + 0xf0dea: 0xf0dea, + 0xf0deb: 0xf0deb, + 0xf0dec: 0xf0dec, + 0xf0ded: 0xf0ded, + 0xf0dee: 0xf0dee, + 0xf0def: 0xf0def, + 0xf0df0: 0xf0df0, + 0xf0df1: 0xf0df1, + 0xf0df2: 0xf0df2, + 0xf0df3: 0xf0df3, + 0xf0df4: 0xf0df4, + 0xf0df5: 0xf0df5, + 0xf0df6: 0xf0df6, + 0xf0df7: 0xf0df7, + 0xf0df8: 0xf0df8, + 0xf0df9: 0xf0df9, + 0xf0dfa: 0xf0dfa, + 0xf0dfb: 0xf0dfb, + 0xf0dfc: 0xf0dfc, + 0xf0dfd: 0xf0dfd, + 0xf0dfe: 0xf0dfe, + 0xf0dff: 0xf0dff, + 0xf0e00: 0xf0e00, + 0xf0e01: 0xf0e01, + 0xf0e02: 0xf0e02, + 0xf0e03: 0xf0e03, + 0xf0e04: 0xf0e04, + 0xf0e05: 0xf0e05, + 0xf0e06: 0xf0e06, + 0xf0e07: 0xf0e07, + 0xf0e08: 0xf0e08, + 0xf0e09: 0xf0e09, + 0xf0e0a: 0xf0e0a, + 0xf0e0b: 0xf0e0b, + 0xf0e0c: 0xf0e0c, + 0xf0e0d: 0xf0e0d, + 0xf0e0e: 0xf0e0e, + 0xf0e0f: 0xf0e0f, + 0xf0e10: 0xf0e10, + 0xf0e11: 0xf0e11, + 0xf0e12: 0xf0e12, + 0xf0e13: 0xf0e13, + 0xf0e14: 0xf0e14, + 0xf0e15: 0xf0e15, + 0xf0e16: 0xf0e16, + 0xf0e17: 0xf0e17, + 0xf0e18: 0xf0e18, + 0xf0e19: 0xf0e19, + 0xf0e1a: 0xf0e1a, + 0xf0e1b: 0xf0e1b, + 0xf0e1c: 0xf0e1c, + 0xf0e1d: 0xf0e1d, + 0xf0e1e: 0xf0e1e, + 0xf0e1f: 0xf0e1f, + 0xf0e20: 0xf0e20, + 0xf0e21: 0xf0e21, + 0xf0e22: 0xf0e22, + 0xf0e23: 0xf0e23, + 0xf0e24: 0xf0e24, + 0xf0e25: 0xf0e25, + 0xf0e26: 0xf0e26, + 0xf0e27: 0xf0e27, + 0xf0e28: 0xf0e28, + 0xf0e29: 0xf0e29, + 0xf0e2a: 0xf0e2a, + 0xf0e2b: 0xf0e2b, + 0xf0e2c: 0xf0e2c, + 0xf0e2d: 0xf0e2d, + 0xf0e2e: 0xf0e2e, + 0xf0e2f: 0xf0e2f, + 0xf0e30: 0xf0e30, + 0xf0e31: 0xf0e31, + 0xf0e32: 0xf0e32, + 0xf0e33: 0xf0e33, + 0xf0e34: 0xf0e34, + 0xf0e35: 0xf0e35, + 0xf0e36: 0xf0e36, + 0xf0e37: 0xf0e37, + 0xf0e38: 0xf0e38, + 0xf0e39: 0xf0e39, + 0xf0e3a: 0xf0e3a, + 0xf0e3b: 0xf0e3b, + 0xf0e3c: 0xf0e3c, + 0xf0e3d: 0xf0e3d, + 0xf0e3e: 0xf0e3e, + 0xf0e3f: 0xf0e3f, + 0xf0e40: 0xf0e40, + 0xf0e41: 0xf0e41, + 0xf0e42: 0xf0e42, + 0xf0e43: 0xf0e43, + 0xf0e44: 0xf0e44, + 0xf0e45: 0xf0e45, + 0xf0e46: 0xf0e46, + 0xf0e47: 0xf0e47, + 0xf0e48: 0xf0e48, + 0xf0e49: 0xf0e49, + 0xf0e4a: 0xf0e4a, + 0xf0e4b: 0xf0e4b, + 0xf0e4c: 0xf0e4c, + 0xf0e4d: 0xf0e4d, + 0xf0e4e: 0xf0e4e, + 0xf0e4f: 0xf0e4f, + 0xf0e50: 0xf0e50, + 0xf0e51: 0xf0e51, + 0xf0e52: 0xf0e52, + 0xf0e53: 0xf0e53, + 0xf0e54: 0xf0e54, + 0xf0e55: 0xf0e55, + 0xf0e56: 0xf0e56, + 0xf0e57: 0xf0e57, + 0xf0e58: 0xf0e58, + 0xf0e59: 0xf0e59, + 0xf0e5a: 0xf0e5a, + 0xf0e5b: 0xf0e5b, + 0xf0e5c: 0xf0e5c, + 0xf0e5d: 0xf0e5d, + 0xf0e5e: 0xf0e5e, + 0xf0e5f: 0xf0e5f, + 0xf0e60: 0xf0e60, + 0xf0e61: 0xf0e61, + 0xf0e62: 0xf0e62, + 0xf0e63: 0xf0e63, + 0xf0e64: 0xf0e64, + 0xf0e65: 0xf0e65, + 0xf0e66: 0xf0e66, + 0xf0e67: 0xf0e67, + 0xf0e68: 0xf0e68, + 0xf0e69: 0xf0e69, + 0xf0e6a: 0xf0e6a, + 0xf0e6b: 0xf0e6b, + 0xf0e6c: 0xf0e6c, + 0xf0e6d: 0xf0e6d, + 0xf0e6e: 0xf0e6e, + 0xf0e6f: 0xf0e6f, + 0xf0e70: 0xf0e70, + 0xf0e71: 0xf0e71, + 0xf0e72: 0xf0e72, + 0xf0e73: 0xf0e73, + 0xf0e74: 0xf0e74, + 0xf0e75: 0xf0e75, + 0xf0e76: 0xf0e76, + 0xf0e77: 0xf0e77, + 0xf0e78: 0xf0e78, + 0xf0e79: 0xf0e79, + 0xf0e7a: 0xf0e7a, + 0xf0e7b: 0xf0e7b, + 0xf0e7c: 0xf0e7c, + 0xf0e7d: 0xf0e7d, + 0xf0e7e: 0xf0e7e, + 0xf0e7f: 0xf0e7f, + 0xf0e80: 0xf0e80, + 0xf0e81: 0xf0e81, + 0xf0e82: 0xf0e82, + 0xf0e83: 0xf0e83, + 0xf0e84: 0xf0e84, + 0xf0e85: 0xf0e85, + 0xf0e86: 0xf0e86, + 0xf0e87: 0xf0e87, + 0xf0e88: 0xf0e88, + 0xf0e89: 0xf0e89, + 0xf0e8a: 0xf0e8a, + 0xf0e8b: 0xf0e8b, + 0xf0e8c: 0xf0e8c, + 0xf0e8d: 0xf0e8d, + 0xf0e8e: 0xf0e8e, + 0xf0e8f: 0xf0e8f, + 0xf0e90: 0xf0e90, + 0xf0e91: 0xf0e91, + 0xf0e92: 0xf0e92, + 0xf0e93: 0xf0e93, + 0xf0e94: 0xf0e94, + 0xf0e95: 0xf0e95, + 0xf0e96: 0xf0e96, + 0xf0e97: 0xf0e97, + 0xf0e98: 0xf0e98, + 0xf0e99: 0xf0e99, + 0xf0e9a: 0xf0e9a, + 0xf0e9b: 0xf0e9b, + 0xf0e9c: 0xf0e9c, + 0xf0e9d: 0xf0e9d, + 0xf0e9e: 0xf0e9e, + 0xf0e9f: 0xf0e9f, + 0xf0ea0: 0xf0ea0, + 0xf0ea1: 0xf0ea1, + 0xf0ea2: 0xf0ea2, + 0xf0ea3: 0xf0ea3, + 0xf0ea4: 0xf0ea4, + 0xf0ea5: 0xf0ea5, + 0xf0ea6: 0xf0ea6, + 0xf0ea7: 0xf0ea7, + 0xf0ea8: 0xf0ea8, + 0xf0ea9: 0xf0ea9, + 0xf0eaa: 0xf0eaa, + 0xf0eab: 0xf0eab, + 0xf0eac: 0xf0eac, + 0xf0ead: 0xf0ead, + 0xf0eae: 0xf0eae, + 0xf0eaf: 0xf0eaf, + 0xf0eb0: 0xf0eb0, + 0xf0eb1: 0xf0eb1, + 0xf0eb2: 0xf0eb2, + 0xf0eb3: 0xf0eb3, + 0xf0eb4: 0xf0eb4, + 0xf0eb5: 0xf0eb5, + 0xf0eb6: 0xf0eb6, + 0xf0eb7: 0xf0eb7, + 0xf0eb8: 0xf0eb8, + 0xf0eb9: 0xf0eb9, + 0xf0eba: 0xf0eba, + 0xf0ebb: 0xf0ebb, + 0xf0ebc: 0xf0ebc, + 0xf0ebd: 0xf0ebd, + 0xf0ebe: 0xf0ebe, + 0xf0ebf: 0xf0ebf, + 0xf0ec0: 0xf0ec0, + 0xf0ec1: 0xf0ec1, + 0xf0ec2: 0xf0ec2, + 0xf0ec3: 0xf0ec3, + 0xf0ec4: 0xf0ec4, + 0xf0ec5: 0xf0ec5, + 0xf0ec6: 0xf0ec6, + 0xf0ec7: 0xf0ec7, + 0xf0ec8: 0xf0ec8, + 0xf0ec9: 0xf0ec9, + 0xf0eca: 0xf0eca, + 0xf0ecb: 0xf0ecb, + 0xf0ecc: 0xf0ecc, + 0xf0ecd: 0xf0ecd, + 0xf0ece: 0xf0ece, + 0xf0ecf: 0xf0ecf, + 0xf0ed0: 0xf0ed0, + 0xf0ed1: 0xf0ed1, + 0xf0ed2: 0xf0ed2, + 0xf0ed3: 0xf0ed3, + 0xf0ed4: 0xf0ed4, + 0xf0ed5: 0xf0ed5, + 0xf0ed6: 0xf0ed6, + 0xf0ed7: 0xf0ed7, + 0xf0ed8: 0xf0ed8, + 0xf0ed9: 0xf0ed9, + 0xf0eda: 0xf0eda, + 0xf0edb: 0xf0edb, + 0xf0edc: 0xf0edc, + 0xf0edd: 0xf0edd, + 0xf0ede: 0xf0ede, + 0xf0edf: 0xf0edf, + 0xf0ee0: 0xf0ee0, + 0xf0ee1: 0xf0ee1, + 0xf0ee2: 0xf0ee2, + 0xf0ee3: 0xf0ee3, + 0xf0ee4: 0xf0ee4, + 0xf0ee5: 0xf0ee5, + 0xf0ee6: 0xf0ee6, + 0xf0ee7: 0xf0ee7, + 0xf0ee8: 0xf0ee8, + 0xf0ee9: 0xf0ee9, + 0xf0eea: 0xf0eea, + 0xf0eeb: 0xf0eeb, + 0xf0eec: 0xf0eec, + 0xf0eed: 0xf0eed, + 0xf0eee: 0xf0eee, + 0xf0eef: 0xf0eef, + 0xf0ef0: 0xf0ef0, + 0xf0ef1: 0xf0ef1, + 0xf0ef2: 0xf0ef2, + 0xf0ef3: 0xf0ef3, + 0xf0ef4: 0xf0ef4, + 0xf0ef5: 0xf0ef5, + 0xf0ef6: 0xf0ef6, + 0xf0ef7: 0xf0ef7, + 0xf0ef8: 0xf0ef8, + 0xf0ef9: 0xf0ef9, + 0xf0efa: 0xf0efa, + 0xf0efb: 0xf0efb, + 0xf0efc: 0xf0efc, + 0xf0efd: 0xf0efd, + 0xf0efe: 0xf0efe, + 0xf0eff: 0xf0eff, + 0xf0f00: 0xf0f00, + 0xf0f01: 0xf0f01, + 0xf0f02: 0xf0f02, + 0xf0f03: 0xf0f03, + 0xf0f04: 0xf0f04, + 0xf0f05: 0xf0f05, + 0xf0f06: 0xf0f06, + 0xf0f07: 0xf0f07, + 0xf0f08: 0xf0f08, + 0xf0f09: 0xf0f09, + 0xf0f0a: 0xf0f0a, + 0xf0f0b: 0xf0f0b, + 0xf0f0c: 0xf0f0c, + 0xf0f0d: 0xf0f0d, + 0xf0f0e: 0xf0f0e, + 0xf0f0f: 0xf0f0f, + 0xf0f10: 0xf0f10, + 0xf0f11: 0xf0f11, + 0xf0f12: 0xf0f12, + 0xf0f13: 0xf0f13, + 0xf0f14: 0xf0f14, + 0xf0f15: 0xf0f15, + 0xf0f16: 0xf0f16, + 0xf0f17: 0xf0f17, + 0xf0f18: 0xf0f18, + 0xf0f19: 0xf0f19, + 0xf0f1a: 0xf0f1a, + 0xf0f1b: 0xf0f1b, + 0xf0f1c: 0xf0f1c, + 0xf0f1d: 0xf0f1d, + 0xf0f1e: 0xf0f1e, + 0xf0f1f: 0xf0f1f, + 0xf0f20: 0xf0f20, + 0xf0f21: 0xf0f21, + 0xf0f22: 0xf0f22, + 0xf0f23: 0xf0f23, + 0xf0f24: 0xf0f24, + 0xf0f25: 0xf0f25, + 0xf0f26: 0xf0f26, + 0xf0f27: 0xf0f27, + 0xf0f28: 0xf0f28, + 0xf0f29: 0xf0f29, + 0xf0f2a: 0xf0f2a, + 0xf0f2b: 0xf0f2b, + 0xf0f2c: 0xf0f2c, + 0xf0f2d: 0xf0f2d, + 0xf0f2e: 0xf0f2e, + 0xf0f2f: 0xf0f2f, + 0xf0f30: 0xf0f30, + 0xf0f31: 0xf0f31, + 0xf0f32: 0xf0f32, + 0xf0f33: 0xf0f33, + 0xf0f34: 0xf0f34, + 0xf0f35: 0xf0f35, + 0xf0f36: 0xf0f36, + 0xf0f37: 0xf0f37, + 0xf0f38: 0xf0f38, + 0xf0f39: 0xf0f39, + 0xf0f3a: 0xf0f3a, + 0xf0f3b: 0xf0f3b, + 0xf0f3c: 0xf0f3c, + 0xf0f3d: 0xf0f3d, + 0xf0f3e: 0xf0f3e, + 0xf0f3f: 0xf0f3f, + 0xf0f40: 0xf0f40, + 0xf0f41: 0xf0f41, + 0xf0f42: 0xf0f42, + 0xf0f43: 0xf0f43, + 0xf0f44: 0xf0f44, + 0xf0f45: 0xf0f45, + 0xf0f46: 0xf0f46, + 0xf0f47: 0xf0f47, + 0xf0f48: 0xf0f48, + 0xf0f49: 0xf0f49, + 0xf0f4a: 0xf0f4a, + 0xf0f4b: 0xf0f4b, + 0xf0f4c: 0xf0f4c, + 0xf0f4d: 0xf0f4d, + 0xf0f4e: 0xf0f4e, + 0xf0f4f: 0xf0f4f, + 0xf0f50: 0xf0f50, + 0xf0f51: 0xf0f51, + 0xf0f52: 0xf0f52, + 0xf0f53: 0xf0f53, + 0xf0f54: 0xf0f54, + 0xf0f55: 0xf0f55, + 0xf0f56: 0xf0f56, + 0xf0f57: 0xf0f57, + 0xf0f58: 0xf0f58, + 0xf0f59: 0xf0f59, + 0xf0f5a: 0xf0f5a, + 0xf0f5b: 0xf0f5b, + 0xf0f5c: 0xf0f5c, + 0xf0f5d: 0xf0f5d, + 0xf0f5e: 0xf0f5e, + 0xf0f5f: 0xf0f5f, + 0xf0f60: 0xf0f60, + 0xf0f61: 0xf0f61, + 0xf0f62: 0xf0f62, + 0xf0f63: 0xf0f63, + 0xf0f64: 0xf0f64, + 0xf0f65: 0xf0f65, + 0xf0f66: 0xf0f66, + 0xf0f67: 0xf0f67, + 0xf0f68: 0xf0f68, + 0xf0f69: 0xf0f69, + 0xf0f6a: 0xf0f6a, + 0xf0f6b: 0xf0f6b, + 0xf0f6c: 0xf0f6c, + 0xf0f6d: 0xf0f6d, + 0xf0f6e: 0xf0f6e, + 0xf0f6f: 0xf0f6f, + 0xf0f70: 0xf0f70, + 0xf0f71: 0xf0f71, + 0xf0f72: 0xf0f72, + 0xf0f73: 0xf0f73, + 0xf0f74: 0xf0f74, + 0xf0f75: 0xf0f75, + 0xf0f76: 0xf0f76, + 0xf0f77: 0xf0f77, + 0xf0f78: 0xf0f78, + 0xf0f79: 0xf0f79, + 0xf0f7a: 0xf0f7a, + 0xf0f7b: 0xf0f7b, + 0xf0f7c: 0xf0f7c, + 0xf0f7d: 0xf0f7d, + 0xf0f7e: 0xf0f7e, + 0xf0f7f: 0xf0f7f, + 0xf0f80: 0xf0f80, + 0xf0f81: 0xf0f81, + 0xf0f82: 0xf0f82, + 0xf0f83: 0xf0f83, + 0xf0f84: 0xf0f84, + 0xf0f85: 0xf0f85, + 0xf0f86: 0xf0f86, + 0xf0f87: 0xf0f87, + 0xf0f88: 0xf0f88, + 0xf0f89: 0xf0f89, + 0xf0f8a: 0xf0f8a, + 0xf0f8b: 0xf0f8b, + 0xf0f8c: 0xf0f8c, + 0xf0f8d: 0xf0f8d, + 0xf0f8e: 0xf0f8e, + 0xf0f8f: 0xf0f8f, + 0xf0f90: 0xf0f90, + 0xf0f91: 0xf0f91, + 0xf0f92: 0xf0f92, + 0xf0f93: 0xf0f93, + 0xf0f94: 0xf0f94, + 0xf0f95: 0xf0f95, + 0xf0f96: 0xf0f96, + 0xf0f97: 0xf0f97, + 0xf0f98: 0xf0f98, + 0xf0f99: 0xf0f99, + 0xf0f9a: 0xf0f9a, + 0xf0f9b: 0xf0f9b, + 0xf0f9c: 0xf0f9c, + 0xf0f9d: 0xf0f9d, + 0xf0f9e: 0xf0f9e, + 0xf0f9f: 0xf0f9f, + 0xf0fa0: 0xf0fa0, + 0xf0fa1: 0xf0fa1, + 0xf0fa2: 0xf0fa2, + 0xf0fa3: 0xf0fa3, + 0xf0fa4: 0xf0fa4, + 0xf0fa5: 0xf0fa5, + 0xf0fa6: 0xf0fa6, + 0xf0fa7: 0xf0fa7, + 0xf0fa8: 0xf0fa8, + 0xf0fa9: 0xf0fa9, + 0xf0faa: 0xf0faa, + 0xf0fab: 0xf0fab, + 0xf0fac: 0xf0fac, + 0xf0fad: 0xf0fad, + 0xf0fae: 0xf0fae, + 0xf0faf: 0xf0faf, + 0xf0fb0: 0xf0fb0, + 0xf0fb1: 0xf0fb1, + 0xf0fb2: 0xf0fb2, + 0xf0fb3: 0xf0fb3, + 0xf0fb4: 0xf0fb4, + 0xf0fb5: 0xf0fb5, + 0xf0fb6: 0xf0fb6, + 0xf0fb7: 0xf0fb7, + 0xf0fb8: 0xf0fb8, + 0xf0fb9: 0xf0fb9, + 0xf0fba: 0xf0fba, + 0xf0fbb: 0xf0fbb, + 0xf0fbc: 0xf0fbc, + 0xf0fbd: 0xf0fbd, + 0xf0fbe: 0xf0fbe, + 0xf0fbf: 0xf0fbf, + 0xf0fc0: 0xf0fc0, + 0xf0fc1: 0xf0fc1, + 0xf0fc2: 0xf0fc2, + 0xf0fc3: 0xf0fc3, + 0xf0fc4: 0xf0fc4, + 0xf0fc5: 0xf0fc5, + 0xf0fc6: 0xf0fc6, + 0xf0fc7: 0xf0fc7, + 0xf0fc8: 0xf0fc8, + 0xf0fc9: 0xf0fc9, + 0xf0fca: 0xf0fca, + 0xf0fcb: 0xf0fcb, + 0xf0fcc: 0xf0fcc, + 0xf0fcd: 0xf0fcd, + 0xf0fce: 0xf0fce, + 0xf0fcf: 0xf0fcf, + 0xf0fd0: 0xf0fd0, + 0xf0fd1: 0xf0fd1, + 0xf0fd2: 0xf0fd2, + 0xf0fd3: 0xf0fd3, + 0xf0fd4: 0xf0fd4, + 0xf0fd5: 0xf0fd5, + 0xf0fd6: 0xf0fd6, + 0xf0fd7: 0xf0fd7, + 0xf0fd8: 0xf0fd8, + 0xf0fd9: 0xf0fd9, + 0xf0fda: 0xf0fda, + 0xf0fdb: 0xf0fdb, + 0xf0fdc: 0xf0fdc, + 0xf0fdd: 0xf0fdd, + 0xf0fde: 0xf0fde, + 0xf0fdf: 0xf0fdf, + 0xf0fe0: 0xf0fe0, + 0xf0fe1: 0xf0fe1, + 0xf0fe2: 0xf0fe2, + 0xf0fe3: 0xf0fe3, + 0xf0fe4: 0xf0fe4, + 0xf0fe5: 0xf0fe5, + 0xf0fe6: 0xf0fe6, + 0xf0fe7: 0xf0fe7, + 0xf0fe8: 0xf0fe8, + 0xf0fe9: 0xf0fe9, + 0xf0fea: 0xf0fea, + 0xf0feb: 0xf0feb, + 0xf0fec: 0xf0fec, + 0xf0fed: 0xf0fed, + 0xf0fee: 0xf0fee, + 0xf0fef: 0xf0fef, + 0xf0ff0: 0xf0ff0, + 0xf0ff1: 0xf0ff1, + 0xf0ff2: 0xf0ff2, + 0xf0ff3: 0xf0ff3, + 0xf0ff4: 0xf0ff4, + 0xf0ff5: 0xf0ff5, + 0xf0ff6: 0xf0ff6, + 0xf0ff7: 0xf0ff7, + 0xf0ff8: 0xf0ff8, + 0xf0ff9: 0xf0ff9, + 0xf0ffa: 0xf0ffa, + 0xf0ffb: 0xf0ffb, + 0xf0ffc: 0xf0ffc, + 0xf0ffd: 0xf0ffd, + 0xf0ffe: 0xf0ffe, + 0xf0fff: 0xf0fff, + 0xf1000: 0xf1000, + 0xf1001: 0xf1001, + 0xf1002: 0xf1002, + 0xf1003: 0xf1003, + 0xf1004: 0xf1004, + 0xf1005: 0xf1005, + 0xf1006: 0xf1006, + 0xf1007: 0xf1007, + 0xf1008: 0xf1008, + 0xf1009: 0xf1009, + 0xf100a: 0xf100a, + 0xf100b: 0xf100b, + 0xf100c: 0xf100c, + 0xf100d: 0xf100d, + 0xf100e: 0xf100e, + 0xf100f: 0xf100f, + 0xf1010: 0xf1010, + 0xf1011: 0xf1011, + 0xf1012: 0xf1012, + 0xf1013: 0xf1013, + 0xf1014: 0xf1014, + 0xf1015: 0xf1015, + 0xf1016: 0xf1016, + 0xf1017: 0xf1017, + 0xf1018: 0xf1018, + 0xf1019: 0xf1019, + 0xf101a: 0xf101a, + 0xf101b: 0xf101b, + 0xf101c: 0xf101c, + 0xf101d: 0xf101d, + 0xf101e: 0xf101e, + 0xf101f: 0xf101f, + 0xf1020: 0xf1020, + 0xf1021: 0xf1021, + 0xf1022: 0xf1022, + 0xf1023: 0xf1023, + 0xf1024: 0xf1024, + 0xf1025: 0xf1025, + 0xf1026: 0xf1026, + 0xf1027: 0xf1027, + 0xf1028: 0xf1028, + 0xf1029: 0xf1029, + 0xf102a: 0xf102a, + 0xf102b: 0xf102b, + 0xf102c: 0xf102c, + 0xf102d: 0xf102d, + 0xf102e: 0xf102e, + 0xf102f: 0xf102f, + 0xf1030: 0xf1030, + 0xf1031: 0xf1031, + 0xf1032: 0xf1032, + 0xf1033: 0xf1033, + 0xf1034: 0xf1034, + 0xf1035: 0xf1035, + 0xf1036: 0xf1036, + 0xf1037: 0xf1037, + 0xf1038: 0xf1038, + 0xf1039: 0xf1039, + 0xf103a: 0xf103a, + 0xf103b: 0xf103b, + 0xf103c: 0xf103c, + 0xf103d: 0xf103d, + 0xf103e: 0xf103e, + 0xf103f: 0xf103f, + 0xf1040: 0xf1040, + 0xf1041: 0xf1041, + 0xf1042: 0xf1042, + 0xf1043: 0xf1043, + 0xf1044: 0xf1044, + 0xf1045: 0xf1045, + 0xf1046: 0xf1046, + 0xf1047: 0xf1047, + 0xf1048: 0xf1048, + 0xf1049: 0xf1049, + 0xf104a: 0xf104a, + 0xf104b: 0xf104b, + 0xf104c: 0xf104c, + 0xf104d: 0xf104d, + 0xf104e: 0xf104e, + 0xf104f: 0xf104f, + 0xf1050: 0xf1050, + 0xf1051: 0xf1051, + 0xf1052: 0xf1052, + 0xf1053: 0xf1053, + 0xf1054: 0xf1054, + 0xf1055: 0xf1055, + 0xf1056: 0xf1056, + 0xf1057: 0xf1057, + 0xf1058: 0xf1058, + 0xf1059: 0xf1059, + 0xf105a: 0xf105a, + 0xf105b: 0xf105b, + 0xf105c: 0xf105c, + 0xf105d: 0xf105d, + 0xf105e: 0xf105e, + 0xf105f: 0xf105f, + 0xf1060: 0xf1060, + 0xf1061: 0xf1061, + 0xf1062: 0xf1062, + 0xf1063: 0xf1063, + 0xf1064: 0xf1064, + 0xf1065: 0xf1065, + 0xf1066: 0xf1066, + 0xf1067: 0xf1067, + 0xf1068: 0xf1068, + 0xf1069: 0xf1069, + 0xf106a: 0xf106a, + 0xf106b: 0xf106b, + 0xf106c: 0xf106c, + 0xf106d: 0xf106d, + 0xf106e: 0xf106e, + 0xf106f: 0xf106f, + 0xf1070: 0xf1070, + 0xf1071: 0xf1071, + 0xf1072: 0xf1072, + 0xf1073: 0xf1073, + 0xf1074: 0xf1074, + 0xf1075: 0xf1075, + 0xf1076: 0xf1076, + 0xf1077: 0xf1077, + 0xf1078: 0xf1078, + 0xf1079: 0xf1079, + 0xf107a: 0xf107a, + 0xf107b: 0xf107b, + 0xf107c: 0xf107c, + 0xf107d: 0xf107d, + 0xf107e: 0xf107e, + 0xf107f: 0xf107f, + 0xf1080: 0xf1080, + 0xf1081: 0xf1081, + 0xf1082: 0xf1082, + 0xf1083: 0xf1083, + 0xf1084: 0xf1084, + 0xf1085: 0xf1085, + 0xf1086: 0xf1086, + 0xf1087: 0xf1087, + 0xf1088: 0xf1088, + 0xf1089: 0xf1089, + 0xf108a: 0xf108a, + 0xf108b: 0xf108b, + 0xf108c: 0xf108c, + 0xf108d: 0xf108d, + 0xf108e: 0xf108e, + 0xf108f: 0xf108f, + 0xf1090: 0xf1090, + 0xf1091: 0xf1091, + 0xf1092: 0xf1092, + 0xf1093: 0xf1093, + 0xf1094: 0xf1094, + 0xf1095: 0xf1095, + 0xf1096: 0xf1096, + 0xf1097: 0xf1097, + 0xf1098: 0xf1098, + 0xf1099: 0xf1099, + 0xf109a: 0xf109a, + 0xf109b: 0xf109b, + 0xf109c: 0xf109c, + 0xf109d: 0xf109d, + 0xf109e: 0xf109e, + 0xf109f: 0xf109f, + 0xf10a0: 0xf10a0, + 0xf10a1: 0xf10a1, + 0xf10a2: 0xf10a2, + 0xf10a3: 0xf10a3, + 0xf10a4: 0xf10a4, + 0xf10a5: 0xf10a5, + 0xf10a6: 0xf10a6, + 0xf10a7: 0xf10a7, + 0xf10a8: 0xf10a8, + 0xf10a9: 0xf10a9, + 0xf10aa: 0xf10aa, + 0xf10ab: 0xf10ab, + 0xf10ac: 0xf10ac, + 0xf10ad: 0xf10ad, + 0xf10ae: 0xf10ae, + 0xf10af: 0xf10af, + 0xf10b0: 0xf10b0, + 0xf10b1: 0xf10b1, + 0xf10b2: 0xf10b2, + 0xf10b3: 0xf10b3, + 0xf10b4: 0xf10b4, + 0xf10b5: 0xf10b5, + 0xf10b6: 0xf10b6, + 0xf10b7: 0xf10b7, + 0xf10b8: 0xf10b8, + 0xf10b9: 0xf10b9, + 0xf10ba: 0xf10ba, + 0xf10bb: 0xf10bb, + 0xf10bc: 0xf10bc, + 0xf10bd: 0xf10bd, + 0xf10be: 0xf10be, + 0xf10bf: 0xf10bf, + 0xf10c0: 0xf10c0, + 0xf10c1: 0xf10c1, + 0xf10c2: 0xf10c2, + 0xf10c3: 0xf10c3, + 0xf10c4: 0xf10c4, + 0xf10c5: 0xf10c5, + 0xf10c6: 0xf10c6, + 0xf10c7: 0xf10c7, + 0xf10c8: 0xf10c8, + 0xf10c9: 0xf10c9, + 0xf10ca: 0xf10ca, + 0xf10cb: 0xf10cb, + 0xf10cc: 0xf10cc, + 0xf10cd: 0xf10cd, + 0xf10ce: 0xf10ce, + 0xf10cf: 0xf10cf, + 0xf10d0: 0xf10d0, + 0xf10d1: 0xf10d1, + 0xf10d2: 0xf10d2, + 0xf10d3: 0xf10d3, + 0xf10d4: 0xf10d4, + 0xf10d5: 0xf10d5, + 0xf10d6: 0xf10d6, + 0xf10d7: 0xf10d7, + 0xf10d8: 0xf10d8, + 0xf10d9: 0xf10d9, + 0xf10da: 0xf10da, + 0xf10db: 0xf10db, + 0xf10dc: 0xf10dc, + 0xf10dd: 0xf10dd, + 0xf10de: 0xf10de, + 0xf10df: 0xf10df, + 0xf10e0: 0xf10e0, + 0xf10e1: 0xf10e1, + 0xf10e2: 0xf10e2, + 0xf10e3: 0xf10e3, + 0xf10e4: 0xf10e4, + 0xf10e5: 0xf10e5, + 0xf10e6: 0xf10e6, + 0xf10e7: 0xf10e7, + 0xf10e8: 0xf10e8, + 0xf10e9: 0xf10e9, + 0xf10ea: 0xf10ea, + 0xf10eb: 0xf10eb, + 0xf10ec: 0xf10ec, + 0xf10ed: 0xf10ed, + 0xf10ee: 0xf10ee, + 0xf10ef: 0xf10ef, + 0xf10f0: 0xf10f0, + 0xf10f1: 0xf10f1, + 0xf10f2: 0xf10f2, + 0xf10f3: 0xf10f3, + 0xf10f4: 0xf10f4, + 0xf10f5: 0xf10f5, + 0xf10f6: 0xf10f6, + 0xf10f7: 0xf10f7, + 0xf10f8: 0xf10f8, + 0xf10f9: 0xf10f9, + 0xf10fa: 0xf10fa, + 0xf10fb: 0xf10fb, + 0xf10fc: 0xf10fc, + 0xf10fd: 0xf10fd, + 0xf10fe: 0xf10fe, + 0xf10ff: 0xf10ff, + 0xf1100: 0xf1100, + 0xf1101: 0xf1101, + 0xf1102: 0xf1102, + 0xf1103: 0xf1103, + 0xf1104: 0xf1104, + 0xf1105: 0xf1105, + 0xf1106: 0xf1106, + 0xf1107: 0xf1107, + 0xf1108: 0xf1108, + 0xf1109: 0xf1109, + 0xf110a: 0xf110a, + 0xf110b: 0xf110b, + 0xf110c: 0xf110c, + 0xf110d: 0xf110d, + 0xf110e: 0xf110e, + 0xf110f: 0xf110f, + 0xf1110: 0xf1110, + 0xf1111: 0xf1111, + 0xf1112: 0xf1112, + 0xf1113: 0xf1113, + 0xf1114: 0xf1114, + 0xf1115: 0xf1115, + 0xf1116: 0xf1116, + 0xf1117: 0xf1117, + 0xf1118: 0xf1118, + 0xf1119: 0xf1119, + 0xf111a: 0xf111a, + 0xf111b: 0xf111b, + 0xf111c: 0xf111c, + 0xf111d: 0xf111d, + 0xf111e: 0xf111e, + 0xf111f: 0xf111f, + 0xf1120: 0xf1120, + 0xf1121: 0xf1121, + 0xf1122: 0xf1122, + 0xf1123: 0xf1123, + 0xf1124: 0xf1124, + 0xf1125: 0xf1125, + 0xf1126: 0xf1126, + 0xf1127: 0xf1127, + 0xf1128: 0xf1128, + 0xf1129: 0xf1129, + 0xf112a: 0xf112a, + 0xf112b: 0xf112b, + 0xf112c: 0xf112c, + 0xf112d: 0xf112d, + 0xf112e: 0xf112e, + 0xf112f: 0xf112f, + 0xf1130: 0xf1130, + 0xf1131: 0xf1131, + 0xf1132: 0xf1132, + 0xf1133: 0xf1133, + 0xf1134: 0xf1134, + 0xf1135: 0xf1135, + 0xf1136: 0xf1136, + 0xf1137: 0xf1137, + 0xf1138: 0xf1138, + 0xf1139: 0xf1139, + 0xf113a: 0xf113a, + 0xf113b: 0xf113b, + 0xf113c: 0xf113c, + 0xf113d: 0xf113d, + 0xf113e: 0xf113e, + 0xf113f: 0xf113f, + 0xf1140: 0xf1140, + 0xf1141: 0xf1141, + 0xf1142: 0xf1142, + 0xf1143: 0xf1143, + 0xf1144: 0xf1144, + 0xf1145: 0xf1145, + 0xf1146: 0xf1146, + 0xf1147: 0xf1147, + 0xf1148: 0xf1148, + 0xf1149: 0xf1149, + 0xf114a: 0xf114a, + 0xf114b: 0xf114b, + 0xf114c: 0xf114c, + 0xf114d: 0xf114d, + 0xf114e: 0xf114e, + 0xf114f: 0xf114f, + 0xf1150: 0xf1150, + 0xf1151: 0xf1151, + 0xf1152: 0xf1152, + 0xf1153: 0xf1153, + 0xf1154: 0xf1154, + 0xf1155: 0xf1155, + 0xf1156: 0xf1156, + 0xf1157: 0xf1157, + 0xf1158: 0xf1158, + 0xf1159: 0xf1159, + 0xf115a: 0xf115a, + 0xf115b: 0xf115b, + 0xf115c: 0xf115c, + 0xf115d: 0xf115d, + 0xf115e: 0xf115e, + 0xf115f: 0xf115f, + 0xf1160: 0xf1160, + 0xf1161: 0xf1161, + 0xf1162: 0xf1162, + 0xf1163: 0xf1163, + 0xf1164: 0xf1164, + 0xf1165: 0xf1165, + 0xf1166: 0xf1166, + 0xf1167: 0xf1167, + 0xf1168: 0xf1168, + 0xf1169: 0xf1169, + 0xf116a: 0xf116a, + 0xf116b: 0xf116b, + 0xf116c: 0xf116c, + 0xf116d: 0xf116d, + 0xf116e: 0xf116e, + 0xf116f: 0xf116f, + 0xf1170: 0xf1170, + 0xf1171: 0xf1171, + 0xf1172: 0xf1172, + 0xf1173: 0xf1173, + 0xf1174: 0xf1174, + 0xf1175: 0xf1175, + 0xf1176: 0xf1176, + 0xf1177: 0xf1177, + 0xf1178: 0xf1178, + 0xf1179: 0xf1179, + 0xf117a: 0xf117a, + 0xf117b: 0xf117b, + 0xf117c: 0xf117c, + 0xf117d: 0xf117d, + 0xf117e: 0xf117e, + 0xf117f: 0xf117f, + 0xf1180: 0xf1180, + 0xf1181: 0xf1181, + 0xf1182: 0xf1182, + 0xf1183: 0xf1183, + 0xf1184: 0xf1184, + 0xf1185: 0xf1185, + 0xf1186: 0xf1186, + 0xf1187: 0xf1187, + 0xf1188: 0xf1188, + 0xf1189: 0xf1189, + 0xf118a: 0xf118a, + 0xf118b: 0xf118b, + 0xf118c: 0xf118c, + 0xf118d: 0xf118d, + 0xf118e: 0xf118e, + 0xf118f: 0xf118f, + 0xf1190: 0xf1190, + 0xf1191: 0xf1191, + 0xf1192: 0xf1192, + 0xf1193: 0xf1193, + 0xf1194: 0xf1194, + 0xf1195: 0xf1195, + 0xf1196: 0xf1196, + 0xf1197: 0xf1197, + 0xf1198: 0xf1198, + 0xf1199: 0xf1199, + 0xf119a: 0xf119a, + 0xf119b: 0xf119b, + 0xf119c: 0xf119c, + 0xf119d: 0xf119d, + 0xf119e: 0xf119e, + 0xf119f: 0xf119f, + 0xf11a0: 0xf11a0, + 0xf11a1: 0xf11a1, + 0xf11a2: 0xf11a2, + 0xf11a3: 0xf11a3, + 0xf11a4: 0xf11a4, + 0xf11a5: 0xf11a5, + 0xf11a6: 0xf11a6, + 0xf11a7: 0xf11a7, + 0xf11a8: 0xf11a8, + 0xf11a9: 0xf11a9, + 0xf11aa: 0xf11aa, + 0xf11ab: 0xf11ab, + 0xf11ac: 0xf11ac, + 0xf11ad: 0xf11ad, + 0xf11ae: 0xf11ae, + 0xf11af: 0xf11af, + 0xf11b0: 0xf11b0, + 0xf11b1: 0xf11b1, + 0xf11b2: 0xf11b2, + 0xf11b3: 0xf11b3, + 0xf11b4: 0xf11b4, + 0xf11b5: 0xf11b5, + 0xf11b6: 0xf11b6, + 0xf11b7: 0xf11b7, + 0xf11b8: 0xf11b8, + 0xf11b9: 0xf11b9, + 0xf11ba: 0xf11ba, + 0xf11bb: 0xf11bb, + 0xf11bc: 0xf11bc, + 0xf11bd: 0xf11bd, + 0xf11be: 0xf11be, + 0xf11bf: 0xf11bf, + 0xf11c0: 0xf11c0, + 0xf11c1: 0xf11c1, + 0xf11c2: 0xf11c2, + 0xf11c3: 0xf11c3, + 0xf11c4: 0xf11c4, + 0xf11c5: 0xf11c5, + 0xf11c6: 0xf11c6, + 0xf11c7: 0xf11c7, + 0xf11c8: 0xf11c8, + 0xf11c9: 0xf11c9, + 0xf11ca: 0xf11ca, + 0xf11cb: 0xf11cb, + 0xf11cc: 0xf11cc, + 0xf11cd: 0xf11cd, + 0xf11ce: 0xf11ce, + 0xf11cf: 0xf11cf, + 0xf11d0: 0xf11d0, + 0xf11d1: 0xf11d1, + 0xf11d2: 0xf11d2, + 0xf11d3: 0xf11d3, + 0xf11d4: 0xf11d4, + 0xf11d5: 0xf11d5, + 0xf11d6: 0xf11d6, + 0xf11d7: 0xf11d7, + 0xf11d8: 0xf11d8, + 0xf11d9: 0xf11d9, + 0xf11da: 0xf11da, + 0xf11db: 0xf11db, + 0xf11dc: 0xf11dc, + 0xf11dd: 0xf11dd, + 0xf11de: 0xf11de, + 0xf11df: 0xf11df, + 0xf11e0: 0xf11e0, + 0xf11e1: 0xf11e1, + 0xf11e2: 0xf11e2, + 0xf11e3: 0xf11e3, + 0xf11e4: 0xf11e4, + 0xf11e5: 0xf11e5, + 0xf11e6: 0xf11e6, + 0xf11e7: 0xf11e7, + 0xf11e8: 0xf11e8, + 0xf11e9: 0xf11e9, + 0xf11ea: 0xf11ea, + 0xf11eb: 0xf11eb, + 0xf11ec: 0xf11ec, + 0xf11ed: 0xf11ed, + 0xf11ee: 0xf11ee, + 0xf11ef: 0xf11ef, + 0xf11f0: 0xf11f0, + 0xf11f1: 0xf11f1, + 0xf11f2: 0xf11f2, + 0xf11f3: 0xf11f3, + 0xf11f4: 0xf11f4, + 0xf11f5: 0xf11f5, + 0xf11f6: 0xf11f6, + 0xf11f7: 0xf11f7, + 0xf11f8: 0xf11f8, + 0xf11f9: 0xf11f9, + 0xf11fa: 0xf11fa, + 0xf11fb: 0xf11fb, + 0xf11fc: 0xf11fc, + 0xf11fd: 0xf11fd, + 0xf11fe: 0xf11fe, + 0xf11ff: 0xf11ff, + 0xf1200: 0xf1200, + 0xf1201: 0xf1201, + 0xf1202: 0xf1202, + 0xf1203: 0xf1203, + 0xf1204: 0xf1204, + 0xf1205: 0xf1205, + 0xf1206: 0xf1206, + 0xf1207: 0xf1207, + 0xf1208: 0xf1208, + 0xf1209: 0xf1209, + 0xf120a: 0xf120a, + 0xf120b: 0xf120b, + 0xf120c: 0xf120c, + 0xf120d: 0xf120d, + 0xf120e: 0xf120e, + 0xf120f: 0xf120f, + 0xf1210: 0xf1210, + 0xf1211: 0xf1211, + 0xf1212: 0xf1212, + 0xf1213: 0xf1213, + 0xf1214: 0xf1214, + 0xf1215: 0xf1215, + 0xf1216: 0xf1216, + 0xf1217: 0xf1217, + 0xf1218: 0xf1218, + 0xf1219: 0xf1219, + 0xf121a: 0xf121a, + 0xf121b: 0xf121b, + 0xf121c: 0xf121c, + 0xf121d: 0xf121d, + 0xf121e: 0xf121e, + 0xf121f: 0xf121f, + 0xf1220: 0xf1220, + 0xf1221: 0xf1221, + 0xf1222: 0xf1222, + 0xf1223: 0xf1223, + 0xf1224: 0xf1224, + 0xf1225: 0xf1225, + 0xf1226: 0xf1226, + 0xf1227: 0xf1227, + 0xf1228: 0xf1228, + 0xf1229: 0xf1229, + 0xf122a: 0xf122a, + 0xf122b: 0xf122b, + 0xf122c: 0xf122c, + 0xf122d: 0xf122d, + 0xf122e: 0xf122e, + 0xf122f: 0xf122f, + 0xf1230: 0xf1230, + 0xf1231: 0xf1231, + 0xf1232: 0xf1232, + 0xf1233: 0xf1233, + 0xf1234: 0xf1234, + 0xf1235: 0xf1235, + 0xf1236: 0xf1236, + 0xf1237: 0xf1237, + 0xf1238: 0xf1238, + 0xf1239: 0xf1239, + 0xf123a: 0xf123a, + 0xf123b: 0xf123b, + 0xf123c: 0xf123c, + 0xf123d: 0xf123d, + 0xf123e: 0xf123e, + 0xf123f: 0xf123f, + 0xf1240: 0xf1240, + 0xf1241: 0xf1241, + 0xf1242: 0xf1242, + 0xf1243: 0xf1243, + 0xf1244: 0xf1244, + 0xf1245: 0xf1245, + 0xf1246: 0xf1246, + 0xf1247: 0xf1247, + 0xf1248: 0xf1248, + 0xf1249: 0xf1249, + 0xf124a: 0xf124a, + 0xf124b: 0xf124b, + 0xf124c: 0xf124c, + 0xf124d: 0xf124d, + 0xf124e: 0xf124e, + 0xf124f: 0xf124f, + 0xf1250: 0xf1250, + 0xf1251: 0xf1251, + 0xf1252: 0xf1252, + 0xf1253: 0xf1253, + 0xf1254: 0xf1254, + 0xf1255: 0xf1255, + 0xf1256: 0xf1256, + 0xf1257: 0xf1257, + 0xf1258: 0xf1258, + 0xf1259: 0xf1259, + 0xf125a: 0xf125a, + 0xf125b: 0xf125b, + 0xf125c: 0xf125c, + 0xf125d: 0xf125d, + 0xf125e: 0xf125e, + 0xf125f: 0xf125f, + 0xf1260: 0xf1260, + 0xf1261: 0xf1261, + 0xf1262: 0xf1262, + 0xf1263: 0xf1263, + 0xf1264: 0xf1264, + 0xf1265: 0xf1265, + 0xf1266: 0xf1266, + 0xf1267: 0xf1267, + 0xf1268: 0xf1268, + 0xf1269: 0xf1269, + 0xf126a: 0xf126a, + 0xf126b: 0xf126b, + 0xf126c: 0xf126c, + 0xf126d: 0xf126d, + 0xf126e: 0xf126e, + 0xf126f: 0xf126f, + 0xf1270: 0xf1270, + 0xf1271: 0xf1271, + 0xf1272: 0xf1272, + 0xf1273: 0xf1273, + 0xf1274: 0xf1274, + 0xf1275: 0xf1275, + 0xf1276: 0xf1276, + 0xf1277: 0xf1277, + 0xf1278: 0xf1278, + 0xf1279: 0xf1279, + 0xf127a: 0xf127a, + 0xf127b: 0xf127b, + 0xf127c: 0xf127c, + 0xf127d: 0xf127d, + 0xf127e: 0xf127e, + 0xf127f: 0xf127f, + 0xf1280: 0xf1280, + 0xf1281: 0xf1281, + 0xf1282: 0xf1282, + 0xf1283: 0xf1283, + 0xf1284: 0xf1284, + 0xf1285: 0xf1285, + 0xf1286: 0xf1286, + 0xf1287: 0xf1287, + 0xf1288: 0xf1288, + 0xf1289: 0xf1289, + 0xf128a: 0xf128a, + 0xf128b: 0xf128b, + 0xf128c: 0xf128c, + 0xf128d: 0xf128d, + 0xf128e: 0xf128e, + 0xf128f: 0xf128f, + 0xf1290: 0xf1290, + 0xf1291: 0xf1291, + 0xf1292: 0xf1292, + 0xf1293: 0xf1293, + 0xf1294: 0xf1294, + 0xf1295: 0xf1295, + 0xf1296: 0xf1296, + 0xf1297: 0xf1297, + 0xf1298: 0xf1298, + 0xf1299: 0xf1299, + 0xf129a: 0xf129a, + 0xf129b: 0xf129b, + 0xf129c: 0xf129c, + 0xf129d: 0xf129d, + 0xf129e: 0xf129e, + 0xf129f: 0xf129f, + 0xf12a0: 0xf12a0, + 0xf12a1: 0xf12a1, + 0xf12a2: 0xf12a2, + 0xf12a3: 0xf12a3, + 0xf12a4: 0xf12a4, + 0xf12a5: 0xf12a5, + 0xf12a6: 0xf12a6, + 0xf12a7: 0xf12a7, + 0xf12a8: 0xf12a8, + 0xf12a9: 0xf12a9, + 0xf12aa: 0xf12aa, + 0xf12ab: 0xf12ab, + 0xf12ac: 0xf12ac, + 0xf12ad: 0xf12ad, + 0xf12ae: 0xf12ae, + 0xf12af: 0xf12af, + 0xf12b0: 0xf12b0, + 0xf12b1: 0xf12b1, + 0xf12b2: 0xf12b2, + 0xf12b3: 0xf12b3, + 0xf12b4: 0xf12b4, + 0xf12b5: 0xf12b5, + 0xf12b6: 0xf12b6, + 0xf12b7: 0xf12b7, + 0xf12b8: 0xf12b8, + 0xf12b9: 0xf12b9, + 0xf12ba: 0xf12ba, + 0xf12bb: 0xf12bb, + 0xf12bc: 0xf12bc, + 0xf12bd: 0xf12bd, + 0xf12be: 0xf12be, + 0xf12bf: 0xf12bf, + 0xf12c0: 0xf12c0, + 0xf12c1: 0xf12c1, + 0xf12c2: 0xf12c2, + 0xf12c3: 0xf12c3, + 0xf12c4: 0xf12c4, + 0xf12c5: 0xf12c5, + 0xf12c6: 0xf12c6, + 0xf12c7: 0xf12c7, + 0xf12c8: 0xf12c8, + 0xf12c9: 0xf12c9, + 0xf12ca: 0xf12ca, + 0xf12cb: 0xf12cb, + 0xf12cc: 0xf12cc, + 0xf12cd: 0xf12cd, + 0xf12ce: 0xf12ce, + 0xf12cf: 0xf12cf, + 0xf12d0: 0xf12d0, + 0xf12d1: 0xf12d1, + 0xf12d2: 0xf12d2, + 0xf12d3: 0xf12d3, + 0xf12d4: 0xf12d4, + 0xf12d5: 0xf12d5, + 0xf12d6: 0xf12d6, + 0xf12d7: 0xf12d7, + 0xf12d8: 0xf12d8, + 0xf12d9: 0xf12d9, + 0xf12da: 0xf12da, + 0xf12db: 0xf12db, + 0xf12dc: 0xf12dc, + 0xf12dd: 0xf12dd, + 0xf12de: 0xf12de, + 0xf12df: 0xf12df, + 0xf12e0: 0xf12e0, + 0xf12e1: 0xf12e1, + 0xf12e2: 0xf12e2, + 0xf12e3: 0xf12e3, + 0xf12e4: 0xf12e4, + 0xf12e5: 0xf12e5, + 0xf12e6: 0xf12e6, + 0xf12e7: 0xf12e7, + 0xf12e8: 0xf12e8, + 0xf12e9: 0xf12e9, + 0xf12ea: 0xf12ea, + 0xf12eb: 0xf12eb, + 0xf12ec: 0xf12ec, + 0xf12ed: 0xf12ed, + 0xf12ee: 0xf12ee, + 0xf12ef: 0xf12ef, + 0xf12f0: 0xf12f0, + 0xf12f1: 0xf12f1, + 0xf12f2: 0xf12f2, + 0xf12f3: 0xf12f3, + 0xf12f4: 0xf12f4, + 0xf12f5: 0xf12f5, + 0xf12f6: 0xf12f6, + 0xf12f7: 0xf12f7, + 0xf12f8: 0xf12f8, + 0xf12f9: 0xf12f9, + 0xf12fa: 0xf12fa, + 0xf12fb: 0xf12fb, + 0xf12fc: 0xf12fc, + 0xf12fd: 0xf12fd, + 0xf12fe: 0xf12fe, + 0xf12ff: 0xf12ff, + 0xf1300: 0xf1300, + 0xf1301: 0xf1301, + 0xf1302: 0xf1302, + 0xf1303: 0xf1303, + 0xf1304: 0xf1304, + 0xf1305: 0xf1305, + 0xf1306: 0xf1306, + 0xf1307: 0xf1307, + 0xf1308: 0xf1308, + 0xf1309: 0xf1309, + 0xf130a: 0xf130a, + 0xf130b: 0xf130b, + 0xf130c: 0xf130c, + 0xf130d: 0xf130d, + 0xf130e: 0xf130e, + 0xf130f: 0xf130f, + 0xf1310: 0xf1310, + 0xf1311: 0xf1311, + 0xf1312: 0xf1312, + 0xf1313: 0xf1313, + 0xf1314: 0xf1314, + 0xf1315: 0xf1315, + 0xf1316: 0xf1316, + 0xf1317: 0xf1317, + 0xf1318: 0xf1318, + 0xf1319: 0xf1319, + 0xf131a: 0xf131a, + 0xf131b: 0xf131b, + 0xf131c: 0xf131c, + 0xf131d: 0xf131d, + 0xf131e: 0xf131e, + 0xf131f: 0xf131f, + 0xf1320: 0xf1320, + 0xf1321: 0xf1321, + 0xf1322: 0xf1322, + 0xf1323: 0xf1323, + 0xf1324: 0xf1324, + 0xf1325: 0xf1325, + 0xf1326: 0xf1326, + 0xf1327: 0xf1327, + 0xf1328: 0xf1328, + 0xf1329: 0xf1329, + 0xf132a: 0xf132a, + 0xf132b: 0xf132b, + 0xf132c: 0xf132c, + 0xf132d: 0xf132d, + 0xf132e: 0xf132e, + 0xf132f: 0xf132f, + 0xf1330: 0xf1330, + 0xf1331: 0xf1331, + 0xf1332: 0xf1332, + 0xf1333: 0xf1333, + 0xf1334: 0xf1334, + 0xf1335: 0xf1335, + 0xf1336: 0xf1336, + 0xf1337: 0xf1337, + 0xf1338: 0xf1338, + 0xf1339: 0xf1339, + 0xf133a: 0xf133a, + 0xf133b: 0xf133b, + 0xf133c: 0xf133c, + 0xf133d: 0xf133d, + 0xf133e: 0xf133e, + 0xf133f: 0xf133f, + 0xf1340: 0xf1340, + 0xf1341: 0xf1341, + 0xf1342: 0xf1342, + 0xf1343: 0xf1343, + 0xf1344: 0xf1344, + 0xf1345: 0xf1345, + 0xf1346: 0xf1346, + 0xf1347: 0xf1347, + 0xf1348: 0xf1348, + 0xf1349: 0xf1349, + 0xf134a: 0xf134a, + 0xf134b: 0xf134b, + 0xf134c: 0xf134c, + 0xf134d: 0xf134d, + 0xf134e: 0xf134e, + 0xf134f: 0xf134f, + 0xf1350: 0xf1350, + 0xf1351: 0xf1351, + 0xf1352: 0xf1352, + 0xf1353: 0xf1353, + 0xf1354: 0xf1354, + 0xf1355: 0xf1355, + 0xf1356: 0xf1356, + 0xf1357: 0xf1357, + 0xf1358: 0xf1358, + 0xf1359: 0xf1359, + 0xf135a: 0xf135a, + 0xf135b: 0xf135b, + 0xf135c: 0xf135c, + 0xf135d: 0xf135d, + 0xf135e: 0xf135e, + 0xf135f: 0xf135f, + 0xf1360: 0xf1360, + 0xf1361: 0xf1361, + 0xf1362: 0xf1362, + 0xf1363: 0xf1363, + 0xf1364: 0xf1364, + 0xf1365: 0xf1365, + 0xf1366: 0xf1366, + 0xf1367: 0xf1367, + 0xf1368: 0xf1368, + 0xf1369: 0xf1369, + 0xf136a: 0xf136a, + 0xf136b: 0xf136b, + 0xf136c: 0xf136c, + 0xf136d: 0xf136d, + 0xf136e: 0xf136e, + 0xf136f: 0xf136f, + 0xf1370: 0xf1370, + 0xf1371: 0xf1371, + 0xf1372: 0xf1372, + 0xf1373: 0xf1373, + 0xf1374: 0xf1374, + 0xf1375: 0xf1375, + 0xf1376: 0xf1376, + 0xf1377: 0xf1377, + 0xf1378: 0xf1378, + 0xf1379: 0xf1379, + 0xf137a: 0xf137a, + 0xf137b: 0xf137b, + 0xf137c: 0xf137c, + 0xf137d: 0xf137d, + 0xf137e: 0xf137e, + 0xf137f: 0xf137f, + 0xf1380: 0xf1380, + 0xf1381: 0xf1381, + 0xf1382: 0xf1382, + 0xf1383: 0xf1383, + 0xf1384: 0xf1384, + 0xf1385: 0xf1385, + 0xf1386: 0xf1386, + 0xf1387: 0xf1387, + 0xf1388: 0xf1388, + 0xf1389: 0xf1389, + 0xf138a: 0xf138a, + 0xf138b: 0xf138b, + 0xf138c: 0xf138c, + 0xf138d: 0xf138d, + 0xf138e: 0xf138e, + 0xf138f: 0xf138f, + 0xf1390: 0xf1390, + 0xf1391: 0xf1391, + 0xf1392: 0xf1392, + 0xf1393: 0xf1393, + 0xf1394: 0xf1394, + 0xf1395: 0xf1395, + 0xf1396: 0xf1396, + 0xf1397: 0xf1397, + 0xf1398: 0xf1398, + 0xf1399: 0xf1399, + 0xf139a: 0xf139a, + 0xf139b: 0xf139b, + 0xf139c: 0xf139c, + 0xf139d: 0xf139d, + 0xf139e: 0xf139e, + 0xf139f: 0xf139f, + 0xf13a0: 0xf13a0, + 0xf13a1: 0xf13a1, + 0xf13a2: 0xf13a2, + 0xf13a3: 0xf13a3, + 0xf13a4: 0xf13a4, + 0xf13a5: 0xf13a5, + 0xf13a6: 0xf13a6, + 0xf13a7: 0xf13a7, + 0xf13a8: 0xf13a8, + 0xf13a9: 0xf13a9, + 0xf13aa: 0xf13aa, + 0xf13ab: 0xf13ab, + 0xf13ac: 0xf13ac, + 0xf13ad: 0xf13ad, + 0xf13ae: 0xf13ae, + 0xf13af: 0xf13af, + 0xf13b0: 0xf13b0, + 0xf13b1: 0xf13b1, + 0xf13b2: 0xf13b2, + 0xf13b3: 0xf13b3, + 0xf13b4: 0xf13b4, + 0xf13b5: 0xf13b5, + 0xf13b6: 0xf13b6, + 0xf13b7: 0xf13b7, + 0xf13b8: 0xf13b8, + 0xf13b9: 0xf13b9, + 0xf13ba: 0xf13ba, + 0xf13bb: 0xf13bb, + 0xf13bc: 0xf13bc, + 0xf13bd: 0xf13bd, + 0xf13be: 0xf13be, + 0xf13bf: 0xf13bf, + 0xf13c0: 0xf13c0, + 0xf13c1: 0xf13c1, + 0xf13c2: 0xf13c2, + 0xf13c3: 0xf13c3, + 0xf13c4: 0xf13c4, + 0xf13c5: 0xf13c5, + 0xf13c6: 0xf13c6, + 0xf13c7: 0xf13c7, + 0xf13c8: 0xf13c8, + 0xf13c9: 0xf13c9, + 0xf13ca: 0xf13ca, + 0xf13cb: 0xf13cb, + 0xf13cc: 0xf13cc, + 0xf13cd: 0xf13cd, + 0xf13ce: 0xf13ce, + 0xf13cf: 0xf13cf, + 0xf13d0: 0xf13d0, + 0xf13d1: 0xf13d1, + 0xf13d2: 0xf13d2, + 0xf13d3: 0xf13d3, + 0xf13d4: 0xf13d4, + 0xf13d5: 0xf13d5, + 0xf13d6: 0xf13d6, + 0xf13d7: 0xf13d7, + 0xf13d8: 0xf13d8, + 0xf13d9: 0xf13d9, + 0xf13da: 0xf13da, + 0xf13db: 0xf13db, + 0xf13dc: 0xf13dc, + 0xf13dd: 0xf13dd, + 0xf13de: 0xf13de, + 0xf13df: 0xf13df, + 0xf13e0: 0xf13e0, + 0xf13e1: 0xf13e1, + 0xf13e2: 0xf13e2, + 0xf13e3: 0xf13e3, + 0xf13e4: 0xf13e4, + 0xf13e5: 0xf13e5, + 0xf13e6: 0xf13e6, + 0xf13e7: 0xf13e7, + 0xf13e8: 0xf13e8, + 0xf13e9: 0xf13e9, + 0xf13ea: 0xf13ea, + 0xf13eb: 0xf13eb, + 0xf13ec: 0xf13ec, + 0xf13ed: 0xf13ed, + 0xf13ee: 0xf13ee, + 0xf13ef: 0xf13ef, + 0xf13f0: 0xf13f0, + 0xf13f1: 0xf13f1, + 0xf13f2: 0xf13f2, + 0xf13f3: 0xf13f3, + 0xf13f4: 0xf13f4, + 0xf13f5: 0xf13f5, + 0xf13f6: 0xf13f6, + 0xf13f7: 0xf13f7, + 0xf13f8: 0xf13f8, + 0xf13f9: 0xf13f9, + 0xf13fa: 0xf13fa, + 0xf13fb: 0xf13fb, + 0xf13fc: 0xf13fc, + 0xf13fd: 0xf13fd, + 0xf13fe: 0xf13fe, + 0xf13ff: 0xf13ff, + 0xf1400: 0xf1400, + 0xf1401: 0xf1401, + 0xf1402: 0xf1402, + 0xf1403: 0xf1403, + 0xf1404: 0xf1404, + 0xf1405: 0xf1405, + 0xf1406: 0xf1406, + 0xf1407: 0xf1407, + 0xf1408: 0xf1408, + 0xf1409: 0xf1409, + 0xf140a: 0xf140a, + 0xf140b: 0xf140b, + 0xf140c: 0xf140c, + 0xf140d: 0xf140d, + 0xf140e: 0xf140e, + 0xf140f: 0xf140f, + 0xf1410: 0xf1410, + 0xf1411: 0xf1411, + 0xf1412: 0xf1412, + 0xf1413: 0xf1413, + 0xf1414: 0xf1414, + 0xf1415: 0xf1415, + 0xf1416: 0xf1416, + 0xf1417: 0xf1417, + 0xf1418: 0xf1418, + 0xf1419: 0xf1419, + 0xf141a: 0xf141a, + 0xf141b: 0xf141b, + 0xf141c: 0xf141c, + 0xf141d: 0xf141d, + 0xf141e: 0xf141e, + 0xf141f: 0xf141f, + 0xf1420: 0xf1420, + 0xf1421: 0xf1421, + 0xf1422: 0xf1422, + 0xf1423: 0xf1423, + 0xf1424: 0xf1424, + 0xf1425: 0xf1425, + 0xf1426: 0xf1426, + 0xf1427: 0xf1427, + 0xf1428: 0xf1428, + 0xf1429: 0xf1429, + 0xf142a: 0xf142a, + 0xf142b: 0xf142b, + 0xf142c: 0xf142c, + 0xf142d: 0xf142d, + 0xf142e: 0xf142e, + 0xf142f: 0xf142f, + 0xf1430: 0xf1430, + 0xf1431: 0xf1431, + 0xf1432: 0xf1432, + 0xf1433: 0xf1433, + 0xf1434: 0xf1434, + 0xf1435: 0xf1435, + 0xf1436: 0xf1436, + 0xf1437: 0xf1437, + 0xf1438: 0xf1438, + 0xf1439: 0xf1439, + 0xf143a: 0xf143a, + 0xf143b: 0xf143b, + 0xf143c: 0xf143c, + 0xf143d: 0xf143d, + 0xf143e: 0xf143e, + 0xf143f: 0xf143f, + 0xf1440: 0xf1440, + 0xf1441: 0xf1441, + 0xf1442: 0xf1442, + 0xf1443: 0xf1443, + 0xf1444: 0xf1444, + 0xf1445: 0xf1445, + 0xf1446: 0xf1446, + 0xf1447: 0xf1447, + 0xf1448: 0xf1448, + 0xf1449: 0xf1449, + 0xf144a: 0xf144a, + 0xf144b: 0xf144b, + 0xf144c: 0xf144c, + 0xf144d: 0xf144d, + 0xf144e: 0xf144e, + 0xf144f: 0xf144f, + 0xf1450: 0xf1450, + 0xf1451: 0xf1451, + 0xf1452: 0xf1452, + 0xf1453: 0xf1453, + 0xf1454: 0xf1454, + 0xf1455: 0xf1455, + 0xf1456: 0xf1456, + 0xf1457: 0xf1457, + 0xf1458: 0xf1458, + 0xf1459: 0xf1459, + 0xf145a: 0xf145a, + 0xf145b: 0xf145b, + 0xf145c: 0xf145c, + 0xf145d: 0xf145d, + 0xf145e: 0xf145e, + 0xf145f: 0xf145f, + 0xf1460: 0xf1460, + 0xf1461: 0xf1461, + 0xf1462: 0xf1462, + 0xf1463: 0xf1463, + 0xf1464: 0xf1464, + 0xf1465: 0xf1465, + 0xf1466: 0xf1466, + 0xf1467: 0xf1467, + 0xf1468: 0xf1468, + 0xf1469: 0xf1469, + 0xf146a: 0xf146a, + 0xf146b: 0xf146b, + 0xf146c: 0xf146c, + 0xf146d: 0xf146d, + 0xf146e: 0xf146e, + 0xf146f: 0xf146f, + 0xf1470: 0xf1470, + 0xf1471: 0xf1471, + 0xf1472: 0xf1472, + 0xf1473: 0xf1473, + 0xf1474: 0xf1474, + 0xf1475: 0xf1475, + 0xf1476: 0xf1476, + 0xf1477: 0xf1477, + 0xf1478: 0xf1478, + 0xf1479: 0xf1479, + 0xf147a: 0xf147a, + 0xf147b: 0xf147b, + 0xf147c: 0xf147c, + 0xf147d: 0xf147d, + 0xf147e: 0xf147e, + 0xf147f: 0xf147f, + 0xf1480: 0xf1480, + 0xf1481: 0xf1481, + 0xf1482: 0xf1482, + 0xf1483: 0xf1483, + 0xf1484: 0xf1484, + 0xf1485: 0xf1485, + 0xf1486: 0xf1486, + 0xf1487: 0xf1487, + 0xf1488: 0xf1488, + 0xf1489: 0xf1489, + 0xf148a: 0xf148a, + 0xf148b: 0xf148b, + 0xf148c: 0xf148c, + 0xf148d: 0xf148d, + 0xf148e: 0xf148e, + 0xf148f: 0xf148f, + 0xf1490: 0xf1490, + 0xf1491: 0xf1491, + 0xf1492: 0xf1492, + 0xf1493: 0xf1493, + 0xf1494: 0xf1494, + 0xf1495: 0xf1495, + 0xf1496: 0xf1496, + 0xf1497: 0xf1497, + 0xf1498: 0xf1498, + 0xf1499: 0xf1499, + 0xf149a: 0xf149a, + 0xf149b: 0xf149b, + 0xf149c: 0xf149c, + 0xf149d: 0xf149d, + 0xf149e: 0xf149e, + 0xf149f: 0xf149f, + 0xf14a0: 0xf14a0, + 0xf14a1: 0xf14a1, + 0xf14a2: 0xf14a2, + 0xf14a3: 0xf14a3, + 0xf14a4: 0xf14a4, + 0xf14a5: 0xf14a5, + 0xf14a6: 0xf14a6, + 0xf14a7: 0xf14a7, + 0xf14a8: 0xf14a8, + 0xf14a9: 0xf14a9, + 0xf14aa: 0xf14aa, + 0xf14ab: 0xf14ab, + 0xf14ac: 0xf14ac, + 0xf14ad: 0xf14ad, + 0xf14ae: 0xf14ae, + 0xf14af: 0xf14af, + 0xf14b0: 0xf14b0, + 0xf14b1: 0xf14b1, + 0xf14b2: 0xf14b2, + 0xf14b3: 0xf14b3, + 0xf14b4: 0xf14b4, + 0xf14b5: 0xf14b5, + 0xf14b6: 0xf14b6, + 0xf14b7: 0xf14b7, + 0xf14b8: 0xf14b8, + 0xf14b9: 0xf14b9, + 0xf14ba: 0xf14ba, + 0xf14bb: 0xf14bb, + 0xf14bc: 0xf14bc, + 0xf14bd: 0xf14bd, + 0xf14be: 0xf14be, + 0xf14bf: 0xf14bf, + 0xf14c0: 0xf14c0, + 0xf14c1: 0xf14c1, + 0xf14c2: 0xf14c2, + 0xf14c3: 0xf14c3, + 0xf14c4: 0xf14c4, + 0xf14c5: 0xf14c5, + 0xf14c6: 0xf14c6, + 0xf14c7: 0xf14c7, + 0xf14c8: 0xf14c8, + 0xf14c9: 0xf14c9, + 0xf14ca: 0xf14ca, + 0xf14cb: 0xf14cb, + 0xf14cc: 0xf14cc, + 0xf14cd: 0xf14cd, + 0xf14ce: 0xf14ce, + 0xf14cf: 0xf14cf, + 0xf14d0: 0xf14d0, + 0xf14d1: 0xf14d1, + 0xf14d2: 0xf14d2, + 0xf14d3: 0xf14d3, + 0xf14d4: 0xf14d4, + 0xf14d5: 0xf14d5, + 0xf14d6: 0xf14d6, + 0xf14d7: 0xf14d7, + 0xf14d8: 0xf14d8, + 0xf14d9: 0xf14d9, + 0xf14da: 0xf14da, + 0xf14db: 0xf14db, + 0xf14dc: 0xf14dc, + 0xf14dd: 0xf14dd, + 0xf14de: 0xf14de, + 0xf14df: 0xf14df, + 0xf14e0: 0xf14e0, + 0xf14e1: 0xf14e1, + 0xf14e2: 0xf14e2, + 0xf14e3: 0xf14e3, + 0xf14e4: 0xf14e4, + 0xf14e5: 0xf14e5, + 0xf14e6: 0xf14e6, + 0xf14e7: 0xf14e7, + 0xf14e8: 0xf14e8, + 0xf14e9: 0xf14e9, + 0xf14ea: 0xf14ea, + 0xf14eb: 0xf14eb, + 0xf14ec: 0xf14ec, + 0xf14ed: 0xf14ed, + 0xf14ee: 0xf14ee, + 0xf14ef: 0xf14ef, + 0xf14f0: 0xf14f0, + 0xf14f1: 0xf14f1, + 0xf14f2: 0xf14f2, + 0xf14f3: 0xf14f3, + 0xf14f4: 0xf14f4, + 0xf14f5: 0xf14f5, + 0xf14f6: 0xf14f6, + 0xf14f7: 0xf14f7, + 0xf14f8: 0xf14f8, + 0xf14f9: 0xf14f9, + 0xf14fa: 0xf14fa, + 0xf14fb: 0xf14fb, + 0xf14fc: 0xf14fc, + 0xf14fd: 0xf14fd, + 0xf14fe: 0xf14fe, + 0xf14ff: 0xf14ff, + 0xf1500: 0xf1500, + 0xf1501: 0xf1501, + 0xf1502: 0xf1502, + 0xf1503: 0xf1503, + 0xf1504: 0xf1504, + 0xf1505: 0xf1505, + 0xf1506: 0xf1506, + 0xf1507: 0xf1507, + 0xf1508: 0xf1508, + 0xf1509: 0xf1509, + 0xf150a: 0xf150a, + 0xf150b: 0xf150b, + 0xf150c: 0xf150c, + 0xf150d: 0xf150d, + 0xf150e: 0xf150e, + 0xf150f: 0xf150f, + 0xf1510: 0xf1510, + 0xf1511: 0xf1511, + 0xf1512: 0xf1512, + 0xf1513: 0xf1513, + 0xf1514: 0xf1514, + 0xf1515: 0xf1515, + 0xf1516: 0xf1516, + 0xf1517: 0xf1517, + 0xf1518: 0xf1518, + 0xf1519: 0xf1519, + 0xf151a: 0xf151a, + 0xf151b: 0xf151b, + 0xf151c: 0xf151c, + 0xf151d: 0xf151d, + 0xf151e: 0xf151e, + 0xf151f: 0xf151f, + 0xf1520: 0xf1520, + 0xf1521: 0xf1521, + 0xf1522: 0xf1522, + 0xf1523: 0xf1523, + 0xf1524: 0xf1524, + 0xf1525: 0xf1525, + 0xf1526: 0xf1526, + 0xf1527: 0xf1527, + 0xf1528: 0xf1528, + 0xf1529: 0xf1529, + 0xf152a: 0xf152a, + 0xf152b: 0xf152b, + 0xf152c: 0xf152c, + 0xf152d: 0xf152d, + 0xf152e: 0xf152e, + 0xf152f: 0xf152f, + 0xf1530: 0xf1530, + 0xf1531: 0xf1531, + 0xf1532: 0xf1532, + 0xf1533: 0xf1533, + 0xf1534: 0xf1534, + 0xf1535: 0xf1535, + 0xf1536: 0xf1536, + 0xf1537: 0xf1537, + 0xf1538: 0xf1538, + 0xf1539: 0xf1539, + 0xf153a: 0xf153a, + 0xf153b: 0xf153b, + 0xf153c: 0xf153c, + 0xf153d: 0xf153d, + 0xf153e: 0xf153e, + 0xf153f: 0xf153f, + 0xf1540: 0xf1540, + 0xf1541: 0xf1541, + 0xf1542: 0xf1542, + 0xf1543: 0xf1543, + 0xf1544: 0xf1544, + 0xf1545: 0xf1545, + 0xf1546: 0xf1546, + 0xf1547: 0xf1547, + 0xf1548: 0xf1548, + 0xf1549: 0xf1549, + 0xf154a: 0xf154a, + 0xf154b: 0xf154b, + 0xf154c: 0xf154c, + 0xf154d: 0xf154d, + 0xf154e: 0xf154e, + 0xf154f: 0xf154f, + 0xf1550: 0xf1550, + 0xf1551: 0xf1551, + 0xf1552: 0xf1552, + 0xf1553: 0xf1553, + 0xf1554: 0xf1554, + 0xf1555: 0xf1555, + 0xf1556: 0xf1556, + 0xf1557: 0xf1557, + 0xf1558: 0xf1558, + 0xf1559: 0xf1559, + 0xf155a: 0xf155a, + 0xf155b: 0xf155b, + 0xf155c: 0xf155c, + 0xf155d: 0xf155d, + 0xf155e: 0xf155e, + 0xf155f: 0xf155f, + 0xf1560: 0xf1560, + 0xf1561: 0xf1561, + 0xf1562: 0xf1562, + 0xf1563: 0xf1563, + 0xf1564: 0xf1564, + 0xf1565: 0xf1565, + 0xf1566: 0xf1566, + 0xf1567: 0xf1567, + 0xf1568: 0xf1568, + 0xf1569: 0xf1569, + 0xf156a: 0xf156a, + 0xf156b: 0xf156b, + 0xf156c: 0xf156c, + 0xf156d: 0xf156d, + 0xf156e: 0xf156e, + 0xf156f: 0xf156f, + 0xf1570: 0xf1570, + 0xf1571: 0xf1571, + 0xf1572: 0xf1572, + 0xf1573: 0xf1573, + 0xf1574: 0xf1574, + 0xf1575: 0xf1575, + 0xf1576: 0xf1576, + 0xf1577: 0xf1577, + 0xf1578: 0xf1578, + 0xf1579: 0xf1579, + 0xf157a: 0xf157a, + 0xf157b: 0xf157b, + 0xf157c: 0xf157c, + 0xf157d: 0xf157d, + 0xf157e: 0xf157e, + 0xf157f: 0xf157f, + 0xf1580: 0xf1580, + 0xf1581: 0xf1581, + 0xf1582: 0xf1582, + 0xf1583: 0xf1583, + 0xf1584: 0xf1584, + 0xf1585: 0xf1585, + 0xf1586: 0xf1586, + 0xf1587: 0xf1587, + 0xf1588: 0xf1588, + 0xf1589: 0xf1589, + 0xf158a: 0xf158a, + 0xf158b: 0xf158b, + 0xf158c: 0xf158c, + 0xf158d: 0xf158d, + 0xf158e: 0xf158e, + 0xf158f: 0xf158f, + 0xf1590: 0xf1590, + 0xf1591: 0xf1591, + 0xf1592: 0xf1592, + 0xf1593: 0xf1593, + 0xf1594: 0xf1594, + 0xf1595: 0xf1595, + 0xf1596: 0xf1596, + 0xf1597: 0xf1597, + 0xf1598: 0xf1598, + 0xf1599: 0xf1599, + 0xf159a: 0xf159a, + 0xf159b: 0xf159b, + 0xf159c: 0xf159c, + 0xf159d: 0xf159d, + 0xf159e: 0xf159e, + 0xf159f: 0xf159f, + 0xf15a0: 0xf15a0, + 0xf15a1: 0xf15a1, + 0xf15a2: 0xf15a2, + 0xf15a3: 0xf15a3, + 0xf15a4: 0xf15a4, + 0xf15a5: 0xf15a5, + 0xf15a6: 0xf15a6, + 0xf15a7: 0xf15a7, + 0xf15a8: 0xf15a8, + 0xf15a9: 0xf15a9, + 0xf15aa: 0xf15aa, + 0xf15ab: 0xf15ab, + 0xf15ac: 0xf15ac, + 0xf15ad: 0xf15ad, + 0xf15ae: 0xf15ae, + 0xf15af: 0xf15af, + 0xf15b0: 0xf15b0, + 0xf15b1: 0xf15b1, + 0xf15b2: 0xf15b2, + 0xf15b3: 0xf15b3, + 0xf15b4: 0xf15b4, + 0xf15b5: 0xf15b5, + 0xf15b6: 0xf15b6, + 0xf15b7: 0xf15b7, + 0xf15b8: 0xf15b8, + 0xf15b9: 0xf15b9, + 0xf15ba: 0xf15ba, + 0xf15bb: 0xf15bb, + 0xf15bc: 0xf15bc, + 0xf15bd: 0xf15bd, + 0xf15be: 0xf15be, + 0xf15bf: 0xf15bf, + 0xf15c0: 0xf15c0, + 0xf15c1: 0xf15c1, + 0xf15c2: 0xf15c2, + 0xf15c3: 0xf15c3, + 0xf15c4: 0xf15c4, + 0xf15c5: 0xf15c5, + 0xf15c6: 0xf15c6, + 0xf15c7: 0xf15c7, + 0xf15c8: 0xf15c8, + 0xf15c9: 0xf15c9, + 0xf15ca: 0xf15ca, + 0xf15cb: 0xf15cb, + 0xf15cc: 0xf15cc, + 0xf15cd: 0xf15cd, + 0xf15ce: 0xf15ce, + 0xf15cf: 0xf15cf, + 0xf15d0: 0xf15d0, + 0xf15d1: 0xf15d1, + 0xf15d2: 0xf15d2, + 0xf15d3: 0xf15d3, + 0xf15d4: 0xf15d4, + 0xf15d5: 0xf15d5, + 0xf15d6: 0xf15d6, + 0xf15d7: 0xf15d7, + 0xf15d8: 0xf15d8, + 0xf15d9: 0xf15d9, + 0xf15da: 0xf15da, + 0xf15db: 0xf15db, + 0xf15dc: 0xf15dc, + 0xf15dd: 0xf15dd, + 0xf15de: 0xf15de, + 0xf15df: 0xf15df, + 0xf15e0: 0xf15e0, + 0xf15e1: 0xf15e1, + 0xf15e2: 0xf15e2, + 0xf15e3: 0xf15e3, + 0xf15e4: 0xf15e4, + 0xf15e5: 0xf15e5, + 0xf15e6: 0xf15e6, + 0xf15e7: 0xf15e7, + 0xf15e8: 0xf15e8, + 0xf15e9: 0xf15e9, + 0xf15ea: 0xf15ea, + 0xf15eb: 0xf15eb, + 0xf15ec: 0xf15ec, + 0xf15ed: 0xf15ed, + 0xf15ee: 0xf15ee, + 0xf15ef: 0xf15ef, + 0xf15f0: 0xf15f0, + 0xf15f1: 0xf15f1, + 0xf15f2: 0xf15f2, + 0xf15f3: 0xf15f3, + 0xf15f4: 0xf15f4, + 0xf15f5: 0xf15f5, + 0xf15f6: 0xf15f6, + 0xf15f7: 0xf15f7, + 0xf15f8: 0xf15f8, + 0xf15f9: 0xf15f9, + 0xf15fa: 0xf15fa, + 0xf15fb: 0xf15fb, + 0xf15fc: 0xf15fc, + 0xf15fd: 0xf15fd, + 0xf15fe: 0xf15fe, + 0xf15ff: 0xf15ff, + 0xf1600: 0xf1600, + 0xf1601: 0xf1601, + 0xf1602: 0xf1602, + 0xf1603: 0xf1603, + 0xf1604: 0xf1604, + 0xf1605: 0xf1605, + 0xf1606: 0xf1606, + 0xf1607: 0xf1607, + 0xf1608: 0xf1608, + 0xf1609: 0xf1609, + 0xf160a: 0xf160a, + 0xf160b: 0xf160b, + 0xf160c: 0xf160c, + 0xf160d: 0xf160d, + 0xf160e: 0xf160e, + 0xf160f: 0xf160f, + 0xf1610: 0xf1610, + 0xf1611: 0xf1611, + 0xf1612: 0xf1612, + 0xf1613: 0xf1613, + 0xf1614: 0xf1614, + 0xf1615: 0xf1615, + 0xf1616: 0xf1616, + 0xf1617: 0xf1617, + 0xf1618: 0xf1618, + 0xf1619: 0xf1619, + 0xf161a: 0xf161a, + 0xf161b: 0xf161b, + 0xf161c: 0xf161c, + 0xf161d: 0xf161d, + 0xf161e: 0xf161e, + 0xf161f: 0xf161f, + 0xf1620: 0xf1620, + 0xf1621: 0xf1621, + 0xf1622: 0xf1622, + 0xf1623: 0xf1623, + 0xf1624: 0xf1624, + 0xf1625: 0xf1625, + 0xf1626: 0xf1626, + 0xf1627: 0xf1627, + 0xf1628: 0xf1628, + 0xf1629: 0xf1629, + 0xf162a: 0xf162a, + 0xf162b: 0xf162b, + 0xf162c: 0xf162c, + 0xf162d: 0xf162d, + 0xf162e: 0xf162e, + 0xf162f: 0xf162f, + 0xf1630: 0xf1630, + 0xf1631: 0xf1631, + 0xf1632: 0xf1632, + 0xf1633: 0xf1633, + 0xf1634: 0xf1634, + 0xf1635: 0xf1635, + 0xf1636: 0xf1636, + 0xf1637: 0xf1637, + 0xf1638: 0xf1638, + 0xf1639: 0xf1639, + 0xf163a: 0xf163a, + 0xf163b: 0xf163b, + 0xf163c: 0xf163c, + 0xf163d: 0xf163d, + 0xf163e: 0xf163e, + 0xf163f: 0xf163f, + 0xf1640: 0xf1640, + 0xf1641: 0xf1641, + 0xf1642: 0xf1642, + 0xf1643: 0xf1643, + 0xf1644: 0xf1644, + 0xf1645: 0xf1645, + 0xf1646: 0xf1646, + 0xf1647: 0xf1647, + 0xf1648: 0xf1648, + 0xf1649: 0xf1649, + 0xf164a: 0xf164a, + 0xf164b: 0xf164b, + 0xf164c: 0xf164c, + 0xf164d: 0xf164d, + 0xf164e: 0xf164e, + 0xf164f: 0xf164f, + 0xf1650: 0xf1650, + 0xf1651: 0xf1651, + 0xf1652: 0xf1652, + 0xf1653: 0xf1653, + 0xf1654: 0xf1654, + 0xf1655: 0xf1655, + 0xf1656: 0xf1656, + 0xf1657: 0xf1657, + 0xf1658: 0xf1658, + 0xf1659: 0xf1659, + 0xf165a: 0xf165a, + 0xf165b: 0xf165b, + 0xf165c: 0xf165c, + 0xf165d: 0xf165d, + 0xf165e: 0xf165e, + 0xf165f: 0xf165f, + 0xf1660: 0xf1660, + 0xf1661: 0xf1661, + 0xf1662: 0xf1662, + 0xf1663: 0xf1663, + 0xf1664: 0xf1664, + 0xf1665: 0xf1665, + 0xf1666: 0xf1666, + 0xf1667: 0xf1667, + 0xf1668: 0xf1668, + 0xf1669: 0xf1669, + 0xf166a: 0xf166a, + 0xf166b: 0xf166b, + 0xf166c: 0xf166c, + 0xf166d: 0xf166d, + 0xf166e: 0xf166e, + 0xf166f: 0xf166f, + 0xf1670: 0xf1670, + 0xf1671: 0xf1671, + 0xf1672: 0xf1672, + 0xf1673: 0xf1673, + 0xf1674: 0xf1674, + 0xf1675: 0xf1675, + 0xf1676: 0xf1676, + 0xf1677: 0xf1677, + 0xf1678: 0xf1678, + 0xf1679: 0xf1679, + 0xf167a: 0xf167a, + 0xf167b: 0xf167b, + 0xf167c: 0xf167c, + 0xf167d: 0xf167d, + 0xf167e: 0xf167e, + 0xf167f: 0xf167f, + 0xf1680: 0xf1680, + 0xf1681: 0xf1681, + 0xf1682: 0xf1682, + 0xf1683: 0xf1683, + 0xf1684: 0xf1684, + 0xf1685: 0xf1685, + 0xf1686: 0xf1686, + 0xf1687: 0xf1687, + 0xf1688: 0xf1688, + 0xf1689: 0xf1689, + 0xf168a: 0xf168a, + 0xf168b: 0xf168b, + 0xf168c: 0xf168c, + 0xf168d: 0xf168d, + 0xf168e: 0xf168e, + 0xf168f: 0xf168f, + 0xf1690: 0xf1690, + 0xf1691: 0xf1691, + 0xf1692: 0xf1692, + 0xf1693: 0xf1693, + 0xf1694: 0xf1694, + 0xf1695: 0xf1695, + 0xf1696: 0xf1696, + 0xf1697: 0xf1697, + 0xf1698: 0xf1698, + 0xf1699: 0xf1699, + 0xf169a: 0xf169a, + 0xf169b: 0xf169b, + 0xf169c: 0xf169c, + 0xf169d: 0xf169d, + 0xf169e: 0xf169e, + 0xf169f: 0xf169f, + 0xf16a0: 0xf16a0, + 0xf16a1: 0xf16a1, + 0xf16a2: 0xf16a2, + 0xf16a3: 0xf16a3, + 0xf16a4: 0xf16a4, + 0xf16a5: 0xf16a5, + 0xf16a6: 0xf16a6, + 0xf16a7: 0xf16a7, + 0xf16a8: 0xf16a8, + 0xf16a9: 0xf16a9, + 0xf16aa: 0xf16aa, + 0xf16ab: 0xf16ab, + 0xf16ac: 0xf16ac, + 0xf16ad: 0xf16ad, + 0xf16ae: 0xf16ae, + 0xf16af: 0xf16af, + 0xf16b0: 0xf16b0, + 0xf16b1: 0xf16b1, + 0xf16b2: 0xf16b2, + 0xf16b3: 0xf16b3, + 0xf16b4: 0xf16b4, + 0xf16b5: 0xf16b5, + 0xf16b6: 0xf16b6, + 0xf16b7: 0xf16b7, + 0xf16b8: 0xf16b8, + 0xf16b9: 0xf16b9, + 0xf16ba: 0xf16ba, + 0xf16bb: 0xf16bb, + 0xf16bc: 0xf16bc, + 0xf16bd: 0xf16bd, + 0xf16be: 0xf16be, + 0xf16bf: 0xf16bf, + 0xf16c0: 0xf16c0, + 0xf16c1: 0xf16c1, + 0xf16c2: 0xf16c2, + 0xf16c3: 0xf16c3, + 0xf16c4: 0xf16c4, + 0xf16c5: 0xf16c5, + 0xf16c6: 0xf16c6, + 0xf16c7: 0xf16c7, + 0xf16c8: 0xf16c8, + 0xf16c9: 0xf16c9, + 0xf16ca: 0xf16ca, + 0xf16cb: 0xf16cb, + 0xf16cc: 0xf16cc, + 0xf16cd: 0xf16cd, + 0xf16ce: 0xf16ce, + 0xf16cf: 0xf16cf, + 0xf16d0: 0xf16d0, + 0xf16d1: 0xf16d1, + 0xf16d2: 0xf16d2, + 0xf16d3: 0xf16d3, + 0xf16d4: 0xf16d4, + 0xf16d5: 0xf16d5, + 0xf16d6: 0xf16d6, + 0xf16d7: 0xf16d7, + 0xf16d8: 0xf16d8, + 0xf16d9: 0xf16d9, + 0xf16da: 0xf16da, + 0xf16db: 0xf16db, + 0xf16dc: 0xf16dc, + 0xf16dd: 0xf16dd, + 0xf16de: 0xf16de, + 0xf16df: 0xf16df, + 0xf16e0: 0xf16e0, + 0xf16e1: 0xf16e1, + 0xf16e2: 0xf16e2, + 0xf16e3: 0xf16e3, + 0xf16e4: 0xf16e4, + 0xf16e5: 0xf16e5, + 0xf16e6: 0xf16e6, + 0xf16e7: 0xf16e7, + 0xf16e8: 0xf16e8, + 0xf16e9: 0xf16e9, + 0xf16ea: 0xf16ea, + 0xf16eb: 0xf16eb, + 0xf16ec: 0xf16ec, + 0xf16ed: 0xf16ed, + 0xf16ee: 0xf16ee, + 0xf16ef: 0xf16ef, + 0xf16f0: 0xf16f0, + 0xf16f1: 0xf16f1, + 0xf16f2: 0xf16f2, + 0xf16f3: 0xf16f3, + 0xf16f4: 0xf16f4, + 0xf16f5: 0xf16f5, + 0xf16f6: 0xf16f6, + 0xf16f7: 0xf16f7, + 0xf16f8: 0xf16f8, + 0xf16f9: 0xf16f9, + 0xf16fa: 0xf16fa, + 0xf16fb: 0xf16fb, + 0xf16fc: 0xf16fc, + 0xf16fd: 0xf16fd, + 0xf16fe: 0xf16fe, + 0xf16ff: 0xf16ff, + 0xf1700: 0xf1700, + 0xf1701: 0xf1701, + 0xf1702: 0xf1702, + 0xf1703: 0xf1703, + 0xf1704: 0xf1704, + 0xf1705: 0xf1705, + 0xf1706: 0xf1706, + 0xf1707: 0xf1707, + 0xf1708: 0xf1708, + 0xf1709: 0xf1709, + 0xf170a: 0xf170a, + 0xf170b: 0xf170b, + 0xf170c: 0xf170c, + 0xf170d: 0xf170d, + 0xf170e: 0xf170e, + 0xf170f: 0xf170f, + 0xf1710: 0xf1710, + 0xf1711: 0xf1711, + 0xf1712: 0xf1712, + 0xf1713: 0xf1713, + 0xf1714: 0xf1714, + 0xf1715: 0xf1715, + 0xf1716: 0xf1716, + 0xf1717: 0xf1717, + 0xf1718: 0xf1718, + 0xf1719: 0xf1719, + 0xf171a: 0xf171a, + 0xf171b: 0xf171b, + 0xf171c: 0xf171c, + 0xf171d: 0xf171d, + 0xf171e: 0xf171e, + 0xf171f: 0xf171f, + 0xf1720: 0xf1720, + 0xf1721: 0xf1721, + 0xf1722: 0xf1722, + 0xf1723: 0xf1723, + 0xf1724: 0xf1724, + 0xf1725: 0xf1725, + 0xf1726: 0xf1726, + 0xf1727: 0xf1727, + 0xf1728: 0xf1728, + 0xf1729: 0xf1729, + 0xf172a: 0xf172a, + 0xf172b: 0xf172b, + 0xf172c: 0xf172c, + 0xf172d: 0xf172d, + 0xf172e: 0xf172e, + 0xf172f: 0xf172f, + 0xf1730: 0xf1730, + 0xf1731: 0xf1731, + 0xf1732: 0xf1732, + 0xf1733: 0xf1733, + 0xf1734: 0xf1734, + 0xf1735: 0xf1735, + 0xf1736: 0xf1736, + 0xf1737: 0xf1737, + 0xf1738: 0xf1738, + 0xf1739: 0xf1739, + 0xf173a: 0xf173a, + 0xf173b: 0xf173b, + 0xf173c: 0xf173c, + 0xf173d: 0xf173d, + 0xf173e: 0xf173e, + 0xf173f: 0xf173f, + 0xf1740: 0xf1740, + 0xf1741: 0xf1741, + 0xf1742: 0xf1742, + 0xf1743: 0xf1743, + 0xf1744: 0xf1744, + 0xf1745: 0xf1745, + 0xf1746: 0xf1746, + 0xf1747: 0xf1747, + 0xf1748: 0xf1748, + 0xf1749: 0xf1749, + 0xf174a: 0xf174a, + 0xf174b: 0xf174b, + 0xf174c: 0xf174c, + 0xf174d: 0xf174d, + 0xf174e: 0xf174e, + 0xf174f: 0xf174f, + 0xf1750: 0xf1750, + 0xf1751: 0xf1751, + 0xf1752: 0xf1752, + 0xf1753: 0xf1753, + 0xf1754: 0xf1754, + 0xf1755: 0xf1755, + 0xf1756: 0xf1756, + 0xf1757: 0xf1757, + 0xf1758: 0xf1758, + 0xf1759: 0xf1759, + 0xf175a: 0xf175a, + 0xf175b: 0xf175b, + 0xf175c: 0xf175c, + 0xf175d: 0xf175d, + 0xf175e: 0xf175e, + 0xf175f: 0xf175f, + 0xf1760: 0xf1760, + 0xf1761: 0xf1761, + 0xf1762: 0xf1762, + 0xf1763: 0xf1763, + 0xf1764: 0xf1764, + 0xf1765: 0xf1765, + 0xf1766: 0xf1766, + 0xf1767: 0xf1767, + 0xf1768: 0xf1768, + 0xf1769: 0xf1769, + 0xf176a: 0xf176a, + 0xf176b: 0xf176b, + 0xf176c: 0xf176c, + 0xf176d: 0xf176d, + 0xf176e: 0xf176e, + 0xf176f: 0xf176f, + 0xf1770: 0xf1770, + 0xf1771: 0xf1771, + 0xf1772: 0xf1772, + 0xf1773: 0xf1773, + 0xf1774: 0xf1774, + 0xf1775: 0xf1775, + 0xf1776: 0xf1776, + 0xf1777: 0xf1777, + 0xf1778: 0xf1778, + 0xf1779: 0xf1779, + 0xf177a: 0xf177a, + 0xf177b: 0xf177b, + 0xf177c: 0xf177c, + 0xf177d: 0xf177d, + 0xf177e: 0xf177e, + 0xf177f: 0xf177f, + 0xf1780: 0xf1780, + 0xf1781: 0xf1781, + 0xf1782: 0xf1782, + 0xf1783: 0xf1783, + 0xf1784: 0xf1784, + 0xf1785: 0xf1785, + 0xf1786: 0xf1786, + 0xf1787: 0xf1787, + 0xf1788: 0xf1788, + 0xf1789: 0xf1789, + 0xf178a: 0xf178a, + 0xf178b: 0xf178b, + 0xf178c: 0xf178c, + 0xf178d: 0xf178d, + 0xf178e: 0xf178e, + 0xf178f: 0xf178f, + 0xf1790: 0xf1790, + 0xf1791: 0xf1791, + 0xf1792: 0xf1792, + 0xf1793: 0xf1793, + 0xf1794: 0xf1794, + 0xf1795: 0xf1795, + 0xf1796: 0xf1796, + 0xf1797: 0xf1797, + 0xf1798: 0xf1798, + 0xf1799: 0xf1799, + 0xf179a: 0xf179a, + 0xf179b: 0xf179b, + 0xf179c: 0xf179c, + 0xf179d: 0xf179d, + 0xf179e: 0xf179e, + 0xf179f: 0xf179f, + 0xf17a0: 0xf17a0, + 0xf17a1: 0xf17a1, + 0xf17a2: 0xf17a2, + 0xf17a3: 0xf17a3, + 0xf17a4: 0xf17a4, + 0xf17a5: 0xf17a5, + 0xf17a6: 0xf17a6, + 0xf17a7: 0xf17a7, + 0xf17a8: 0xf17a8, + 0xf17a9: 0xf17a9, + 0xf17aa: 0xf17aa, + 0xf17ab: 0xf17ab, + 0xf17ac: 0xf17ac, + 0xf17ad: 0xf17ad, + 0xf17ae: 0xf17ae, + 0xf17af: 0xf17af, + 0xf17b0: 0xf17b0, + 0xf17b1: 0xf17b1, + 0xf17b2: 0xf17b2, + 0xf17b3: 0xf17b3, + 0xf17b4: 0xf17b4, + 0xf17b5: 0xf17b5, + 0xf17b6: 0xf17b6, + 0xf17b7: 0xf17b7, + 0xf17b8: 0xf17b8, + 0xf17b9: 0xf17b9, + 0xf17ba: 0xf17ba, + 0xf17bb: 0xf17bb, + 0xf17bc: 0xf17bc, + 0xf17bd: 0xf17bd, + 0xf17be: 0xf17be, + 0xf17bf: 0xf17bf, + 0xf17c0: 0xf17c0, + 0xf17c1: 0xf17c1, + 0xf17c2: 0xf17c2, + 0xf17c3: 0xf17c3, + 0xf17c4: 0xf17c4, + 0xf17c5: 0xf17c5, + 0xf17c6: 0xf17c6, + 0xf17c7: 0xf17c7, + 0xf17c8: 0xf17c8, + 0xf17c9: 0xf17c9, + 0xf17ca: 0xf17ca, + 0xf17cb: 0xf17cb, + 0xf17cc: 0xf17cc, + 0xf17cd: 0xf17cd, + 0xf17ce: 0xf17ce, + 0xf17cf: 0xf17cf, + 0xf17d0: 0xf17d0, + 0xf17d1: 0xf17d1, + 0xf17d2: 0xf17d2, + 0xf17d3: 0xf17d3, + 0xf17d4: 0xf17d4, + 0xf17d5: 0xf17d5, + 0xf17d6: 0xf17d6, + 0xf17d7: 0xf17d7, + 0xf17d8: 0xf17d8, + 0xf17d9: 0xf17d9, + 0xf17da: 0xf17da, + 0xf17db: 0xf17db, + 0xf17dc: 0xf17dc, + 0xf17dd: 0xf17dd, + 0xf17de: 0xf17de, + 0xf17df: 0xf17df, + 0xf17e0: 0xf17e0, + 0xf17e1: 0xf17e1, + 0xf17e2: 0xf17e2, + 0xf17e3: 0xf17e3, + 0xf17e4: 0xf17e4, + 0xf17e5: 0xf17e5, + 0xf17e6: 0xf17e6, + 0xf17e7: 0xf17e7, + 0xf17e8: 0xf17e8, + 0xf17e9: 0xf17e9, + 0xf17ea: 0xf17ea, + 0xf17eb: 0xf17eb, + 0xf17ec: 0xf17ec, + 0xf17ed: 0xf17ed, + 0xf17ee: 0xf17ee, + 0xf17ef: 0xf17ef, + 0xf17f0: 0xf17f0, + 0xf17f1: 0xf17f1, + 0xf17f2: 0xf17f2, + 0xf17f3: 0xf17f3, + 0xf17f4: 0xf17f4, + 0xf17f5: 0xf17f5, + 0xf17f6: 0xf17f6, + 0xf17f7: 0xf17f7, + 0xf17f8: 0xf17f8, + 0xf17f9: 0xf17f9, + 0xf17fa: 0xf17fa, + 0xf17fb: 0xf17fb, + 0xf17fc: 0xf17fc, + 0xf17fd: 0xf17fd, + 0xf17fe: 0xf17fe, + 0xf17ff: 0xf17ff, + 0xf1800: 0xf1800, + 0xf1801: 0xf1801, + 0xf1802: 0xf1802, + 0xf1803: 0xf1803, + 0xf1804: 0xf1804, + 0xf1805: 0xf1805, + 0xf1806: 0xf1806, + 0xf1807: 0xf1807, + 0xf1808: 0xf1808, + 0xf1809: 0xf1809, + 0xf180a: 0xf180a, + 0xf180b: 0xf180b, + 0xf180c: 0xf180c, + 0xf180d: 0xf180d, + 0xf180e: 0xf180e, + 0xf180f: 0xf180f, + 0xf1810: 0xf1810, + 0xf1811: 0xf1811, + 0xf1812: 0xf1812, + 0xf1813: 0xf1813, + 0xf1814: 0xf1814, + 0xf1815: 0xf1815, + 0xf1816: 0xf1816, + 0xf1817: 0xf1817, + 0xf1818: 0xf1818, + 0xf1819: 0xf1819, + 0xf181a: 0xf181a, + 0xf181b: 0xf181b, + 0xf181c: 0xf181c, + 0xf181d: 0xf181d, + 0xf181e: 0xf181e, + 0xf181f: 0xf181f, + 0xf1820: 0xf1820, + 0xf1821: 0xf1821, + 0xf1822: 0xf1822, + 0xf1823: 0xf1823, + 0xf1824: 0xf1824, + 0xf1825: 0xf1825, + 0xf1826: 0xf1826, + 0xf1827: 0xf1827, + 0xf1828: 0xf1828, + 0xf1829: 0xf1829, + 0xf182a: 0xf182a, + 0xf182b: 0xf182b, + 0xf182c: 0xf182c, + 0xf182d: 0xf182d, + 0xf182e: 0xf182e, + 0xf182f: 0xf182f, + 0xf1830: 0xf1830, + 0xf1831: 0xf1831, + 0xf1832: 0xf1832, + 0xf1833: 0xf1833, + 0xf1834: 0xf1834, + 0xf1835: 0xf1835, + 0xf1836: 0xf1836, + 0xf1837: 0xf1837, + 0xf1838: 0xf1838, + 0xf1839: 0xf1839, + 0xf183a: 0xf183a, + 0xf183b: 0xf183b, + 0xf183c: 0xf183c, + 0xf183d: 0xf183d, + 0xf183e: 0xf183e, + 0xf183f: 0xf183f, + 0xf1840: 0xf1840, + 0xf1841: 0xf1841, + 0xf1842: 0xf1842, + 0xf1843: 0xf1843, + 0xf1844: 0xf1844, + 0xf1845: 0xf1845, + 0xf1846: 0xf1846, + 0xf1847: 0xf1847, + 0xf1848: 0xf1848, + 0xf1849: 0xf1849, + 0xf184a: 0xf184a, + 0xf184b: 0xf184b, + 0xf184c: 0xf184c, + 0xf184d: 0xf184d, + 0xf184e: 0xf184e, + 0xf184f: 0xf184f, + 0xf1850: 0xf1850, + 0xf1851: 0xf1851, + 0xf1852: 0xf1852, + 0xf1853: 0xf1853, + 0xf1854: 0xf1854, + 0xf1855: 0xf1855, + 0xf1856: 0xf1856, + 0xf1857: 0xf1857, + 0xf1858: 0xf1858, + 0xf1859: 0xf1859, + 0xf185a: 0xf185a, + 0xf185b: 0xf185b, + 0xf185c: 0xf185c, + 0xf185d: 0xf185d, + 0xf185e: 0xf185e, + 0xf185f: 0xf185f, + 0xf1860: 0xf1860, + 0xf1861: 0xf1861, + 0xf1862: 0xf1862, + 0xf1863: 0xf1863, + 0xf1864: 0xf1864, + 0xf1865: 0xf1865, + 0xf1866: 0xf1866, + 0xf1867: 0xf1867, + 0xf1868: 0xf1868, + 0xf1869: 0xf1869, + 0xf186a: 0xf186a, + 0xf186b: 0xf186b, + 0xf186c: 0xf186c, + 0xf186d: 0xf186d, + 0xf186e: 0xf186e, + 0xf186f: 0xf186f, + 0xf1870: 0xf1870, + 0xf1871: 0xf1871, + 0xf1872: 0xf1872, + 0xf1873: 0xf1873, + 0xf1874: 0xf1874, + 0xf1875: 0xf1875, + 0xf1876: 0xf1876, + 0xf1877: 0xf1877, + 0xf1878: 0xf1878, + 0xf1879: 0xf1879, + 0xf187a: 0xf187a, + 0xf187b: 0xf187b, + 0xf187c: 0xf187c, + 0xf187d: 0xf187d, + 0xf187e: 0xf187e, + 0xf187f: 0xf187f, + 0xf1880: 0xf1880, + 0xf1881: 0xf1881, + 0xf1882: 0xf1882, + 0xf1883: 0xf1883, + 0xf1884: 0xf1884, + 0xf1885: 0xf1885, + 0xf1886: 0xf1886, + 0xf1887: 0xf1887, + 0xf1888: 0xf1888, + 0xf1889: 0xf1889, + 0xf188a: 0xf188a, + 0xf188b: 0xf188b, + 0xf188c: 0xf188c, + 0xf188d: 0xf188d, + 0xf188e: 0xf188e, + 0xf188f: 0xf188f, + 0xf1890: 0xf1890, + 0xf1891: 0xf1891, + 0xf1892: 0xf1892, + 0xf1893: 0xf1893, + 0xf1894: 0xf1894, + 0xf1895: 0xf1895, + 0xf1896: 0xf1896, + 0xf1897: 0xf1897, + 0xf1898: 0xf1898, + 0xf1899: 0xf1899, + 0xf189a: 0xf189a, + 0xf189b: 0xf189b, + 0xf189c: 0xf189c, + 0xf189d: 0xf189d, + 0xf189e: 0xf189e, + 0xf189f: 0xf189f, + 0xf18a0: 0xf18a0, + 0xf18a1: 0xf18a1, + 0xf18a2: 0xf18a2, + 0xf18a3: 0xf18a3, + 0xf18a4: 0xf18a4, + 0xf18a5: 0xf18a5, + 0xf18a6: 0xf18a6, + 0xf18a7: 0xf18a7, + 0xf18a8: 0xf18a8, + 0xf18a9: 0xf18a9, + 0xf18aa: 0xf18aa, + 0xf18ab: 0xf18ab, + 0xf18ac: 0xf18ac, + 0xf18ad: 0xf18ad, + 0xf18ae: 0xf18ae, + 0xf18af: 0xf18af, + 0xf18b0: 0xf18b0, + 0xf18b1: 0xf18b1, + 0xf18b2: 0xf18b2, + 0xf18b3: 0xf18b3, + 0xf18b4: 0xf18b4, + 0xf18b5: 0xf18b5, + 0xf18b6: 0xf18b6, + 0xf18b7: 0xf18b7, + 0xf18b8: 0xf18b8, + 0xf18b9: 0xf18b9, + 0xf18ba: 0xf18ba, + 0xf18bb: 0xf18bb, + 0xf18bc: 0xf18bc, + 0xf18bd: 0xf18bd, + 0xf18be: 0xf18be, + 0xf18bf: 0xf18bf, + 0xf18c0: 0xf18c0, + 0xf18c1: 0xf18c1, + 0xf18c2: 0xf18c2, + 0xf18c3: 0xf18c3, + 0xf18c4: 0xf18c4, + 0xf18c5: 0xf18c5, + 0xf18c6: 0xf18c6, + 0xf18c7: 0xf18c7, + 0xf18c8: 0xf18c8, + 0xf18c9: 0xf18c9, + 0xf18ca: 0xf18ca, + 0xf18cb: 0xf18cb, + 0xf18cc: 0xf18cc, + 0xf18cd: 0xf18cd, + 0xf18ce: 0xf18ce, + 0xf18cf: 0xf18cf, + 0xf18d0: 0xf18d0, + 0xf18d1: 0xf18d1, + 0xf18d2: 0xf18d2, + 0xf18d3: 0xf18d3, + 0xf18d4: 0xf18d4, + 0xf18d5: 0xf18d5, + 0xf18d6: 0xf18d6, + 0xf18d7: 0xf18d7, + 0xf18d8: 0xf18d8, + 0xf18d9: 0xf18d9, + 0xf18da: 0xf18da, + 0xf18db: 0xf18db, + 0xf18dc: 0xf18dc, + 0xf18dd: 0xf18dd, + 0xf18de: 0xf18de, + 0xf18df: 0xf18df, + 0xf18e0: 0xf18e0, + 0xf18e1: 0xf18e1, + 0xf18e2: 0xf18e2, + 0xf18e3: 0xf18e3, + 0xf18e4: 0xf18e4, + 0xf18e5: 0xf18e5, + 0xf18e6: 0xf18e6, + 0xf18e7: 0xf18e7, + 0xf18e8: 0xf18e8, + 0xf18e9: 0xf18e9, + 0xf18ea: 0xf18ea, + 0xf18eb: 0xf18eb, + 0xf18ec: 0xf18ec, + 0xf18ed: 0xf18ed, + 0xf18ee: 0xf18ee, + 0xf18ef: 0xf18ef, + 0xf18f0: 0xf18f0, + 0xf18f1: 0xf18f1, + 0xf18f2: 0xf18f2, + 0xf18f3: 0xf18f3, + 0xf18f4: 0xf18f4, + 0xf18f5: 0xf18f5, + 0xf18f6: 0xf18f6, + 0xf18f7: 0xf18f7, + 0xf18f8: 0xf18f8, + 0xf18f9: 0xf18f9, + 0xf18fa: 0xf18fa, + 0xf18fb: 0xf18fb, + 0xf18fc: 0xf18fc, + 0xf18fd: 0xf18fd, + 0xf18fe: 0xf18fe, + 0xf18ff: 0xf18ff, + 0xf1900: 0xf1900, + 0xf1901: 0xf1901, + 0xf1902: 0xf1902, + 0xf1903: 0xf1903, + 0xf1904: 0xf1904, + 0xf1905: 0xf1905, + 0xf1906: 0xf1906, + 0xf1907: 0xf1907, + 0xf1908: 0xf1908, + 0xf1909: 0xf1909, + 0xf190a: 0xf190a, + 0xf190b: 0xf190b, + 0xf190c: 0xf190c, + 0xf190d: 0xf190d, + 0xf190e: 0xf190e, + 0xf190f: 0xf190f, + 0xf1910: 0xf1910, + 0xf1911: 0xf1911, + 0xf1912: 0xf1912, + 0xf1913: 0xf1913, + 0xf1914: 0xf1914, + 0xf1915: 0xf1915, + 0xf1916: 0xf1916, + 0xf1917: 0xf1917, + 0xf1918: 0xf1918, + 0xf1919: 0xf1919, + 0xf191a: 0xf191a, + 0xf191b: 0xf191b, + 0xf191c: 0xf191c, + 0xf191d: 0xf191d, + 0xf191e: 0xf191e, + 0xf191f: 0xf191f, + 0xf1920: 0xf1920, + 0xf1921: 0xf1921, + 0xf1922: 0xf1922, + 0xf1923: 0xf1923, + 0xf1924: 0xf1924, + 0xf1925: 0xf1925, + 0xf1926: 0xf1926, + 0xf1927: 0xf1927, + 0xf1928: 0xf1928, + 0xf1929: 0xf1929, + 0xf192a: 0xf192a, + 0xf192b: 0xf192b, + 0xf192c: 0xf192c, + 0xf192d: 0xf192d, + 0xf192e: 0xf192e, + 0xf192f: 0xf192f, + 0xf1930: 0xf1930, + 0xf1931: 0xf1931, + 0xf1932: 0xf1932, + 0xf1933: 0xf1933, + 0xf1934: 0xf1934, + 0xf1935: 0xf1935, + 0xf1936: 0xf1936, + 0xf1937: 0xf1937, + 0xf1938: 0xf1938, + 0xf1939: 0xf1939, + 0xf193a: 0xf193a, + 0xf193b: 0xf193b, + 0xf193c: 0xf193c, + 0xf193d: 0xf193d, + 0xf193e: 0xf193e, + 0xf193f: 0xf193f, + 0xf1940: 0xf1940, + 0xf1941: 0xf1941, + 0xf1942: 0xf1942, + 0xf1943: 0xf1943, + 0xf1944: 0xf1944, + 0xf1945: 0xf1945, + 0xf1946: 0xf1946, + 0xf1947: 0xf1947, + 0xf1948: 0xf1948, + 0xf1949: 0xf1949, + 0xf194a: 0xf194a, + 0xf194b: 0xf194b, + 0xf194c: 0xf194c, + 0xf194d: 0xf194d, + 0xf194e: 0xf194e, + 0xf194f: 0xf194f, + 0xf1950: 0xf1950, + 0xf1951: 0xf1951, + 0xf1952: 0xf1952, + 0xf1953: 0xf1953, + 0xf1954: 0xf1954, + 0xf1955: 0xf1955, + 0xf1956: 0xf1956, + 0xf1957: 0xf1957, + 0xf1958: 0xf1958, + 0xf1959: 0xf1959, + 0xf195a: 0xf195a, + 0xf195b: 0xf195b, + 0xf195c: 0xf195c, + 0xf195d: 0xf195d, + 0xf195e: 0xf195e, + 0xf195f: 0xf195f, + 0xf1960: 0xf1960, + 0xf1961: 0xf1961, + 0xf1962: 0xf1962, + 0xf1963: 0xf1963, + 0xf1964: 0xf1964, + 0xf1965: 0xf1965, + 0xf1966: 0xf1966, + 0xf1967: 0xf1967, + 0xf1968: 0xf1968, + 0xf1969: 0xf1969, + 0xf196a: 0xf196a, + 0xf196b: 0xf196b, + 0xf196c: 0xf196c, + 0xf196d: 0xf196d, + 0xf196e: 0xf196e, + 0xf196f: 0xf196f, + 0xf1970: 0xf1970, + 0xf1971: 0xf1971, + 0xf1972: 0xf1972, + 0xf1973: 0xf1973, + 0xf1974: 0xf1974, + 0xf1975: 0xf1975, + 0xf1976: 0xf1976, + 0xf1977: 0xf1977, + 0xf1978: 0xf1978, + 0xf1979: 0xf1979, + 0xf197a: 0xf197a, + 0xf197b: 0xf197b, + 0xf197c: 0xf197c, + 0xf197d: 0xf197d, + 0xf197e: 0xf197e, + 0xf197f: 0xf197f, + 0xf1980: 0xf1980, + 0xf1981: 0xf1981, + 0xf1982: 0xf1982, + 0xf1983: 0xf1983, + 0xf1984: 0xf1984, + 0xf1985: 0xf1985, + 0xf1986: 0xf1986, + 0xf1987: 0xf1987, + 0xf1988: 0xf1988, + 0xf1989: 0xf1989, + 0xf198a: 0xf198a, + 0xf198b: 0xf198b, + 0xf198c: 0xf198c, + 0xf198d: 0xf198d, + 0xf198e: 0xf198e, + 0xf198f: 0xf198f, + 0xf1990: 0xf1990, + 0xf1991: 0xf1991, + 0xf1992: 0xf1992, + 0xf1993: 0xf1993, + 0xf1994: 0xf1994, + 0xf1995: 0xf1995, + 0xf1996: 0xf1996, + 0xf1997: 0xf1997, + 0xf1998: 0xf1998, + 0xf1999: 0xf1999, + 0xf199a: 0xf199a, + 0xf199b: 0xf199b, + 0xf199c: 0xf199c, + 0xf199d: 0xf199d, + 0xf199e: 0xf199e, + 0xf199f: 0xf199f, + 0xf19a0: 0xf19a0, + 0xf19a1: 0xf19a1, + 0xf19a2: 0xf19a2, + 0xf19a3: 0xf19a3, + 0xf19a4: 0xf19a4, + 0xf19a5: 0xf19a5, + 0xf19a6: 0xf19a6, + 0xf19a7: 0xf19a7, + 0xf19a8: 0xf19a8, + 0xf19a9: 0xf19a9, + 0xf19aa: 0xf19aa, + 0xf19ab: 0xf19ab, + 0xf19ac: 0xf19ac, + 0xf19ad: 0xf19ad, + 0xf19ae: 0xf19ae, + 0xf19af: 0xf19af, + 0xf19b0: 0xf19b0, + 0xf19b1: 0xf19b1, + 0xf19b2: 0xf19b2, + 0xf19b3: 0xf19b3, + 0xf19b4: 0xf19b4, + 0xf19b5: 0xf19b5, + 0xf19b6: 0xf19b6, + 0xf19b7: 0xf19b7, + 0xf19b8: 0xf19b8, + 0xf19b9: 0xf19b9, + 0xf19ba: 0xf19ba, + 0xf19bb: 0xf19bb, + 0xf19bc: 0xf19bc, + 0xf19bd: 0xf19bd, + 0xf19be: 0xf19be, + 0xf19bf: 0xf19bf, + 0xf19c0: 0xf19c0, + 0xf19c1: 0xf19c1, + 0xf19c2: 0xf19c2, + 0xf19c3: 0xf19c3, + 0xf19c4: 0xf19c4, + 0xf19c5: 0xf19c5, + 0xf19c6: 0xf19c6, + 0xf19c7: 0xf19c7, + 0xf19c8: 0xf19c8, + 0xf19c9: 0xf19c9, + 0xf19ca: 0xf19ca, + 0xf19cb: 0xf19cb, + 0xf19cc: 0xf19cc, + 0xf19cd: 0xf19cd, + 0xf19ce: 0xf19ce, + 0xf19cf: 0xf19cf, + 0xf19d0: 0xf19d0, + 0xf19d1: 0xf19d1, + 0xf19d2: 0xf19d2, + 0xf19d3: 0xf19d3, + 0xf19d4: 0xf19d4, + 0xf19d5: 0xf19d5, + 0xf19d6: 0xf19d6, + 0xf19d7: 0xf19d7, + 0xf19d8: 0xf19d8, + 0xf19d9: 0xf19d9, + 0xf19da: 0xf19da, + 0xf19db: 0xf19db, + 0xf19dc: 0xf19dc, + 0xf19dd: 0xf19dd, + 0xf19de: 0xf19de, + 0xf19df: 0xf19df, + 0xf19e0: 0xf19e0, + 0xf19e1: 0xf19e1, + 0xf19e2: 0xf19e2, + 0xf19e3: 0xf19e3, + 0xf19e4: 0xf19e4, + 0xf19e5: 0xf19e5, + 0xf19e6: 0xf19e6, + 0xf19e7: 0xf19e7, + 0xf19e8: 0xf19e8, + 0xf19e9: 0xf19e9, + 0xf19ea: 0xf19ea, + 0xf19eb: 0xf19eb, + 0xf19ec: 0xf19ec, + 0xf19ed: 0xf19ed, + 0xf19ee: 0xf19ee, + 0xf19ef: 0xf19ef, + 0xf19f0: 0xf19f0, + 0xf19f1: 0xf19f1, + 0xf19f2: 0xf19f2, + 0xf19f3: 0xf19f3, + 0xf19f4: 0xf19f4, + 0xf19f5: 0xf19f5, + 0xf19f6: 0xf19f6, + 0xf19f7: 0xf19f7, + 0xf19f8: 0xf19f8, + 0xf19f9: 0xf19f9, + 0xf19fa: 0xf19fa, + 0xf19fb: 0xf19fb, + 0xf19fc: 0xf19fc, + 0xf19fd: 0xf19fd, + 0xf19fe: 0xf19fe, + 0xf19ff: 0xf19ff, + 0xf1a00: 0xf1a00, + 0xf1a01: 0xf1a01, + 0xf1a02: 0xf1a02, + 0xf1a03: 0xf1a03, + 0xf1a04: 0xf1a04, + 0xf1a05: 0xf1a05, + 0xf1a06: 0xf1a06, + 0xf1a07: 0xf1a07, + 0xf1a08: 0xf1a08, + 0xf1a09: 0xf1a09, + 0xf1a0a: 0xf1a0a, + 0xf1a0b: 0xf1a0b, + 0xf1a0c: 0xf1a0c, + 0xf1a0d: 0xf1a0d, + 0xf1a0e: 0xf1a0e, + 0xf1a0f: 0xf1a0f, + 0xf1a10: 0xf1a10, + 0xf1a11: 0xf1a11, + 0xf1a12: 0xf1a12, + 0xf1a13: 0xf1a13, + 0xf1a14: 0xf1a14, + 0xf1a15: 0xf1a15, + 0xf1a16: 0xf1a16, + 0xf1a17: 0xf1a17, + 0xf1a18: 0xf1a18, + 0xf1a19: 0xf1a19, + 0xf1a1a: 0xf1a1a, + 0xf1a1b: 0xf1a1b, + 0xf1a1c: 0xf1a1c, + 0xf1a1d: 0xf1a1d, + 0xf1a1e: 0xf1a1e, + 0xf1a1f: 0xf1a1f, + 0xf1a20: 0xf1a20, + 0xf1a21: 0xf1a21, + 0xf1a22: 0xf1a22, + 0xf1a23: 0xf1a23, + 0xf1a24: 0xf1a24, + 0xf1a25: 0xf1a25, + 0xf1a26: 0xf1a26, + 0xf1a27: 0xf1a27, + 0xf1a28: 0xf1a28, + 0xf1a29: 0xf1a29, + 0xf1a2a: 0xf1a2a, + 0xf1a2b: 0xf1a2b, + 0xf1a2c: 0xf1a2c, + 0xf1a2d: 0xf1a2d, + 0xf1a2e: 0xf1a2e, + 0xf1a2f: 0xf1a2f, + 0xf1a30: 0xf1a30, + 0xf1a31: 0xf1a31, + 0xf1a32: 0xf1a32, + 0xf1a33: 0xf1a33, + 0xf1a34: 0xf1a34, + 0xf1a35: 0xf1a35, + 0xf1a36: 0xf1a36, + 0xf1a37: 0xf1a37, + 0xf1a38: 0xf1a38, + 0xf1a39: 0xf1a39, + 0xf1a3a: 0xf1a3a, + 0xf1a3b: 0xf1a3b, + 0xf1a3c: 0xf1a3c, + 0xf1a3d: 0xf1a3d, + 0xf1a3e: 0xf1a3e, + 0xf1a3f: 0xf1a3f, + 0xf1a40: 0xf1a40, + 0xf1a41: 0xf1a41, + 0xf1a42: 0xf1a42, + 0xf1a43: 0xf1a43, + 0xf1a44: 0xf1a44, + 0xf1a45: 0xf1a45, + 0xf1a46: 0xf1a46, + 0xf1a47: 0xf1a47, + 0xf1a48: 0xf1a48, + 0xf1a49: 0xf1a49, + 0xf1a4a: 0xf1a4a, + 0xf1a4b: 0xf1a4b, + 0xf1a4c: 0xf1a4c, + 0xf1a4d: 0xf1a4d, + 0xf1a4e: 0xf1a4e, + 0xf1a4f: 0xf1a4f, + 0xf1a50: 0xf1a50, + 0xf1a51: 0xf1a51, + 0xf1a52: 0xf1a52, + 0xf1a53: 0xf1a53, + 0xf1a54: 0xf1a54, + 0xf1a55: 0xf1a55, + 0xf1a56: 0xf1a56, + 0xf1a57: 0xf1a57, + 0xf1a58: 0xf1a58, + 0xf1a59: 0xf1a59, + 0xf1a5a: 0xf1a5a, + 0xf1a5b: 0xf1a5b, + 0xf1a5c: 0xf1a5c, + 0xf1a5d: 0xf1a5d, + 0xf1a5e: 0xf1a5e, + 0xf1a5f: 0xf1a5f, + 0xf1a60: 0xf1a60, + 0xf1a61: 0xf1a61, + 0xf1a62: 0xf1a62, + 0xf1a63: 0xf1a63, + 0xf1a64: 0xf1a64, + 0xf1a65: 0xf1a65, + 0xf1a66: 0xf1a66, + 0xf1a67: 0xf1a67, + 0xf1a68: 0xf1a68, + 0xf1a69: 0xf1a69, + 0xf1a6a: 0xf1a6a, + 0xf1a6b: 0xf1a6b, + 0xf1a6c: 0xf1a6c, + 0xf1a6d: 0xf1a6d, + 0xf1a6e: 0xf1a6e, + 0xf1a6f: 0xf1a6f, + 0xf1a70: 0xf1a70, + 0xf1a71: 0xf1a71, + 0xf1a72: 0xf1a72, + 0xf1a73: 0xf1a73, + 0xf1a74: 0xf1a74, + 0xf1a75: 0xf1a75, + 0xf1a76: 0xf1a76, + 0xf1a77: 0xf1a77, + 0xf1a78: 0xf1a78, + 0xf1a79: 0xf1a79, + 0xf1a7a: 0xf1a7a, + 0xf1a7b: 0xf1a7b, + 0xf1a7c: 0xf1a7c, + 0xf1a7d: 0xf1a7d, + 0xf1a7e: 0xf1a7e, + 0xf1a7f: 0xf1a7f, + 0xf1a80: 0xf1a80, + 0xf1a81: 0xf1a81, + 0xf1a82: 0xf1a82, + 0xf1a83: 0xf1a83, + 0xf1a84: 0xf1a84, + 0xf1a85: 0xf1a85, + 0xf1a86: 0xf1a86, + 0xf1a87: 0xf1a87, + 0xf1a88: 0xf1a88, + 0xf1a89: 0xf1a89, + 0xf1a8a: 0xf1a8a, + 0xf1a8b: 0xf1a8b, + 0xf1a8c: 0xf1a8c, + 0xf1a8d: 0xf1a8d, + 0xf1a8e: 0xf1a8e, + 0xf1a8f: 0xf1a8f, + 0xf1a90: 0xf1a90, + 0xf1a91: 0xf1a91, + 0xf1a92: 0xf1a92, + 0xf1a93: 0xf1a93, + 0xf1a94: 0xf1a94, + 0xf1a95: 0xf1a95, + 0xf1a96: 0xf1a96, + 0xf1a97: 0xf1a97, + 0xf1a98: 0xf1a98, + 0xf1a99: 0xf1a99, + 0xf1a9a: 0xf1a9a, + 0xf1a9b: 0xf1a9b, + 0xf1a9c: 0xf1a9c, + 0xf1a9d: 0xf1a9d, + 0xf1a9e: 0xf1a9e, + 0xf1a9f: 0xf1a9f, + 0xf1aa0: 0xf1aa0, + 0xf1aa1: 0xf1aa1, + 0xf1aa2: 0xf1aa2, + 0xf1aa3: 0xf1aa3, + 0xf1aa4: 0xf1aa4, + 0xf1aa5: 0xf1aa5, + 0xf1aa6: 0xf1aa6, + 0xf1aa7: 0xf1aa7, + 0xf1aa8: 0xf1aa8, + 0xf1aa9: 0xf1aa9, + 0xf1aaa: 0xf1aaa, + 0xf1aab: 0xf1aab, + 0xf1aac: 0xf1aac, + 0xf1aad: 0xf1aad, + 0xf1aae: 0xf1aae, + 0xf1aaf: 0xf1aaf, + 0xf1ab0: 0xf1ab0, + 0xf1ab1: 0xf1ab1, + 0xf1ab2: 0xf1ab2, + 0xf1ab3: 0xf1ab3, + 0xf1ab4: 0xf1ab4, + 0xf1ab5: 0xf1ab5, + 0xf1ab6: 0xf1ab6, + 0xf1ab7: 0xf1ab7, + 0xf1ab8: 0xf1ab8, + 0xf1ab9: 0xf1ab9, + 0xf1aba: 0xf1aba, + 0xf1abb: 0xf1abb, + 0xf1abc: 0xf1abc, + 0xf1abd: 0xf1abd, + 0xf1abe: 0xf1abe, + 0xf1abf: 0xf1abf, + 0xf1ac0: 0xf1ac0, + 0xf1ac1: 0xf1ac1, + 0xf1ac2: 0xf1ac2, + 0xf1ac3: 0xf1ac3, + 0xf1ac4: 0xf1ac4, + 0xf1ac5: 0xf1ac5, + 0xf1ac6: 0xf1ac6, + 0xf1ac7: 0xf1ac7, + 0xf1ac8: 0xf1ac8, + 0xf1ac9: 0xf1ac9, + 0xf1aca: 0xf1aca, + 0xf1acb: 0xf1acb, + 0xf1acc: 0xf1acc, + 0xf1acd: 0xf1acd, + 0xf1ace: 0xf1ace, + 0xf1acf: 0xf1acf, + 0xf1ad0: 0xf1ad0, + 0xf1ad1: 0xf1ad1, + 0xf1ad2: 0xf1ad2, + 0xf1ad3: 0xf1ad3, + 0xf1ad4: 0xf1ad4, + 0xf1ad5: 0xf1ad5, + 0xf1ad6: 0xf1ad6, + 0xf1ad7: 0xf1ad7, + 0xf1ad8: 0xf1ad8, + 0xf1ad9: 0xf1ad9, + 0xf1ada: 0xf1ada, + 0xf1adb: 0xf1adb, + 0xf1adc: 0xf1adc, + 0xf1add: 0xf1add, + 0xf1ade: 0xf1ade, + 0xf1adf: 0xf1adf, + 0xf1ae0: 0xf1ae0, + 0xf1ae1: 0xf1ae1, + 0xf1ae2: 0xf1ae2, + 0xf1ae3: 0xf1ae3, + 0xf1ae4: 0xf1ae4, + 0xf1ae5: 0xf1ae5, + 0xf1ae6: 0xf1ae6, + 0xf1ae7: 0xf1ae7, + 0xf1ae8: 0xf1ae8, + 0xf1ae9: 0xf1ae9, + 0xf1aea: 0xf1aea, + 0xf1aeb: 0xf1aeb, + 0xf1aec: 0xf1aec, + 0xf1aed: 0xf1aed, + 0xf1aee: 0xf1aee, + 0xf1aef: 0xf1aef, + 0xf1af0: 0xf1af0, + }, + "Weather Icons": { + 0xf000: 0xe300, + 0xf001: 0xe301, + 0xf002: 0xe302, + 0xf003: 0xe303, + 0xf004: 0xe304, + 0xf005: 0xe305, + 0xf006: 0xe306, + 0xf007: 0xe307, + 0xf008: 0xe308, + 0xf009: 0xe309, + 0xf00a: 0xe30a, + 0xf00b: 0xe30b, + 0xf00c: 0xe30c, + 0xf00d: 0xe30d, + 0xf00e: 0xe30e, + 0xf010: 0xe30f, + 0xf011: 0xe310, + 0xf012: 0xe311, + 0xf013: 0xe312, + 0xf014: 0xe313, + 0xf015: 0xe314, + 0xf016: 0xe315, + 0xf017: 0xe316, + 0xf018: 0xe317, + 0xf019: 0xe318, + 0xf01a: 0xe319, + 0xf01b: 0xe31a, + 0xf01c: 0xe31b, + 0xf01d: 0xe31c, + 0xf01e: 0xe31d, + 0xf021: 0xe31e, + 0xf022: 0xe31f, + 0xf023: 0xe320, + 0xf024: 0xe321, + 0xf025: 0xe322, + 0xf026: 0xe323, + 0xf027: 0xe324, + 0xf028: 0xe325, + 0xf029: 0xe326, + 0xf02a: 0xe327, + 0xf02b: 0xe328, + 0xf02c: 0xe329, + 0xf02d: 0xe32a, + 0xf02e: 0xe32b, + 0xf02f: 0xe32c, + 0xf030: 0xe32d, + 0xf031: 0xe32e, + 0xf032: 0xe32f, + 0xf033: 0xe330, + 0xf034: 0xe331, + 0xf035: 0xe332, + 0xf036: 0xe333, + 0xf037: 0xe334, + 0xf038: 0xe335, + 0xf039: 0xe336, + 0xf03a: 0xe337, + 0xf03b: 0xe338, + 0xf03c: 0xe339, + 0xf03d: 0xe33a, + 0xf03e: 0xe33b, + 0xf040: 0xe33c, + 0xf041: 0xe33d, + 0xf042: 0xe33e, + 0xf043: 0xe33f, + 0xf044: 0xe340, + 0xf045: 0xe341, + 0xf046: 0xe342, + 0xf047: 0xe343, + 0xf048: 0xe344, + 0xf049: 0xe345, + 0xf04a: 0xe346, + 0xf04b: 0xe347, + 0xf04c: 0xe348, + 0xf04d: 0xe349, + 0xf04e: 0xe34a, + 0xf050: 0xe34b, + 0xf051: 0xe34c, + 0xf052: 0xe34d, + 0xf053: 0xe34e, + 0xf054: 0xe34f, + 0xf055: 0xe350, + 0xf056: 0xe351, + 0xf057: 0xe352, + 0xf058: 0xe353, + 0xf059: 0xe354, + 0xf05a: 0xe355, + 0xf05b: 0xe356, + 0xf05c: 0xe357, + 0xf05d: 0xe358, + 0xf05e: 0xe359, + 0xf060: 0xe35a, + 0xf061: 0xe35b, + 0xf062: 0xe35c, + 0xf063: 0xe35d, + 0xf064: 0xe35e, + 0xf065: 0xe35f, + 0xf066: 0xe360, + 0xf067: 0xe361, + 0xf068: 0xe362, + 0xf069: 0xe363, + 0xf06a: 0xe364, + 0xf06b: 0xe365, + 0xf06c: 0xe366, + 0xf06d: 0xe367, + 0xf06e: 0xe368, + 0xf070: 0xe369, + 0xf071: 0xe36a, + 0xf072: 0xe36b, + 0xf073: 0xe36c, + 0xf074: 0xe36d, + 0xf075: 0xe36e, + 0xf076: 0xe36f, + 0xf077: 0xe370, + 0xf078: 0xe371, + 0xf079: 0xe372, + 0xf07a: 0xe373, + 0xf07b: 0xe374, + 0xf07c: 0xe375, + 0xf07d: 0xe376, + 0xf07e: 0xe377, + 0xf080: 0xe378, + 0xf081: 0xe379, + 0xf082: 0xe37a, + 0xf083: 0xe37b, + 0xf084: 0xe37c, + 0xf085: 0xe37d, + 0xf086: 0xe37e, + 0xf087: 0xe37f, + 0xf088: 0xe380, + 0xf089: 0xe381, + 0xf08a: 0xe382, + 0xf08b: 0xe383, + 0xf08c: 0xe384, + 0xf08d: 0xe385, + 0xf08e: 0xe386, + 0xf08f: 0xe387, + 0xf090: 0xe388, + 0xf091: 0xe389, + 0xf092: 0xe38a, + 0xf093: 0xe38b, + 0xf094: 0xe38c, + 0xf095: 0xe38d, + 0xf096: 0xe38e, + 0xf097: 0xe38f, + 0xf098: 0xe390, + 0xf099: 0xe391, + 0xf09a: 0xe392, + 0xf09b: 0xe393, + 0xf09c: 0xe394, + 0xf09d: 0xe395, + 0xf09e: 0xe396, + 0xf09f: 0xe397, + 0xf0a0: 0xe398, + 0xf0a1: 0xe399, + 0xf0a2: 0xe39a, + 0xf0a3: 0xe39b, + 0xf0a4: 0xe39c, + 0xf0a5: 0xe39d, + 0xf0a6: 0xe39e, + 0xf0a7: 0xe39f, + 0xf0a8: 0xe3a0, + 0xf0a9: 0xe3a1, + 0xf0aa: 0xe3a2, + 0xf0ab: 0xe3a3, + 0xf0ac: 0xe3a4, + 0xf0ad: 0xe3a5, + 0xf0ae: 0xe3a6, + 0xf0af: 0xe3a7, + 0xf0b0: 0xe3a8, + 0xf0b1: 0xe3a9, + 0xf0b2: 0xe3aa, + 0xf0b3: 0xe3ab, + 0xf0b4: 0xe3ac, + 0xf0b5: 0xe3ad, + 0xf0b6: 0xe3ae, + 0xf0b7: 0xe3af, + 0xf0b8: 0xe3b0, + 0xf0b9: 0xe3b1, + 0xf0ba: 0xe3b2, + 0xf0bb: 0xe3b3, + 0xf0bc: 0xe3b4, + 0xf0bd: 0xe3b5, + 0xf0be: 0xe3b6, + 0xf0bf: 0xe3b7, + 0xf0c0: 0xe3b8, + 0xf0c1: 0xe3b9, + 0xf0c2: 0xe3ba, + 0xf0c3: 0xe3bb, + 0xf0c4: 0xe3bc, + 0xf0c5: 0xe3bd, + 0xf0c6: 0xe3be, + 0xf0c7: 0xe3bf, + 0xf0c8: 0xe3c0, + 0xf0c9: 0xe3c1, + 0xf0ca: 0xe3c2, + 0xf0cb: 0xe3c3, + 0xf0cc: 0xe3c4, + 0xf0cd: 0xe3c5, + 0xf0ce: 0xe3c6, + 0xf0cf: 0xe3c7, + 0xf0d0: 0xe3c8, + 0xf0d1: 0xe3c9, + 0xf0d2: 0xe3ca, + 0xf0d3: 0xe3cb, + 0xf0d4: 0xe3cc, + 0xf0d5: 0xe3cd, + 0xf0d6: 0xe3ce, + 0xf0d7: 0xe3cf, + 0xf0d8: 0xe3d0, + 0xf0d9: 0xe3d1, + 0xf0da: 0xe3d2, + 0xf0db: 0xe3d3, + 0xf0dc: 0xe3d4, + 0xf0dd: 0xe3d5, + 0xf0de: 0xe3d6, + 0xf0df: 0xe3d7, + 0xf0e0: 0xe3d8, + 0xf0e1: 0xe3d9, + 0xf0e2: 0xe3da, + 0xf0e3: 0xe3db, + 0xf0e4: 0xe3dc, + 0xf0e5: 0xe3dd, + 0xf0e6: 0xe3de, + 0xf0e7: 0xe3df, + 0xf0e8: 0xe3e0, + 0xf0e9: 0xe3e1, + 0xf0ea: 0xe3e2, + 0xf0eb: 0xe3e3, + }, + "Font Logos": { + 0xf300: 0xf300, + 0xf301: 0xf301, + 0xf302: 0xf302, + 0xf303: 0xf303, + 0xf304: 0xf304, + 0xf305: 0xf305, + 0xf306: 0xf306, + 0xf307: 0xf307, + 0xf308: 0xf308, + 0xf309: 0xf309, + 0xf30a: 0xf30a, + 0xf30b: 0xf30b, + 0xf30c: 0xf30c, + 0xf30d: 0xf30d, + 0xf30e: 0xf30e, + 0xf30f: 0xf30f, + 0xf310: 0xf310, + 0xf311: 0xf311, + 0xf312: 0xf312, + 0xf313: 0xf313, + 0xf314: 0xf314, + 0xf315: 0xf315, + 0xf316: 0xf316, + 0xf317: 0xf317, + 0xf318: 0xf318, + 0xf319: 0xf319, + 0xf31a: 0xf31a, + 0xf31b: 0xf31b, + 0xf31c: 0xf31c, + 0xf31d: 0xf31d, + 0xf31e: 0xf31e, + 0xf31f: 0xf31f, + 0xf320: 0xf320, + 0xf321: 0xf321, + 0xf322: 0xf322, + 0xf323: 0xf323, + 0xf324: 0xf324, + 0xf325: 0xf325, + 0xf326: 0xf326, + 0xf327: 0xf327, + 0xf328: 0xf328, + 0xf329: 0xf329, + 0xf32a: 0xf32a, + 0xf32b: 0xf32b, + 0xf32c: 0xf32c, + 0xf32d: 0xf32d, + 0xf32e: 0xf32e, + 0xf32f: 0xf32f, + 0xf330: 0xf330, + 0xf331: 0xf331, + 0xf332: 0xf332, + 0xf333: 0xf333, + 0xf334: 0xf334, + 0xf335: 0xf335, + 0xf336: 0xf336, + 0xf337: 0xf337, + 0xf338: 0xf338, + 0xf339: 0xf339, + 0xf33a: 0xf33a, + 0xf33b: 0xf33b, + 0xf33c: 0xf33c, + 0xf33d: 0xf33d, + 0xf33e: 0xf33e, + 0xf33f: 0xf33f, + 0xf340: 0xf340, + 0xf341: 0xf341, + 0xf342: 0xf342, + 0xf343: 0xf343, + 0xf344: 0xf344, + 0xf345: 0xf345, + 0xf346: 0xf346, + 0xf347: 0xf347, + 0xf348: 0xf348, + 0xf349: 0xf349, + 0xf34a: 0xf34a, + 0xf34b: 0xf34b, + 0xf34c: 0xf34c, + 0xf34d: 0xf34d, + 0xf34e: 0xf34e, + 0xf34f: 0xf34f, + 0xf350: 0xf350, + 0xf351: 0xf351, + 0xf352: 0xf352, + 0xf353: 0xf353, + 0xf354: 0xf354, + 0xf355: 0xf355, + 0xf356: 0xf356, + 0xf357: 0xf357, + 0xf358: 0xf358, + 0xf359: 0xf359, + 0xf35a: 0xf35a, + 0xf35b: 0xf35b, + 0xf35c: 0xf35c, + 0xf35d: 0xf35d, + 0xf35e: 0xf35e, + 0xf35f: 0xf35f, + 0xf360: 0xf360, + 0xf361: 0xf361, + 0xf362: 0xf362, + 0xf363: 0xf363, + 0xf364: 0xf364, + 0xf365: 0xf365, + 0xf366: 0xf366, + 0xf367: 0xf367, + 0xf368: 0xf368, + 0xf369: 0xf369, + 0xf36a: 0xf36a, + 0xf36b: 0xf36b, + 0xf36c: 0xf36c, + 0xf36d: 0xf36d, + 0xf36e: 0xf36e, + 0xf36f: 0xf36f, + 0xf370: 0xf370, + 0xf371: 0xf371, + 0xf372: 0xf372, + 0xf373: 0xf373, + 0xf374: 0xf374, + 0xf375: 0xf375, + 0xf376: 0xf376, + 0xf377: 0xf377, + 0xf378: 0xf378, + 0xf379: 0xf379, + 0xf37a: 0xf37a, + 0xf37b: 0xf37b, + 0xf37c: 0xf37c, + 0xf37d: 0xf37d, + 0xf37e: 0xf37e, + 0xf37f: 0xf37f, + 0xf380: 0xf380, + 0xf381: 0xf381, + }, + "Octicons": { + 0xf000: 0xf400, + 0xf001: 0xf401, + 0xf002: 0xf402, + 0xf005: 0xf403, + 0xf006: 0xf404, + 0xf007: 0xf405, + 0xf008: 0xf406, + 0xf009: 0xf407, + 0xf00a: 0xf408, + 0xf00b: 0xf409, + 0xf00c: 0xf40a, + 0xf00d: 0xf40b, + 0xf00e: 0xf40c, + 0xf010: 0xf40d, + 0xf011: 0xf40e, + 0xf012: 0xf40f, + 0xf013: 0xf410, + 0xf014: 0xf411, + 0xf015: 0xf412, + 0xf016: 0xf413, + 0xf017: 0xf414, + 0xf018: 0xf415, + 0xf019: 0xf416, + 0xf01f: 0xf417, + 0xf020: 0xf418, + 0xf023: 0xf419, + 0xf024: 0xf41a, + 0xf026: 0xf41b, + 0xf027: 0xf41c, + 0xf028: 0xf41d, + 0xf02a: 0xf41e, + 0xf02b: 0xf41f, + 0xf02c: 0xf420, + 0xf02d: 0xf421, + 0xf02e: 0xf422, + 0xf02f: 0xf423, + 0xf030: 0xf424, + 0xf031: 0xf425, + 0xf032: 0xf426, + 0xf033: 0xf427, + 0xf034: 0xf428, + 0xf035: 0xf429, + 0xf036: 0xf42a, + 0xf037: 0xf42b, + 0xf038: 0xf42c, + 0xf039: 0xf42d, + 0xf03a: 0xf42e, + 0xf03b: 0xf42f, + 0xf03c: 0xf430, + 0xf03d: 0xf431, + 0xf03e: 0xf432, + 0xf03f: 0xf433, + 0xf040: 0xf434, + 0xf041: 0xf435, + 0xf042: 0xf436, + 0xf043: 0xf437, + 0xf044: 0xf438, + 0xf045: 0xf439, + 0xf046: 0xf43a, + 0xf047: 0xf43b, + 0xf048: 0xf43c, + 0xf049: 0xf43d, + 0xf04a: 0xf43e, + 0xf04c: 0xf43f, + 0xf04d: 0xf440, + 0xf04e: 0xf441, + 0xf04f: 0xf442, + 0xf051: 0xf443, + 0xf052: 0xf444, + 0xf053: 0xf445, + 0xf056: 0xf446, + 0xf057: 0xf447, + 0xf058: 0xf448, + 0xf059: 0xf449, + 0xf05a: 0xf44a, + 0xf05b: 0xf44b, + 0xf05c: 0xf44c, + 0xf05d: 0xf44d, + 0xf05e: 0xf44e, + 0xf05f: 0xf44f, + 0xf060: 0xf450, + 0xf061: 0xf451, + 0xf062: 0xf452, + 0xf063: 0xf453, + 0xf064: 0xf454, + 0xf068: 0xf455, + 0xf06a: 0xf456, + 0xf06b: 0xf457, + 0xf06c: 0xf458, + 0xf06d: 0xf459, + 0xf06e: 0xf45a, + 0xf070: 0xf45b, + 0xf071: 0xf45c, + 0xf075: 0xf45d, + 0xf076: 0xf45e, + 0xf077: 0xf45f, + 0xf078: 0xf460, + 0xf07b: 0xf461, + 0xf07c: 0xf462, + 0xf07d: 0xf463, + 0xf07e: 0xf464, + 0xf07f: 0xf465, + 0xf080: 0xf466, + 0xf081: 0xf467, + 0xf084: 0xf468, + 0xf085: 0xf469, + 0xf087: 0xf46a, + 0xf088: 0xf46b, + 0xf08c: 0xf46c, + 0xf08d: 0xf46d, + 0xf08f: 0xf46e, + 0xf091: 0xf46f, + 0xf092: 0xf470, + 0xf094: 0xf471, + 0xf096: 0xf472, + 0xf097: 0xf473, + 0xf099: 0xf474, + 0xf09a: 0xf475, + 0xf09c: 0xf476, + 0xf09d: 0xf477, + 0xf09f: 0xf478, + 0xf0a0: 0xf479, + 0xf0a1: 0xf47a, + 0xf0a2: 0xf47b, + 0xf0a3: 0xf47c, + 0xf0a4: 0xf47d, + 0xf0aa: 0xf47e, + 0xf0ac: 0xf47f, + 0xf0ad: 0xf480, + 0xf0b0: 0xf481, + 0xf0b1: 0xf482, + 0xf0b2: 0xf483, + 0xf0b6: 0xf484, + 0xf0ba: 0xf485, + 0xf0be: 0xf486, + 0xf0c4: 0xf487, + 0xf0c5: 0xf488, + 0xf0c8: 0xf489, + 0xf0c9: 0xf48a, + 0xf0ca: 0xf48b, + 0xf0cc: 0xf48c, + 0xf0cf: 0xf48d, + 0xf0d0: 0xf48e, + 0xf0d1: 0xf48f, + 0xf0d2: 0xf490, + 0xf0d3: 0xf491, + 0xf0d4: 0xf492, + 0xf0d6: 0xf493, + 0xf0d7: 0xf494, + 0xf0d8: 0xf495, + 0xf0da: 0xf496, + 0xf0db: 0xf497, + 0xf0dc: 0xf498, + 0xf0dd: 0xf499, + 0xf0de: 0xf49a, + 0xf0e0: 0xf49b, + 0xf0e1: 0xf49c, + 0xf0e2: 0xf49d, + 0xf0e3: 0xf49e, + 0xf0e4: 0xf49f, + 0xf0e5: 0xf4a0, + 0xf0e6: 0xf4a1, + 0xf0e7: 0xf4a2, + 0xf0e8: 0xf4a3, + 0xf101: 0xf4a4, + 0xf102: 0xf4a5, + 0xf103: 0xf4a6, + 0xf104: 0xf4a7, + 0xf105: 0xf4a8, + 0x2665: 0x2665, + 0x26a1: 0x26a1, + 0xf27c: 0xf4a9, + 0xf27d: 0xf4aa, + 0xf27e: 0xf4ab, + 0xf27f: 0xf4ac, + 0xf280: 0xf4ad, + 0xf281: 0xf4ae, + 0xf282: 0xf4af, + 0xf283: 0xf4b0, + 0xf284: 0xf4b1, + 0xf285: 0xf4b2, + 0xf286: 0xf4b3, + 0xf287: 0xf4b4, + 0xf288: 0xf4b5, + 0xf289: 0xf4b6, + 0xf28a: 0xf4b7, + 0xf28b: 0xf4b8, + 0xf28c: 0xf4b9, + 0xf28d: 0xf4ba, + 0xf28e: 0xf4bb, + 0xf28f: 0xf4bc, + 0xf290: 0xf4bd, + 0xf291: 0xf4be, + 0xf292: 0xf4bf, + 0xf293: 0xf4c0, + 0xf294: 0xf4c1, + 0xf295: 0xf4c2, + 0xf296: 0xf4c3, + 0xf297: 0xf4c4, + 0xf298: 0xf4c5, + 0xf299: 0xf4c6, + 0xf29a: 0xf4c7, + 0xf29b: 0xf4c8, + 0xf29c: 0xf4c9, + 0xf29d: 0xf4ca, + 0xf29e: 0xf4cb, + 0xf29f: 0xf4cc, + 0xf2a0: 0xf4cd, + 0xf2a1: 0xf4ce, + 0xf2a2: 0xf4cf, + 0xf2a3: 0xf4d0, + 0xf2a4: 0xf4d1, + 0xf2a5: 0xf4d2, + 0xf2a6: 0xf4d3, + 0xf2a7: 0xf4d4, + 0xf2a8: 0xf4d5, + 0xf2a9: 0xf4d6, + 0xf2aa: 0xf4d7, + 0xf2ab: 0xf4d8, + 0xf2ac: 0xf4d9, + 0xf2ad: 0xf4da, + 0xf2ae: 0xf4db, + 0xf2af: 0xf4dc, + 0xf2b0: 0xf4dd, + 0xf2b1: 0xf4de, + 0xf2b2: 0xf4df, + 0xf2b3: 0xf4e0, + 0xf2b4: 0xf4e1, + 0xf2b5: 0xf4e2, + 0xf2b6: 0xf4e3, + 0xf2b7: 0xf4e4, + 0xf2b8: 0xf4e5, + 0xf2b9: 0xf4e6, + 0xf2ba: 0xf4e7, + 0xf2bb: 0xf4e8, + 0xf2bc: 0xf4e9, + 0xf2bd: 0xf4ea, + 0xf2be: 0xf4eb, + 0xf2bf: 0xf4ec, + 0xf2c0: 0xf4ed, + 0xf2c1: 0xf4ee, + 0xf2c2: 0xf4ef, + 0xf2c3: 0xf4f0, + 0xf2c4: 0xf4f1, + 0xf2c5: 0xf4f2, + 0xf2c6: 0xf4f3, + 0xf2c7: 0xf4f4, + 0xf2c8: 0xf4f5, + 0xf2c9: 0xf4f6, + 0xf2ca: 0xf4f7, + 0xf2cb: 0xf4f8, + 0xf2cc: 0xf4f9, + 0xf2cd: 0xf4fa, + 0xf2ce: 0xf4fb, + 0xf2cf: 0xf4fc, + 0xf2d0: 0xf4fd, + 0xf2d1: 0xf4fe, + 0xf2d2: 0xf4ff, + 0xf2d3: 0xf500, + 0xf2d4: 0xf501, + 0xf2d5: 0xf502, + 0xf2d6: 0xf503, + 0xf2d7: 0xf504, + 0xf2d8: 0xf505, + 0xf2d9: 0xf506, + 0xf2da: 0xf507, + 0xf2db: 0xf508, + 0xf2dc: 0xf509, + 0xf2dd: 0xf50a, + 0xf2de: 0xf50b, + 0xf2df: 0xf50c, + 0xf2e0: 0xf50d, + 0xf2e1: 0xf50e, + 0xf2e2: 0xf50f, + 0xf2e3: 0xf510, + 0xf2e4: 0xf511, + 0xf2e5: 0xf512, + 0xf2e6: 0xf513, + 0xf2e7: 0xf514, + 0xf2e8: 0xf515, + 0xf2e9: 0xf516, + 0xf2ea: 0xf517, + 0xf2eb: 0xf518, + 0xf2ec: 0xf519, + 0xf2ed: 0xf51a, + 0xf2ee: 0xf51b, + 0xf2ef: 0xf51c, + 0xf2f0: 0xf51d, + 0xf2f1: 0xf51e, + 0xf2f2: 0xf51f, + 0xf2f3: 0xf520, + 0xf2f4: 0xf521, + 0xf2f5: 0xf522, + 0xf2f6: 0xf523, + 0xf2f7: 0xf524, + 0xf2f8: 0xf525, + 0xf2f9: 0xf526, + 0xf2fa: 0xf527, + 0xf2fb: 0xf528, + 0xf2fc: 0xf529, + 0xf2fd: 0xf52a, + 0xf2fe: 0xf52b, + 0xf2ff: 0xf52c, + 0xf300: 0xf52d, + 0xf301: 0xf52e, + 0xf302: 0xf52f, + 0xf303: 0xf530, + 0xf304: 0xf531, + 0xf305: 0xf532, + 0xf306: 0xf533, + }, + "Codicons": { + 0xea60: 0xea60, + 0xea61: 0xea61, + 0xea62: 0xea62, + 0xea63: 0xea63, + 0xea64: 0xea64, + 0xea65: 0xea65, + 0xea66: 0xea66, + 0xea67: 0xea67, + 0xea68: 0xea68, + 0xea69: 0xea69, + 0xea6a: 0xea6a, + 0xea6b: 0xea6b, + 0xea6c: 0xea6c, + 0xea6d: 0xea6d, + 0xea6e: 0xea6e, + 0xea6f: 0xea6f, + 0xea70: 0xea70, + 0xea71: 0xea71, + 0xea72: 0xea72, + 0xea73: 0xea73, + 0xea74: 0xea74, + 0xea75: 0xea75, + 0xea76: 0xea76, + 0xea77: 0xea77, + 0xea78: 0xea78, + 0xea79: 0xea79, + 0xea7a: 0xea7a, + 0xea7b: 0xea7b, + 0xea7c: 0xea7c, + 0xea7d: 0xea7d, + 0xea7e: 0xea7e, + 0xea7f: 0xea7f, + 0xea80: 0xea80, + 0xea81: 0xea81, + 0xea82: 0xea82, + 0xea83: 0xea83, + 0xea84: 0xea84, + 0xea85: 0xea85, + 0xea86: 0xea86, + 0xea87: 0xea87, + 0xea88: 0xea88, + 0xea8a: 0xea8a, + 0xea8b: 0xea8b, + 0xea8c: 0xea8c, + 0xea8f: 0xea8f, + 0xea90: 0xea90, + 0xea91: 0xea91, + 0xea92: 0xea92, + 0xea93: 0xea93, + 0xea94: 0xea94, + 0xea95: 0xea95, + 0xea96: 0xea96, + 0xea97: 0xea97, + 0xea98: 0xea98, + 0xea99: 0xea99, + 0xea9a: 0xea9a, + 0xea9b: 0xea9b, + 0xea9c: 0xea9c, + 0xea9d: 0xea9d, + 0xea9e: 0xea9e, + 0xea9f: 0xea9f, + 0xeaa0: 0xeaa0, + 0xeaa1: 0xeaa1, + 0xeaa2: 0xeaa2, + 0xeaa3: 0xeaa3, + 0xeaa4: 0xeaa4, + 0xeaa5: 0xeaa5, + 0xeaa6: 0xeaa6, + 0xeaa7: 0xeaa7, + 0xeaa8: 0xeaa8, + 0xeaa9: 0xeaa9, + 0xeaaa: 0xeaaa, + 0xeaab: 0xeaab, + 0xeaac: 0xeaac, + 0xeaad: 0xeaad, + 0xeaae: 0xeaae, + 0xeaaf: 0xeaaf, + 0xeab0: 0xeab0, + 0xeab1: 0xeab1, + 0xeab2: 0xeab2, + 0xeab3: 0xeab3, + 0xeab4: 0xeab4, + 0xeab5: 0xeab5, + 0xeab6: 0xeab6, + 0xeab7: 0xeab7, + 0xeab8: 0xeab8, + 0xeab9: 0xeab9, + 0xeaba: 0xeaba, + 0xeabb: 0xeabb, + 0xeabc: 0xeabc, + 0xeabd: 0xeabd, + 0xeabe: 0xeabe, + 0xeabf: 0xeabf, + 0xeac0: 0xeac0, + 0xeac1: 0xeac1, + 0xeac2: 0xeac2, + 0xeac3: 0xeac3, + 0xeac4: 0xeac4, + 0xeac5: 0xeac5, + 0xeac6: 0xeac6, + 0xeac7: 0xeac7, + 0xeac9: 0xeac9, + 0xeacc: 0xeacc, + 0xeacd: 0xeacd, + 0xeace: 0xeace, + 0xeacf: 0xeacf, + 0xead0: 0xead0, + 0xead1: 0xead1, + 0xead2: 0xead2, + 0xead3: 0xead3, + 0xead4: 0xead4, + 0xead5: 0xead5, + 0xead6: 0xead6, + 0xead7: 0xead7, + 0xead8: 0xead8, + 0xead9: 0xead9, + 0xeada: 0xeada, + 0xeadb: 0xeadb, + 0xeadc: 0xeadc, + 0xeadd: 0xeadd, + 0xeade: 0xeade, + 0xeadf: 0xeadf, + 0xeae0: 0xeae0, + 0xeae1: 0xeae1, + 0xeae2: 0xeae2, + 0xeae3: 0xeae3, + 0xeae4: 0xeae4, + 0xeae5: 0xeae5, + 0xeae6: 0xeae6, + 0xeae7: 0xeae7, + 0xeae8: 0xeae8, + 0xeae9: 0xeae9, + 0xeaea: 0xeaea, + 0xeaeb: 0xeaeb, + 0xeaec: 0xeaec, + 0xeaed: 0xeaed, + 0xeaee: 0xeaee, + 0xeaef: 0xeaef, + 0xeaf0: 0xeaf0, + 0xeaf1: 0xeaf1, + 0xeaf2: 0xeaf2, + 0xeaf3: 0xeaf3, + 0xeaf4: 0xeaf4, + 0xeaf5: 0xeaf5, + 0xeaf6: 0xeaf6, + 0xeaf7: 0xeaf7, + 0xeaf8: 0xeaf8, + 0xeaf9: 0xeaf9, + 0xeafa: 0xeafa, + 0xeafb: 0xeafb, + 0xeafc: 0xeafc, + 0xeafd: 0xeafd, + 0xeafe: 0xeafe, + 0xeaff: 0xeaff, + 0xeb00: 0xeb00, + 0xeb01: 0xeb01, + 0xeb02: 0xeb02, + 0xeb03: 0xeb03, + 0xeb04: 0xeb04, + 0xeb05: 0xeb05, + 0xeb06: 0xeb06, + 0xeb07: 0xeb07, + 0xeb08: 0xeb08, + 0xeb09: 0xeb09, + 0xeb0b: 0xeb0b, + 0xeb0c: 0xeb0c, + 0xeb0d: 0xeb0d, + 0xeb0e: 0xeb0e, + 0xeb0f: 0xeb0f, + 0xeb10: 0xeb10, + 0xeb11: 0xeb11, + 0xeb12: 0xeb12, + 0xeb13: 0xeb13, + 0xeb14: 0xeb14, + 0xeb15: 0xeb15, + 0xeb16: 0xeb16, + 0xeb17: 0xeb17, + 0xeb18: 0xeb18, + 0xeb19: 0xeb19, + 0xeb1a: 0xeb1a, + 0xeb1b: 0xeb1b, + 0xeb1c: 0xeb1c, + 0xeb1d: 0xeb1d, + 0xeb1e: 0xeb1e, + 0xeb1f: 0xeb1f, + 0xeb20: 0xeb20, + 0xeb21: 0xeb21, + 0xeb22: 0xeb22, + 0xeb23: 0xeb23, + 0xeb24: 0xeb24, + 0xeb25: 0xeb25, + 0xeb26: 0xeb26, + 0xeb27: 0xeb27, + 0xeb28: 0xeb28, + 0xeb29: 0xeb29, + 0xeb2a: 0xeb2a, + 0xeb2b: 0xeb2b, + 0xeb2c: 0xeb2c, + 0xeb2d: 0xeb2d, + 0xeb2e: 0xeb2e, + 0xeb2f: 0xeb2f, + 0xeb30: 0xeb30, + 0xeb31: 0xeb31, + 0xeb32: 0xeb32, + 0xeb33: 0xeb33, + 0xeb34: 0xeb34, + 0xeb35: 0xeb35, + 0xeb36: 0xeb36, + 0xeb37: 0xeb37, + 0xeb38: 0xeb38, + 0xeb39: 0xeb39, + 0xeb3a: 0xeb3a, + 0xeb3b: 0xeb3b, + 0xeb3c: 0xeb3c, + 0xeb3d: 0xeb3d, + 0xeb3e: 0xeb3e, + 0xeb3f: 0xeb3f, + 0xeb40: 0xeb40, + 0xeb41: 0xeb41, + 0xeb42: 0xeb42, + 0xeb43: 0xeb43, + 0xeb44: 0xeb44, + 0xeb45: 0xeb45, + 0xeb46: 0xeb46, + 0xeb47: 0xeb47, + 0xeb48: 0xeb48, + 0xeb49: 0xeb49, + 0xeb4a: 0xeb4a, + 0xeb4b: 0xeb4b, + 0xeb4c: 0xeb4c, + 0xeb4d: 0xeb4d, + 0xeb4e: 0xeb4e, + 0xeb50: 0xeb50, + 0xeb51: 0xeb51, + 0xeb52: 0xeb52, + 0xeb53: 0xeb53, + 0xeb54: 0xeb54, + 0xeb55: 0xeb55, + 0xeb56: 0xeb56, + 0xeb57: 0xeb57, + 0xeb58: 0xeb58, + 0xeb59: 0xeb59, + 0xeb5a: 0xeb5a, + 0xeb5b: 0xeb5b, + 0xeb5c: 0xeb5c, + 0xeb5d: 0xeb5d, + 0xeb5e: 0xeb5e, + 0xeb5f: 0xeb5f, + 0xeb60: 0xeb60, + 0xeb61: 0xeb61, + 0xeb62: 0xeb62, + 0xeb63: 0xeb63, + 0xeb64: 0xeb64, + 0xeb65: 0xeb65, + 0xeb66: 0xeb66, + 0xeb67: 0xeb67, + 0xeb68: 0xeb68, + 0xeb69: 0xeb69, + 0xeb6a: 0xeb6a, + 0xeb6b: 0xeb6b, + 0xeb6c: 0xeb6c, + 0xeb6d: 0xeb6d, + 0xeb6e: 0xeb6e, + 0xeb6f: 0xeb6f, + 0xeb70: 0xeb70, + 0xeb71: 0xeb71, + 0xeb72: 0xeb72, + 0xeb73: 0xeb73, + 0xeb74: 0xeb74, + 0xeb75: 0xeb75, + 0xeb76: 0xeb76, + 0xeb77: 0xeb77, + 0xeb78: 0xeb78, + 0xeb79: 0xeb79, + 0xeb7a: 0xeb7a, + 0xeb7b: 0xeb7b, + 0xeb7c: 0xeb7c, + 0xeb7d: 0xeb7d, + 0xeb7e: 0xeb7e, + 0xeb7f: 0xeb7f, + 0xeb80: 0xeb80, + 0xeb81: 0xeb81, + 0xeb82: 0xeb82, + 0xeb83: 0xeb83, + 0xeb84: 0xeb84, + 0xeb85: 0xeb85, + 0xeb86: 0xeb86, + 0xeb87: 0xeb87, + 0xeb88: 0xeb88, + 0xeb89: 0xeb89, + 0xeb8a: 0xeb8a, + 0xeb8b: 0xeb8b, + 0xeb8c: 0xeb8c, + 0xeb8d: 0xeb8d, + 0xeb8e: 0xeb8e, + 0xeb8f: 0xeb8f, + 0xeb90: 0xeb90, + 0xeb91: 0xeb91, + 0xeb92: 0xeb92, + 0xeb93: 0xeb93, + 0xeb94: 0xeb94, + 0xeb95: 0xeb95, + 0xeb96: 0xeb96, + 0xeb97: 0xeb97, + 0xeb98: 0xeb98, + 0xeb99: 0xeb99, + 0xeb9a: 0xeb9a, + 0xeb9b: 0xeb9b, + 0xeb9c: 0xeb9c, + 0xeb9d: 0xeb9d, + 0xeb9e: 0xeb9e, + 0xeb9f: 0xeb9f, + 0xeba0: 0xeba0, + 0xeba1: 0xeba1, + 0xeba2: 0xeba2, + 0xeba3: 0xeba3, + 0xeba4: 0xeba4, + 0xeba5: 0xeba5, + 0xeba6: 0xeba6, + 0xeba7: 0xeba7, + 0xeba8: 0xeba8, + 0xeba9: 0xeba9, + 0xebaa: 0xebaa, + 0xebab: 0xebab, + 0xebac: 0xebac, + 0xebad: 0xebad, + 0xebae: 0xebae, + 0xebaf: 0xebaf, + 0xebb0: 0xebb0, + 0xebb1: 0xebb1, + 0xebb2: 0xebb2, + 0xebb3: 0xebb3, + 0xebb4: 0xebb4, + 0xebb5: 0xebb5, + 0xebb6: 0xebb6, + 0xebb7: 0xebb7, + 0xebb8: 0xebb8, + 0xebb9: 0xebb9, + 0xebba: 0xebba, + 0xebbb: 0xebbb, + 0xebbc: 0xebbc, + 0xebbd: 0xebbd, + 0xebbe: 0xebbe, + 0xebbf: 0xebbf, + 0xebc0: 0xebc0, + 0xebc1: 0xebc1, + 0xebc2: 0xebc2, + 0xebc3: 0xebc3, + 0xebc4: 0xebc4, + 0xebc5: 0xebc5, + 0xebc6: 0xebc6, + 0xebc7: 0xebc7, + 0xebc8: 0xebc8, + 0xebc9: 0xebc9, + 0xebca: 0xebca, + 0xebcb: 0xebcb, + 0xebcc: 0xebcc, + 0xebcd: 0xebcd, + 0xebce: 0xebce, + 0xebcf: 0xebcf, + 0xebd0: 0xebd0, + 0xebd1: 0xebd1, + 0xebd2: 0xebd2, + 0xebd3: 0xebd3, + 0xebd4: 0xebd4, + 0xebd5: 0xebd5, + 0xebd6: 0xebd6, + 0xebd7: 0xebd7, + 0xebd8: 0xebd8, + 0xebd9: 0xebd9, + 0xebda: 0xebda, + 0xebdb: 0xebdb, + 0xebdc: 0xebdc, + 0xebdd: 0xebdd, + 0xebde: 0xebde, + 0xebdf: 0xebdf, + 0xebe0: 0xebe0, + 0xebe1: 0xebe1, + 0xebe2: 0xebe2, + 0xebe3: 0xebe3, + 0xebe4: 0xebe4, + 0xebe5: 0xebe5, + 0xebe6: 0xebe6, + 0xebe7: 0xebe7, + 0xebe8: 0xebe8, + 0xebe9: 0xebe9, + 0xebea: 0xebea, + 0xebeb: 0xebeb, + 0xebec: 0xebec, + 0xebed: 0xebed, + 0xebee: 0xebee, + 0xebef: 0xebef, + 0xebf0: 0xebf0, + 0xebf1: 0xebf1, + 0xebf2: 0xebf2, + 0xebf3: 0xebf3, + 0xebf4: 0xebf4, + 0xebf5: 0xebf5, + 0xebf6: 0xebf6, + 0xebf7: 0xebf7, + 0xebf8: 0xebf8, + 0xebf9: 0xebf9, + 0xebfa: 0xebfa, + 0xebfb: 0xebfb, + 0xebfc: 0xebfc, + 0xebfd: 0xebfd, + 0xebfe: 0xebfe, + 0xebff: 0xebff, + 0xec00: 0xec00, + 0xec01: 0xec01, + 0xec02: 0xec02, + 0xec03: 0xec03, + 0xec04: 0xec04, + 0xec05: 0xec05, + 0xec06: 0xec06, + 0xec07: 0xec07, + 0xec08: 0xec08, + 0xec09: 0xec09, + 0xec0a: 0xec0a, + 0xec0b: 0xec0b, + 0xec0c: 0xec0c, + 0xec0d: 0xec0d, + 0xec0e: 0xec0e, + 0xec0f: 0xec0f, + 0xec10: 0xec10, + 0xec11: 0xec11, + 0xec12: 0xec12, + 0xec13: 0xec13, + 0xec14: 0xec14, + 0xec15: 0xec15, + 0xec16: 0xec16, + 0xec17: 0xec17, + 0xec18: 0xec18, + 0xec19: 0xec19, + 0xec1a: 0xec1a, + 0xec1b: 0xec1b, + 0xec1c: 0xec1c, + 0xec1d: 0xec1d, + 0xec1e: 0xec1e, + }, +} From d3ee3c5b8a382aee982973cce704411b9a33f052 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Oct 2025 14:49:31 -0700 Subject: [PATCH 087/702] macos: update permission request response should move state back to idle (#9151) Previously, the permission request response would not move the state so it'd stay in the titlebar. --- macos/Sources/Features/Update/UpdateDriver.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 81477ef67..ed58f1663 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -39,7 +39,10 @@ class UpdateDriver: NSObject, SPUUserDriver { func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { - viewModel.state = .permissionRequest(.init(request: request, reply: reply)) + viewModel.state = .permissionRequest(.init(request: request, reply: { [weak viewModel] response in + viewModel?.state = .idle + reply(response) + })) if !hasUnobtrusiveTarget { standard.show(request, reply: reply) } From aa0c68ee5ef209b9bca9ff4070d3858b3c23da95 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Oct 2025 19:44:59 -0700 Subject: [PATCH 088/702] Update iTerm2 colorschemes (#9155) Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/release-20251006-150522-c07f0e8 Co-authored-by: mitchellh <1299+mitchellh@users.noreply.github.com> --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 8fe8daefb..45f1e3692 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz", - .hash = "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz", + .hash = "N-V-__8AALIsAwAgV4B4dOJYBqTmOftgMMm4D4BxFPzns5Bl", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index c62a0ed85..e9f2b8cd8 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y": { + "N-V-__8AALIsAwAgV4B4dOJYBqTmOftgMMm4D4BxFPzns5Bl": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz", - "hash": "sha256-GsEWVt4wMzp6+7N5I+QVuhCVJ70cFrdADwUds59AKnw=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz", + "hash": "sha256-yTN88FFxGVeK07fSQK+jWy9XLAln7f/W+xcDH+tLOEY=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index d699b454b..08e524e91 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y"; + name = "N-V-__8AALIsAwAgV4B4dOJYBqTmOftgMMm4D4BxFPzns5Bl"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz"; - hash = "sha256-GsEWVt4wMzp6+7N5I+QVuhCVJ70cFrdADwUds59AKnw="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz"; + hash = "sha256-yTN88FFxGVeK07fSQK+jWy9XLAln7f/W+xcDH+tLOEY="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index d8a390c40..d7b76e59f 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -31,6 +31,6 @@ 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/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.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 3f573456a..df210bf22 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-20251002-142451-4a5043e/ghostty-themes.tgz", - "dest": "vendor/p/N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y", - "sha256": "1ac11656de30333a7afbb37923e415ba109527bd1c16b7400f051db39f402a7c" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AALIsAwAgV4B4dOJYBqTmOftgMMm4D4BxFPzns5Bl", + "sha256": "c9337cf0517119578ad3b7d240afa35b2f572c0967edffd6fb17031feb4b3846" }, { "type": "archive", From 2e34f4e0e574612c7bae4730325e57a4154236b9 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 11 Oct 2025 19:48:08 -0700 Subject: [PATCH 089/702] fix(font): Additional scale group tweaks (#9152) Of course #9142 would require a minor follow-up! * Scale groups can cut across patch sets, but not across fonts. We had some scale group mixing between Font Awesome and the weather symbols, which is removed by this PR.[^cp_table_full] * There's one case where a scale group includes a glyph that's not part of any patch sets, just for padding out the group bounding box. Previously, an unrelated glyph from a different font would be pulled in. Now we use an appropriate stand-in. (See code comment for details.) * I noticed overlaps weren't being split between each side of the bounding box, they were added to both sides, resulting in twice as much padding as specified. Screenshots showing the extra vertical padding for progress bar elements due to the second bullet point: **Before** Screenshot 2025-10-11 at 15 33 54 **After** Screenshot 2025-10-11 at 15 33 20 [^cp_table_full]: Forming and using the merged `cp_table_full` table should have been a red flag. Such a table doesn't make sense, it would be a one-to-many map. You need the names of the original fonts to disambiguate. --- src/font/nerd_font_attributes.zig | 1530 +++++++++++++++++++++++++---- src/font/nerd_font_codegen.py | 51 +- 2 files changed, 1374 insertions(+), 207 deletions(-) diff --git a/src/font/nerd_font_attributes.zig b/src/font/nerd_font_attributes.zig index 73fcf0bd3..f4a19d963 100644 --- a/src/font/nerd_font_attributes.zig +++ b/src/font/nerd_font_attributes.zig @@ -16,10 +16,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .center1, .align_vertical = .center1, - .pad_left = 0.1, - .pad_right = 0.1, - .pad_top = 0.1, - .pad_bottom = 0.1, + .pad_left = 0.05, + .pad_right = 0.05, + .pad_top = 0.05, + .pad_bottom = 0.05, }, 0x276c...0x276d, => .{ @@ -69,10 +69,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.06, - .pad_right = -0.06, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.03, + .pad_right = -0.03, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe0b1, @@ -89,10 +89,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.06, - .pad_right = -0.06, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.03, + .pad_right = -0.03, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe0b3, @@ -109,10 +109,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.06, - .pad_right = -0.06, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.03, + .pad_right = -0.03, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.59, }, 0xe0b5, @@ -129,10 +129,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.06, - .pad_right = -0.06, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.03, + .pad_right = -0.03, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.59, }, 0xe0b7, @@ -143,6 +143,18 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_vertical = .center1, .max_xy_ratio = 0.5, }, + 0xe0b8, + 0xe0bc, + => .{ + .size = .stretch, + .max_constraint_width = 1, + .align_horizontal = .start, + .align_vertical = .center1, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, + }, 0xe0b9, 0xe0bd, => .{ @@ -151,6 +163,18 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_horizontal = .start, .align_vertical = .center1, }, + 0xe0ba, + 0xe0be, + => .{ + .size = .stretch, + .max_constraint_width = 1, + .align_horizontal = .end, + .align_vertical = .center1, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, + }, 0xe0bb, 0xe0bf, => .{ @@ -165,10 +189,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, }, 0xe0c1, => .{ @@ -182,10 +206,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, }, 0xe0c3, => .{ @@ -198,10 +222,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, .max_xy_ratio = 0.86, }, 0xe0c5, @@ -209,10 +233,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, .max_xy_ratio = 0.86, }, 0xe0c6, @@ -220,10 +244,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, .max_xy_ratio = 0.78, }, 0xe0c7, @@ -231,10 +255,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, .max_xy_ratio = 0.78, }, 0xe0cc, @@ -242,10 +266,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .size = .stretch, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.02, - .pad_right = -0.02, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.01, + .pad_right = -0.01, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.85, }, 0xe0cd, @@ -268,10 +292,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.02, - .pad_right = -0.02, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.01, + .pad_right = -0.01, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe0d4, @@ -280,10 +304,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.02, - .pad_right = -0.02, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.01, + .pad_right = -0.01, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe0d6, @@ -292,10 +316,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, 0xe0d7, @@ -304,12 +328,39 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, .max_xy_ratio = 0.7, }, + 0xe300, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8984375000000000, + .relative_y = 0.0986328125000000, + }, + 0xe301, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8798828125000000, + .relative_y = 0.1171875000000000, + }, + 0xe302, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7646484375000000, + .relative_y = 0.2314453125000000, + }, 0xe303, => .{ .size = .fit_cover1, @@ -328,6 +379,188 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.9755859375000000, .relative_y = 0.0244140625000000, }, + 0xe305, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9960937500000000, + .relative_y = 0.0019531250000000, + }, + 0xe306, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9863281250000000, + .relative_y = 0.0097656250000000, + }, + 0xe307, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9951171875000000, + .relative_y = 0.0039062500000000, + }, + 0xe308, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9785156250000000, + .relative_y = 0.0195312500000000, + }, + 0xe309, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9736328125000000, + .relative_y = 0.0214843750000000, + }, + 0xe30a, + 0xe35f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9648437500000000, + .relative_y = 0.0302734375000000, + }, + 0xe30b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8437500000000000, + .relative_y = 0.1513671875000000, + }, + 0xe30c, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8027343750000000, + .relative_y = 0.1835937500000000, + }, + 0xe30d, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7753906250000000, + .relative_y = 0.1083984375000000, + }, + 0xe30e, + 0xe365, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9833984375000000, + .relative_y = 0.0166015625000000, + }, + 0xe30f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9716796875000000, + .relative_y = 0.0263671875000000, + }, + 0xe310, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6621093750000000, + .relative_y = 0.0986328125000000, + }, + 0xe311, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6425781250000000, + .relative_y = 0.1171875000000000, + }, + 0xe312, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5322265625000000, + .relative_y = 0.2314453125000000, + }, + 0xe313, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6416015625000000, + .relative_y = 0.1181640625000000, + }, + 0xe314, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7382812500000000, + .relative_y = 0.0195312500000000, + }, + 0xe315, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6787109375000000, + .relative_y = 0.1357421875000000, + }, + 0xe316, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7480468750000000, + .relative_y = 0.0097656250000000, + }, + 0xe317, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7529296875000000, + .relative_y = 0.0048828125000000, + }, + 0xe318, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7314453125000000, + .relative_y = 0.0263671875000000, + }, 0xe319, => .{ .size = .fit_cover1, @@ -338,6 +571,7 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_y = 0.0195312500000000, }, 0xe31a, + 0xe35e, => .{ .size = .fit_cover1, .height = .icon, @@ -391,6 +625,24 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7363281250000000, .relative_y = 0.0986328125000000, }, + 0xe320, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7177734375000000, + .relative_y = 0.1171875000000000, + }, + 0xe321, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8085937500000000, + .relative_y = 0.0253906250000000, + }, 0xe322, => .{ .size = .fit_cover1, @@ -400,6 +652,32 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7509765625000000, .relative_y = 0.0839843750000000, }, + 0xe323, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8281250000000000, + .relative_y = 0.0097656250000000, + }, + 0xe324, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8349609375000000, + }, + 0xe325, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8154296875000000, + .relative_y = 0.0214843750000000, + }, 0xe326, => .{ .size = .fit_cover1, @@ -409,14 +687,294 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.8144531250000000, .relative_y = 0.0195312500000000, }, - 0xe347, + 0xe327, + 0xe361, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8076171875000000, + .relative_y = 0.0273437500000000, + }, + 0xe328, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6845703125000000, + .relative_y = 0.1503906250000000, + }, + 0xe329, + 0xe367, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8173828125000000, + .relative_y = 0.0175781250000000, + }, + 0xe32a, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8105468750000000, + .relative_y = 0.0263671875000000, + }, + 0xe32b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5175781250000000, + .relative_y = 0.2421875000000000, + }, + 0xe32c, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6992187500000000, + .relative_y = 0.1005859375000000, + }, + 0xe32d, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6787109375000000, + .relative_y = 0.1201171875000000, + }, + 0xe32e, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5654296875000000, + .relative_y = 0.2324218750000000, + }, + 0xe32f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7714843750000000, + .relative_y = 0.0273437500000000, + }, + 0xe330, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7148437500000000, + .relative_y = 0.0830078125000000, + }, + 0xe331, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7919921875000000, + .relative_y = 0.0097656250000000, + }, + 0xe332, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7871093750000000, + .relative_y = 0.0126953125000000, + }, + 0xe333, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7714843750000000, + .relative_y = 0.0263671875000000, + }, + 0xe334, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7773437500000000, + .relative_y = 0.0195312500000000, + }, + 0xe335, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7714843750000000, + .relative_y = 0.0283203125000000, + }, + 0xe336, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6503906250000000, + .relative_y = 0.1503906250000000, + }, + 0xe337, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7753906250000000, + .relative_y = 0.0234375000000000, + }, + 0xe338, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7792968750000000, + .relative_y = 0.0185546875000000, + }, + 0xe339, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8445945945945946, + }, + 0xe33a, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5283203125000000, + .relative_y = 0.2324218750000000, + }, + 0xe33b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5449218750000000, + .relative_y = 0.2148437500000000, + }, + 0xe33c...0xe33d, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, .relative_height = 0.5273437500000000, - .relative_y = 0.2617187500000000, + .relative_y = 0.2324218750000000, + }, + 0xe33e, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.3293918918918919, + .relative_y = 0.6706081081081081, + }, + 0xe33f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5200000000000000, + .relative_y = 0.2707692307692308, + }, + 0xe340, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8307692307692308, + .relative_y = 0.0861538461538462, + }, + 0xe341, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8327702702702703, + .relative_y = 0.0050675675675676, + }, + 0xe344, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5307692307692308, + .relative_y = 0.2092307692307692, + }, + 0xe345, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5332112630208333, + .relative_y = 0.2040934244791667, + }, + 0xe347, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8307692307692308, + .relative_y = 0.1246153846153846, + }, + 0xe349, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5307967032967034, + .relative_y = 0.2615384615384616, + }, + 0xe34c, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8659995118379302, + .relative_y = 0.1340004881620698, + }, + 0xe34d, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9890163534293386, + .relative_y = 0.0002440810349036, }, 0xe34f, => .{ @@ -427,14 +985,69 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.5751953125000000, .relative_y = 0.1142578125000000, }, - 0xe35f, + 0xe351, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.9648437500000000, - .relative_y = 0.0302734375000000, + .relative_height = 0.6533203125000000, + .relative_y = 0.1328125000000000, + }, + 0xe352, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5215384615384615, + .relative_y = 0.2846153846153846, + }, + 0xe353, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8308012820512821, + .relative_y = 0.1230448717948718, + }, + 0xe354...0xe356, + 0xe358...0xe359, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9935233160621761, + .relative_y = 0.0025906735751295, + }, + 0xe357, + 0xe3a9, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9961139896373057, + }, + 0xe35a, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9935233160621761, + .relative_y = 0.0012953367875648, + }, + 0xe35b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9987046632124352, + .relative_y = 0.0012953367875648, }, 0xe360, => .{ @@ -445,14 +1058,14 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7695312500000000, .relative_y = 0.0302734375000000, }, - 0xe361, + 0xe362, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.8076171875000000, - .relative_y = 0.0273437500000000, + .relative_height = 0.9902343750000000, + .relative_y = 0.0097656250000000, }, 0xe363, => .{ @@ -463,6 +1076,42 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7900390625000000, .relative_y = 0.0097656250000000, }, + 0xe364, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8251953125000000, + .relative_y = 0.0097656250000000, + }, + 0xe366, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7832031250000000, + .relative_y = 0.0166015625000000, + }, + 0xe369, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.4902343750000000, + .relative_y = 0.2548828125000000, + }, + 0xe36b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9333658774713205, + .relative_y = 0.0266048328044911, + }, 0xe36c, => .{ .size = .fit_cover1, @@ -481,6 +1130,42 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.8427734375000000, .relative_y = 0.0625000000000000, }, + 0xe36e, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7529721467391304, + .relative_y = 0.0956606657608696, + }, + 0xe36f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6835937500000000, + .relative_y = 0.1250000000000000, + }, + 0xe370, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8642578125000000, + .relative_y = 0.0625000000000000, + }, + 0xe371, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6103515625000000, + .relative_y = 0.1933593750000000, + }, 0xe372, => .{ .size = .fit_cover1, @@ -499,6 +1184,60 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.8652343750000000, .relative_y = 0.0058593750000000, }, + 0xe374, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.3154296875000000, + .relative_y = 0.2861328125000000, + }, + 0xe375, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6772460937500000, + .relative_y = 0.1303710937500000, + }, + 0xe376, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6992187500000000, + .relative_y = 0.1337890625000000, + }, + 0xe377, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7314453125000000, + .relative_y = 0.1552734375000000, + }, + 0xe378, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7314453125000000, + .relative_y = 0.1542968750000000, + }, + 0xe379, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5751953125000000, + .relative_y = 0.1826171875000000, + }, 0xe37a, => .{ .size = .fit_cover1, @@ -517,6 +1256,15 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.5751953125000000, .relative_y = 0.1835937500000000, }, + 0xe37d, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9003906250000000, + .relative_y = 0.0957031250000000, + }, 0xe37e, => .{ .size = .fit_cover1, @@ -526,39 +1274,118 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.6015625000000000, .relative_y = 0.2324218750000000, }, - 0xe381...0xe383, - 0xe386, - 0xe388, - 0xe38b, + 0xe37f, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7500000000000000, - .relative_y = 0.1250000000000000, + .relative_height = 0.5200000000000000, + .relative_y = 0.2784615384615385, }, - 0xe38d, + 0xe380, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7519531250000000, - .relative_y = 0.1240234375000000, + .relative_height = 0.5200000000000000, + .relative_y = 0.2630769230769231, + }, + 0xe38e...0xe391, + 0xe394, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.4990253411306043, + .relative_height = 0.9987012987012988, + .relative_x = 0.4996751137102014, + }, + 0xe392...0xe393, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.4996751137102014, + .relative_height = 0.9987012987012988, + .relative_x = 0.4990253411306043, + }, + 0xe395, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5471085120207927, + .relative_height = 0.9987012987012988, + .relative_x = 0.4515919428200130, }, - 0xe390, - 0xe393, 0xe396, - 0xe3a3, - 0xe3a6...0xe3a7, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7509765625000000, - .relative_y = 0.1240234375000000, + .relative_width = 0.5945419103313840, + .relative_height = 0.9987012987012988, + .relative_x = 0.4041585445094217, + }, + 0xe397, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6426250812215725, + .relative_x = 0.3573749187784275, + }, + 0xe398, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6900584795321637, + .relative_x = 0.3099415204678362, + }, + 0xe399, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7381416504223521, + .relative_x = 0.2618583495776478, + }, + 0xe39a, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7855750487329435, + .relative_x = 0.2144249512670565, + }, + 0xe39b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9987004548408057, + .relative_height = 0.9987012987012988, + }, + 0xe39c, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8323586744639376, + .relative_height = 0.9935064935064936, }, 0xe39d, => .{ @@ -566,17 +1393,35 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7480468750000000, - .relative_y = 0.1240234375000000, + .relative_width = 0.7855750487329435, + .relative_height = 0.9948051948051948, }, - 0xe39e...0xe3a0, + 0xe39e, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7490234375000000, - .relative_y = 0.1240234375000000, + .relative_width = 0.7381416504223521, + .relative_height = 0.9961038961038962, + }, + 0xe39f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6907082521117609, + .relative_height = 0.9961038961038962, + }, + 0xe3a0, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.6426250812215725, + .relative_height = 0.9961038961038962, }, 0xe3a1, => .{ @@ -584,8 +1429,38 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7500000000000000, - .relative_y = 0.1240234375000000, + .relative_width = 0.5945419103313840, + .relative_height = 0.9974025974025974, + }, + 0xe3a2...0xe3a3, + 0xe3a5, + 0xe3a7...0xe3a8, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.4990253411306043, + .relative_height = 0.9987012987012988, + }, + 0xe3a4, + 0xe3a6, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.4996751137102014, + .relative_height = 0.9987012987012988, + }, + 0xe3aa, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9902343750000000, + .relative_y = 0.0078125000000000, }, 0xe3ab, => .{ @@ -614,35 +1489,51 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7519531250000000, .relative_y = 0.0068359375000000, }, + 0xe3ae, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6152343750000000, + .relative_y = 0.2324218750000000, + }, 0xe3af, 0xe3b3, - 0xe3b5, - 0xe3b7...0xe3bb, + 0xe3b5...0xe3bb, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7001953125000000, - .relative_y = 0.1503906250000000, + .relative_height = 0.9986072423398329, + .relative_y = 0.0013927576601671, }, - 0xe3b0...0xe3b1, + 0xe3b0...0xe3b2, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.6982421875000000, - .relative_y = 0.1523437500000000, + .relative_height = 0.9958217270194986, + .relative_y = 0.0041782729805014, }, - 0xe3b4, + 0xe3c1, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.7011718750000000, - .relative_y = 0.1494140625000000, + .relative_height = 0.6590187942396876, + .relative_y = 0.1349768123016842, + }, + 0xe3c2, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7939956065413717, }, 0x23fb...0x23fe, 0x2665, @@ -650,34 +1541,20 @@ pub fn getConstraint(cp: u21) ?Constraint { 0x2b58, 0xe000...0xe00a, 0xe200...0xe2a9, - 0xe300...0xe302, - 0xe305...0xe318, - 0xe320...0xe321, - 0xe323...0xe325, - 0xe327...0xe346, - 0xe348...0xe34e, - 0xe350...0xe35e, - 0xe362, - 0xe364...0xe36b, - 0xe36e...0xe371, - 0xe374...0xe379, - 0xe37c...0xe37d, - 0xe37f...0xe380, - 0xe384...0xe385, - 0xe387, - 0xe389...0xe38a, - 0xe38c, - 0xe38e...0xe38f, - 0xe391...0xe392, - 0xe394...0xe395, - 0xe397...0xe39c, - 0xe3a2, - 0xe3a4...0xe3a5, - 0xe3a8...0xe3aa, - 0xe3ae, - 0xe3b2, - 0xe3b6, - 0xe3bc...0xe3e3, + 0xe342...0xe343, + 0xe346, + 0xe348, + 0xe34a...0xe34b, + 0xe34e, + 0xe350, + 0xe35c...0xe35d, + 0xe368, + 0xe36a, + 0xe37c, + 0xe381...0xe38d, + 0xe3b4, + 0xe3bc...0xe3c0, + 0xe3c3...0xe3e3, 0xe5fa...0xe6b8, 0xe700...0xe8ef, 0xea60, @@ -701,9 +1578,23 @@ pub fn getConstraint(cp: u21) ?Constraint { 0xec0d...0xec1e, 0xed00...0xedff, 0xee0c...0xefce, - 0xf000...0xf0db, + 0xf000...0xf004, + 0xf006...0xf025, + 0xf028...0xf02a, + 0xf02c...0xf030, + 0xf034, + 0xf036...0xf043, + 0xf045, + 0xf047, + 0xf053...0xf05f, + 0xf062, + 0xf064...0xf076, + 0xf079...0xf07d, + 0xf07f...0xf088, + 0xf08a...0xf0a3, + 0xf0a6...0xf0d6, + 0xf0db, 0xf0df...0xf0ff, - 0xf101...0xf105, 0xf108...0xf12f, 0xf131...0xf140, 0xf142...0xf152, @@ -1017,8 +1908,6 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_width = 0.8008342022940563, .relative_x = 0.1991657977059437, }, - 0xe0ba, - 0xe0be, 0xee00, 0xee03, => .{ @@ -1026,10 +1915,14 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .end, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .relative_width = 0.8681172291296625, + .relative_height = 0.8626692456479691, + .relative_x = 0.1314387211367673, + .relative_y = 0.0686653771760155, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, }, 0xee01, 0xee04, @@ -1038,13 +1931,13 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .center1, .align_vertical = .center1, - .pad_left = -0.1, - .pad_right = -0.1, - .pad_top = -0.01, - .pad_bottom = -0.01, + .relative_height = 0.8626692456479691, + .relative_y = 0.0686653771760155, + .pad_left = -0.05, + .pad_right = -0.05, + .pad_top = -0.005, + .pad_bottom = -0.005, }, - 0xe0b8, - 0xe0bc, 0xee02, 0xee05, => .{ @@ -1052,10 +1945,13 @@ pub fn getConstraint(cp: u21) ?Constraint { .max_constraint_width = 1, .align_horizontal = .start, .align_vertical = .center1, - .pad_left = -0.05, - .pad_right = -0.05, - .pad_top = -0.01, - .pad_bottom = -0.01, + .relative_width = 0.8685612788632326, + .relative_height = 0.8626692456479691, + .relative_y = 0.0686653771760155, + .pad_left = -0.025, + .pad_right = -0.025, + .pad_top = -0.005, + .pad_bottom = -0.005, }, 0xee06, => .{ @@ -1067,10 +1963,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.2234524408656266, .relative_x = 0.1470292044310171, .relative_y = 0.7765475591343735, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xee07, => .{ @@ -1082,10 +1978,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.7498741821841973, .relative_x = 0.5000000000000000, .relative_y = 0.2501258178158027, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xee08, => .{ @@ -1096,10 +1992,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_width = 0.6299093655589124, .relative_height = 0.8535480624056366, .relative_x = 0.3700906344410876, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xee09, => .{ @@ -1108,10 +2004,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_horizontal = .center1, .align_vertical = .center1, .relative_height = 0.4997483643683945, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xee0a, => .{ @@ -1121,10 +2017,10 @@ pub fn getConstraint(cp: u21) ?Constraint { .align_vertical = .center1, .relative_width = 0.6299093655589124, .relative_height = 0.8535480624056366, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, 0xee0b, => .{ @@ -1135,25 +2031,279 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_width = 0.5000000000000000, .relative_height = 0.7498741821841973, .relative_y = 0.2501258178158027, - .pad_left = 0.03, - .pad_right = 0.03, - .pad_top = 0.03, - .pad_bottom = 0.03, + .pad_left = 0.015, + .pad_right = 0.015, + .pad_top = 0.015, + .pad_bottom = 0.015, }, - 0xf0dc...0xf0de, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - }, - 0xf100, + 0xf005, => .{ .size = .fit_cover1, .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.6923828125000000, - .relative_y = 0.1538085937500000, + .relative_height = 0.9999664113932554, + .relative_y = 0.0000335886067446, + }, + 0xf026...0xf027, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9786184354605580, + .relative_y = 0.0103951316192896, + }, + 0xf02b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9758052740827267, + .relative_y = 0.0238869355863696, + }, + 0xf031...0xf033, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9987922705314010, + .relative_y = 0.0006038647342995, + }, + 0xf035, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9989935587761675, + .relative_y = 0.0004025764895330, + }, + 0xf044, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9925925925925926, + }, + 0xf046, + 0xf153...0xf154, + 0xf158, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8751322751322751, + .relative_y = 0.0624338624338624, + }, + 0xf048, + 0xf04a, + 0xf04e, + 0xf051, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8577706898990622, + .relative_y = 0.0711892586341537, + }, + 0xf049, + 0xf050, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8579450878868969, + .relative_y = 0.0710148606463189, + }, + 0xf04b, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9997041418532618, + .relative_y = 0.0002958581467381, + }, + 0xf04c...0xf04d, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8572940020656472, + .relative_y = 0.0713404035569438, + }, + 0xf04f, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7138835298072554, + .relative_y = 0.1433479295317200, + }, + 0xf052, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9999748091795350, + }, + 0xf060...0xf061, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8567975830815709, + .relative_y = 0.0719033232628399, + }, + 0xf063, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9987915407854985, + .relative_y = 0.0006042296072508, + }, + 0xf077, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5700483091787439, + .relative_y = 0.2862318840579710, + }, + 0xf078, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.5700483091787439, + .relative_y = 0.1437198067632850, + }, + 0xf07e, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.4989429175475687, + .relative_y = 0.2505285412262157, + }, + 0xf089, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9998488512696494, + .relative_y = 0.0001511487303507, + }, + 0xf0a4...0xf0a5, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7502645502645503, + .relative_y = 0.1248677248677249, + }, + 0xf0d7, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.4281400966183575, + .relative_y = 0.2053140096618357, + }, + 0xf0d8, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.4281400966183575, + .relative_y = 0.3472222222222222, + }, + 0xf0d9, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7140772371750631, + .relative_y = 0.1333462732919255, + }, + 0xf0da, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7140396210163651, + .relative_y = 0.1333838894506235, + }, + 0xf0dc, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + }, + 0xf0dd, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .relative_height = 0.4275362318840580, + .relative_y = 0.0012077294685990, + }, + 0xf0de, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .relative_height = 0.4287439613526570, + .relative_y = 0.5712560386473430, + }, + 0xf100...0xf101, + 0xf104...0xf105, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8573155985489722, + .relative_y = 0.0713422007255139, + }, + 0xf102, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9286577992744861, + .relative_y = 0.0713422007255139, + }, + 0xf103, + => .{ + .size = .fit_cover1, + .height = .icon, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9286577992744861, }, 0xf106...0xf107, => .{ @@ -1161,8 +2311,8 @@ pub fn getConstraint(cp: u21) ?Constraint { .height = .icon, .align_horizontal = .center1, .align_vertical = .center1, - .relative_height = 0.4038085937500000, - .relative_y = 0.3266601562500000, + .relative_height = 0.5000000000000000, + .relative_y = 0.2853688029020556, }, 0xf130, => .{ @@ -1181,16 +2331,6 @@ pub fn getConstraint(cp: u21) ?Constraint { .relative_height = 0.2593984962406015, .relative_y = 0.3696741854636592, }, - 0xf153...0xf154, - 0xf158, - => .{ - .size = .fit_cover1, - .height = .icon, - .align_horizontal = .center1, - .align_vertical = .center1, - .relative_height = 0.8751322751322751, - .relative_y = 0.0624338624338624, - }, 0xf156, => .{ .size = .fit_cover1, diff --git a/src/font/nerd_font_codegen.py b/src/font/nerd_font_codegen.py index f5bac5e86..8ddc0c113 100644 --- a/src/font/nerd_font_codegen.py +++ b/src/font/nerd_font_codegen.py @@ -282,12 +282,12 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry) # `overlap` and `ypadding` are mutually exclusive, # this is asserted in the nerd fonts patcher itself. if overlap: - pad = -overlap + pad = -overlap / 2 s += f" .pad_left = {pad},\n" s += f" .pad_right = {pad},\n" # In the nerd fonts patcher, overlap values # are capped at 0.01 in the vertical direction. - v_pad = -min(0.01, overlap) + v_pad = -min(0.01, overlap) / 2 s += f" .pad_top = {v_pad},\n" s += f" .pad_bottom = {v_pad},\n" elif y_padding: @@ -314,7 +314,7 @@ def generate_codepoint_tables( return nerd_font_codepoint_tables.cp_tables cp_tables: dict[str, dict[int, int]] = {} - cp_table_full: dict[int, int] = {} + cp_nerdfont_used: set[int] = set() cmap = nerd_font.getBestCmap() for entry in patch_sets: patch_set_name = entry["Name"] @@ -381,12 +381,12 @@ def generate_codepoint_tables( raise ValueError( f"Missing codepoint in Symbols Only Font: {hex(cp_nerdfont)} in patch set '{patch_set_name}'" ) - elif cp_nerdfont in cp_table_full.values(): + elif cp_nerdfont in cp_nerdfont_used: raise ValueError( f"Overlap for codepoint {hex(cp_nerdfont)} in patch set '{patch_set_name}'" ) cp_tables[patch_set_name][cp_original] = cp_nerdfont - cp_table_full |= cp_tables[patch_set_name] + cp_nerdfont_used.add(cp_nerdfont) # Store the table and corresponding Nerd Fonts version together in a module. with open("nerd_font_codepoint_tables.py", "w") as f: @@ -419,9 +419,6 @@ def generate_zig_switch_arms( cmap = nerd_font.getBestCmap() glyphs = nerd_font.getGlyphSet() cp_tables = generate_codepoint_tables(patch_sets, nerd_font, nf_version) - cp_table_full: dict[int, int] = {} - for cp_table in cp_tables.values(): - cp_table_full |= cp_table entries: dict[int, PatchSetAttributeEntry] = {} for entry in patch_sets: @@ -454,9 +451,37 @@ def generate_zig_switch_arms( individual_bounds: dict[int, tuple[int, int, int, int]] = {} individual_advances: set[float] = set() for cp_original in group: - # Scale groups may cut across patch sets, so we need to use - # the full lookup table here - cp_nerdfont = cp_table_full[cp_original] + if cp_original not in cp_table: + # There is one special case where a scale group includes + # a glyph from the original font that's not in any patch + # set, and hence not in the Symbols Only font. The point + # of this glyph is to add extra vertical padding to a + # stretched (^xy) scale group, which means that its + # scaled and aligned position would span the line height + # plus overlap. Thus, we can use any other stretched + # glyph with overlap as stand-in to get the vertical + # bounds, such as as 0xE0B0 (powerline left hard + # divider). We don't worry about the horizontal bounds, + # as they by design should not affect the group's + # bounding box. + if ( + patch_set_name == "Progress Indicators" + and cp_original == 0xEDFF + ): + glyph = glyphs[cmap[0xE0B0]] + bounds = BoundsPen(glyphSet=glyphs) + glyph.draw(bounds) + yMin = min(bounds.bounds[1], yMin) + yMax = max(bounds.bounds[3], yMax) + else: + # Other cases are due to lazily specified scale + # groups with gaps in the codepoint range. + print( + f"Info: Skipping scale group codepoint {hex(cp_original)}, which does not exist in patch set '{patch_set_name}'" + ) + continue + + cp_nerdfont = cp_table[cp_original] glyph = glyphs[cmap[cp_nerdfont]] individual_advances.add(glyph.width) bounds = BoundsPen(glyphSet=glyphs) @@ -472,7 +497,9 @@ def generate_zig_switch_arms( len(individual_advances) == 1 ) for cp_original in group: - cp_nerdfont = cp_table_full[cp_original] + if cp_original not in cp_table: + continue + cp_nerdfont = cp_table[cp_original] if ( # Scale groups may cut across patch sets, but we're only # updating a single patch set at a time, so we skip From 6faf7fcac3ff09d0471bcb7ed2dc0b77b052604b Mon Sep 17 00:00:00 2001 From: Rohan Alexander Date: Sun, 12 Oct 2025 10:21:49 -0400 Subject: [PATCH 090/702] Add missing word to README.md (#9165) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index df86f7830..7124400fd 100644 --- a/README.md +++ b/README.md @@ -193,4 +193,4 @@ SENTRY_DSN=https://e914ee84fd895c4fe324afa3e53dac76@o4507352570920960.ingest.us. > purposely contain sensitive information, but it does contain the full > stack memory of each thread at the time of the crash. This information > is used to rebuild the stack trace but can also contain sensitive data -> depending when the crash occurred. +> depending on when the crash occurred. From cbc06a0abc2457b025438adc3d0f57e1c0eca3fa Mon Sep 17 00:00:00 2001 From: Xiangbao Meng <134181853+bo2themax@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:25:18 +0200 Subject: [PATCH 091/702] macOS: Support building with Xcode 16 (#9162) With this you can test most of the old tab bar behaviour without using a virtual machine --- .../Features/App Intents/CloseTerminalIntent.swift | 2 ++ .../Features/App Intents/CommandPaletteIntent.swift | 2 ++ .../Features/App Intents/FocusTerminalIntent.swift | 2 ++ .../App Intents/GetTerminalDetailsIntent.swift | 2 ++ macos/Sources/Features/App Intents/InputIntent.swift | 10 ++++++++++ macos/Sources/Features/App Intents/KeybindIntent.swift | 2 ++ .../Features/App Intents/NewTerminalIntent.swift | 2 ++ .../Features/App Intents/QuickTerminalIntent.swift | 2 ++ .../Sources/Features/Terminal/TerminalController.swift | 4 ++++ 9 files changed, 28 insertions(+) diff --git a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift index 923d22c97..0155cf855 100644 --- a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift @@ -12,8 +12,10 @@ struct CloseTerminalIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif @MainActor func perform() async throws -> some IntentResult { diff --git a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift index fa983054b..2f07d7861 100644 --- a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift +++ b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift @@ -19,8 +19,10 @@ struct CommandPaletteIntent: AppIntent { ) var command: CommandEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif @MainActor func perform() async throws -> some IntentResult & ReturnsValue { diff --git a/macos/Sources/Features/App Intents/FocusTerminalIntent.swift b/macos/Sources/Features/App Intents/FocusTerminalIntent.swift index 4e813e842..21dd71b15 100644 --- a/macos/Sources/Features/App Intents/FocusTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/FocusTerminalIntent.swift @@ -12,8 +12,10 @@ struct FocusTerminalIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif @MainActor func perform() async throws -> some IntentResult { diff --git a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift index 1cbaa9d68..563e3719b 100644 --- a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift +++ b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift @@ -17,8 +17,10 @@ struct GetTerminalDetailsIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif static var parameterSummary: some ParameterSummary { Summary("Get \(\.$detail) from \(\.$terminal)") diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift index 17c97fbbb..d169b3a8c 100644 --- a/macos/Sources/Features/App Intents/InputIntent.swift +++ b/macos/Sources/Features/App Intents/InputIntent.swift @@ -24,8 +24,10 @@ struct InputTextIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult { @@ -74,8 +76,10 @@ struct KeyEventIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult { @@ -136,8 +140,10 @@ struct MouseButtonIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult { @@ -197,8 +203,10 @@ struct MousePosIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult { @@ -265,8 +273,10 @@ struct MouseScrollIntent: AppIntent { ) var terminal: TerminalEntity +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult { diff --git a/macos/Sources/Features/App Intents/KeybindIntent.swift b/macos/Sources/Features/App Intents/KeybindIntent.swift index b31da4a50..a8cea8561 100644 --- a/macos/Sources/Features/App Intents/KeybindIntent.swift +++ b/macos/Sources/Features/App Intents/KeybindIntent.swift @@ -16,8 +16,10 @@ struct KeybindIntent: AppIntent { ) var action: String +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = [.background, .foreground] +#endif @MainActor func perform() async throws -> some IntentResult & ReturnsValue { diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift index 46a752198..be5c65bfa 100644 --- a/macos/Sources/Features/App Intents/NewTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -45,8 +45,10 @@ struct NewTerminalIntent: AppIntent { // Performing in the background can avoid opening multiple windows at the same time // using `foreground` will cause `perform` and `AppDelegate.applicationDidBecomeActive(_:)`/`AppDelegate.applicationShouldHandleReopen(_:hasVisibleWindows:)` running at the 'same' time +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif @available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes") static var openAppWhenRun = false diff --git a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift index 2e6c9850c..2048a3b88 100644 --- a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift @@ -5,8 +5,10 @@ struct QuickTerminalIntent: AppIntent { static var title: LocalizedStringResource = "Open the Quick Terminal" static var description = IntentDescription("Open the Quick Terminal. If it is already open, then do nothing.") +#if compiler(>=6.2) @available(macOS 26.0, *) static var supportedModes: IntentModes = .background +#endif @MainActor func perform() async throws -> some IntentResult & ReturnsValue<[TerminalEntity]> { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 779c13d9c..9790063d7 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -23,11 +23,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" case "tabs": +#if compiler(>=6.2) if #available(macOS 26.0, *) { "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" } +#else + "TerminalTabsTitlebarVentura" +#endif default: defaultValue } From 47a8f8083dd07934fa3284cb758c3e04bffcd638 Mon Sep 17 00:00:00 2001 From: Xiangbao Meng <134181853+bo2themax@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:31:42 +0200 Subject: [PATCH 092/702] macOS: Fix more `macos-titlebar-style` related issues (#9163) ### This PR depends on #9162 #1691 doesn't seem to be an issue anymore, but changing appearance while Ghosty is active will result in similar nastiness. https://github.com/user-attachments/assets/fcd7761e-a521-4382-8d7a-9d93dc0806bc ### Changes - [Sequoia/Ventura] Fix flickering new tab icon, and it also didn't respond to window's key status change correctly - [Sequoia/Ventura] Fix after changing appearance, tab bar may disappear or have inconsistent background colour - Fix initial tint of reset zoom button on Sequoia/Ventura with `macos-titlebar-style=tabs` and all `native/transparent` titlebars - Fix title alignment with custom font with `native/transparent` titlebar --- .../Window Styles/TerminalWindow.swift | 14 +++- .../TitlebarTabsVenturaTerminalWindow.swift | 69 +++++++++---------- 2 files changed, 44 insertions(+), 39 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 95126e188..8249c6cf7 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -159,6 +159,12 @@ class TerminalWindow: NSWindow { } else { tabBarDidDisappear() } + viewModel.isMainWindow = true + } + + override func resignMain() { + super.resignMain() + viewModel.isMainWindow = false } override func mergeAllWindows(_ sender: Any?) { @@ -298,7 +304,7 @@ class TerminalWindow: NSWindow { button.isBordered = false button.allowsExpansionToolTips = true button.toolTip = "Reset Zoom" - button.contentTintColor = .controlAccentColor + button.contentTintColor = isMainWindow ? .controlAccentColor : .secondaryLabelColor button.state = .on button.image = NSImage(named:"ResetZoom") button.frame = NSRect(x: 0, y: 0, width: 20, height: 20) @@ -324,6 +330,7 @@ class TerminalWindow: NSWindow { let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) titlebarTextField?.font = font + titlebarTextField?.usesSingleLineMode = true tab.attributedTitle = attributedTitle } } @@ -505,7 +512,8 @@ extension TerminalWindow { class ViewModel: ObservableObject { @Published var isSurfaceZoomed: Bool = false @Published var hasToolbar: Bool = false - + @Published var isMainWindow: Bool = true + /// Calculates the top padding based on toolbar visibility and macOS version fileprivate var accessoryTopPadding: CGFloat { if #available(macOS 26.0, *) { @@ -525,7 +533,7 @@ extension TerminalWindow { VStack { Button(action: action) { Image("ResetZoom") - .foregroundColor(.accentColor) + .foregroundColor(viewModel.isMainWindow ? .accentColor : .secondary) } .buttonStyle(.plain) .help("Reset Split Zoom") diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 0c087faeb..9aa8ec2eb 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -145,6 +145,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { super.syncAppearance(surfaceConfig) // Update our window light/darkness based on our updated background color + let themeChanged = isLightTheme != OSColor(surfaceConfig.backgroundColor).isLightColor isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor // Update our titlebar color @@ -154,7 +155,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) } - if (isOpaque) { + if (isOpaque || themeChanged) { // If there is transparency, calling this will make the titlebar opaque // so we only call this if we are opaque. updateTabBar() @@ -187,41 +188,33 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // so we need to do it manually. private func updateNewTabButtonOpacity() { guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } - guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { - $0 as? NSImageView != nil - }) as? NSImageView else { return } + guard let newTabButtonImageView = newTabButton.firstDescendant(withClassName: "NSImageView") as? NSImageView else { return } newTabButtonImageView.alphaValue = isKeyWindow ? 1 : 0.5 } - // Color the new tab button's image to match the color of the tab title/keyboard shortcut labels, - // just as it does in the stock tab bar. + /// Update: This method only add a vibrant overlay now, + /// since the image itself supports light/dark tint, + /// and system could restore it any time, + /// altering it will only cause maintenance burden for us. + /// + /// And if we hide original image, + /// ``updateNewTabButtonOpacity`` will not work + /// + /// ~~Color the new tab button's image to match the color of the tab title/keyboard shortcut labels,~~ + /// ~~just as it does in the stock tab bar.~~ private func updateNewTabButtonImage() { guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return } - guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: { - $0 as? NSImageView != nil - }) as? NSImageView else { return } + guard let newTabButtonImageView = newTabButton.firstDescendant(withClassName: "NSImageView") as? NSImageView else { return } guard let newTabButtonImage = newTabButtonImageView.image else { return } + let imageLayer = newTabButtonImageLayer ?? VibrantLayer(forAppearance: isLightTheme ? .light : .dark)! + imageLayer.frame = NSRect(origin: NSPoint(x: newTabButton.bounds.midX - newTabButtonImage.size.width/2, y: newTabButton.bounds.midY - newTabButtonImage.size.height/2), size: newTabButtonImage.size) + imageLayer.contentsGravity = .resizeAspect + imageLayer.opacity = 0.5 - if newTabButtonImageLayer == nil { - let fillColor: NSColor = isLightTheme ? .black.withAlphaComponent(0.85) : .white.withAlphaComponent(0.85) - let newImage = NSImage(size: newTabButtonImage.size, flipped: false) { rect in - newTabButtonImage.draw(in: rect) - fillColor.setFill() - rect.fill(using: .sourceAtop) - return true - } - let imageLayer = VibrantLayer(forAppearance: isLightTheme ? .light : .dark)! - imageLayer.frame = NSRect(origin: NSPoint(x: newTabButton.bounds.midX - newTabButtonImage.size.width/2, y: newTabButton.bounds.midY - newTabButtonImage.size.height/2), size: newTabButtonImage.size) - imageLayer.contentsGravity = .resizeAspect - imageLayer.contents = newImage - imageLayer.opacity = 0.5 + newTabButtonImageLayer = imageLayer - newTabButtonImageLayer = imageLayer - } - - newTabButtonImageView.isHidden = true newTabButton.layer?.sublayers?.first(where: { $0.className == "VibrantLayer" })?.removeFromSuperlayer() newTabButton.layer?.addSublayer(newTabButtonImageLayer!) } @@ -452,6 +445,13 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } private func addWindowButtonsBackdrop(titlebarView: NSView, toolbarView: NSView) { + guard windowButtonsBackdrop?.superview != titlebarView else { + /// replacing existing backdrop aggressively + /// may cause incorrect hierarchy + /// + /// because multiple windows are adding this around the 'same time' + return + } windowButtonsBackdrop?.removeFromSuperview() windowButtonsBackdrop = nil @@ -470,16 +470,11 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { private func addWindowDragHandle(titlebarView: NSView, toolbarView: NSView) { // If we already made the view, just make sure it's unhidden and correctly placed as a subview. - if let view = windowDragHandle { - view.removeFromSuperview() - view.isHidden = false - titlebarView.superview?.addSubview(view) - view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true - view.rightAnchor.constraint(equalTo: toolbarView.rightAnchor).isActive = true - view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true - view.bottomAnchor.constraint(equalTo: toolbarView.topAnchor, constant: 12).isActive = true + guard windowDragHandle?.superview != titlebarView.superview else { + // similar to `addWindowButtonsBackdrop` return } + windowDragHandle?.removeFromSuperview() let view = WindowDragView() view.identifier = NSUserInterfaceItemIdentifier("_windowDragHandle") @@ -540,7 +535,10 @@ fileprivate class WindowButtonsBackdropView: NSView { // This must be weak because the window has this view. Otherwise // a retain cycle occurs. private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow? - private let isLightTheme: Bool + private var isLightTheme: Bool { + // using up-to-date value from hosting window directly + terminalWindow?.isLightTheme ?? false + } private let overlayLayer = VibrantLayer() var isHighlighted: Bool = true { @@ -569,7 +567,6 @@ fileprivate class WindowButtonsBackdropView: NSView { init(window: TitlebarTabsVenturaTerminalWindow) { self.terminalWindow = window - self.isLightTheme = window.isLightTheme super.init(frame: .zero) From 65f73f5d20637234058b633855a04c4cbaf14092 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 12 Oct 2025 07:31:54 -0700 Subject: [PATCH 093/702] font: Apply `adjust-icon-height` to both large and small icons (#9160) As pointed out in #9156, an unintended consequence of all the work to get icon sizing right is that `adjust-icon-height` now only applies to the small icons you get when the next cell is not whitespace. Large icons are unaffected. With this PR, `adjust-icon-height` affects the maximum height of every symbol specifying the `.icon` constraint height, regardless of constraint width. This includes most Nerd Font icons, but excludes emoji and other unicode symbols, and also excludes terminal graphics-oriented Nerd Font symbols such as Powerline symbols. In the following screenshots, **Baseline** is without `adjust-icon-height`, while **Before** and **After** are with `adjust-icon-height = -25%`. **Baseline** Screenshot 2025-10-11 at 23 28 20 **Before** (only small icons affected) Screenshot 2025-10-11 at 23 20 12 **After** (both small and large icons affected, but not emoji) Screenshot 2025-10-11 at 23 21 05 --- src/config/Config.zig | 15 +++++------ src/font/Collection.zig | 6 +++-- src/font/Metrics.zig | 59 +++++++++++++++++++++++++++++++++++++++-- src/font/face.zig | 23 +++++++++------- 4 files changed, 81 insertions(+), 22 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index a203a32a1..7d8c7d9ff 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -412,16 +412,13 @@ pub const compatibility = std.StaticStringMap( @"adjust-box-thickness": ?MetricModifier = null, /// Height in pixels or percentage adjustment of maximum height for nerd font icons. /// -/// Increasing this value will allow nerd font icons to be larger, but won't -/// necessarily force them to be. Decreasing this value will make nerd font -/// icons smaller. +/// A positive (negative) value will increase (decrease) the maximum icon +/// height. This may not affect all icons equally: the effect depends on whether +/// the default size of the icon is height-constrained, which in turn depends on +/// the aspect ratio of both the icon and your primary font. /// -/// This value only applies to icons that are constrained to a single cell by -/// neighboring characters. An icon that is free to spread across two cells -/// can always use up to the full line height of the primary font. -/// -/// The default value is 2/3 times the height of capital letters in your primary -/// font plus 1/3 times the font's line height. +/// Certain icons designed for box drawing and terminal graphics, such as +/// Powerline symbols, are not affected by this option. /// /// See the notes about adjustments in `adjust-cell-width`. /// diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 5ec076608..b587245aa 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -1228,7 +1228,8 @@ test "metrics" { .overline_thickness = 1, .box_thickness = 1, .cursor_height = 17, - .icon_height = 12.24, + .icon_height = 16.784, + .icon_height_single = 12.24, .face_width = 8.0, .face_height = 16.784, .face_y = -0.04, @@ -1248,7 +1249,8 @@ test "metrics" { .overline_thickness = 2, .box_thickness = 2, .cursor_height = 34, - .icon_height = 24.48, + .icon_height = 33.568, + .icon_height_single = 24.48, .face_width = 16.0, .face_height = 33.568, .face_y = -0.08, diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index 668b6f15f..ec89763ea 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -38,6 +38,9 @@ cursor_height: u32, /// The constraint height for nerd fonts icons. icon_height: f64, +/// The constraint height for nerd fonts icons limited to a single cell width. +icon_height_single: f64, + /// The unrounded face width, used in scaling calculations. face_width: f64, @@ -60,6 +63,7 @@ const Minimums = struct { const cursor_thickness = 1; const cursor_height = 1; const icon_height = 1.0; + const icon_height_single = 1.0; const face_height = 1.0; const face_width = 1.0; }; @@ -251,8 +255,11 @@ pub fn calc(face: FaceMetrics) Metrics { const underline_position = @round(top_to_baseline - face.underlinePosition()); const strikethrough_position = @round(top_to_baseline - face.strikethroughPosition()); - // Same heuristic as the font_patcher script - const icon_height = (2 * cap_height + face_height) / 3; + // Same heuristic as the font_patcher script. We store icon_height + // separately from face_height such that modifiers can apply to the former + // without affecting the latter. + const icon_height = face_height; + const icon_height_single = (2 * cap_height + face_height) / 3; var result: Metrics = .{ .cell_width = @intFromFloat(cell_width), @@ -267,6 +274,7 @@ pub fn calc(face: FaceMetrics) Metrics { .box_thickness = @intFromFloat(underline_thickness), .cursor_height = @intFromFloat(cell_height), .icon_height = icon_height, + .icon_height_single = icon_height_single, .face_width = face_width, .face_height = face_height, .face_y = face_y, @@ -328,6 +336,10 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { } } }, + inline .icon_height => { + self.icon_height = entry.value_ptr.apply(self.icon_height); + self.icon_height_single = entry.value_ptr.apply(self.icon_height_single); + }, inline else => |tag| { @field(self, @tagName(tag)) = entry.value_ptr.apply(@field(self, @tagName(tag))); @@ -529,6 +541,7 @@ fn init() Metrics { .box_thickness = 0, .cursor_height = 0, .icon_height = 0.0, + .icon_height_single = 0.0, .face_width = 0.0, .face_height = 0.0, .face_y = 0.0, @@ -609,6 +622,48 @@ test "Metrics: adjust cell height larger" { try testing.expectEqual(@as(u32, 100), m.cursor_height); } +test "Metrics: adjust icon height by percentage" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: ModifierSet = .{}; + defer set.deinit(alloc); + try set.put(alloc, .icon_height, .{ .percent = 0.75 }); + + var m: Metrics = init(); + m.icon_height = 100.0; + m.icon_height_single = 80.0; + m.face_height = 100.0; + m.face_y = 1.0; + m.apply(set); + try testing.expectEqual(75.0, m.icon_height); + try testing.expectEqual(60.0, m.icon_height_single); + // Face metrics not affected + try testing.expectEqual(100.0, m.face_height); + try testing.expectEqual(1.0, m.face_y); +} + +test "Metrics: adjust icon height by absolute pixels" { + const testing = std.testing; + const alloc = testing.allocator; + + var set: ModifierSet = .{}; + defer set.deinit(alloc); + try set.put(alloc, .icon_height, .{ .absolute = -5 }); + + var m: Metrics = init(); + m.icon_height = 100.0; + m.icon_height_single = 80.0; + m.face_height = 100.0; + m.face_y = 1.0; + m.apply(set); + try testing.expectEqual(95.0, m.icon_height); + try testing.expectEqual(75.0, m.icon_height_single); + // Face metrics not affected + try testing.expectEqual(100.0, m.face_height); + try testing.expectEqual(1.0, m.face_y); +} + test "Modifier: parse absolute" { const testing = std.testing; diff --git a/src/font/face.zig b/src/font/face.zig index 586de4ad6..a1312c45a 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -216,11 +216,13 @@ pub const RenderOptions = struct { }; pub const Height = enum { - /// Always use the full height of the cell for constraining this glyph. + /// Use the full line height of the primary face for + /// constraining this glyph. cell, - /// When the constraint width is 1, use the "icon height" from the grid - /// metrics as the height. (When the constraint width is >1, the - /// constraint height is always the full cell height.) + /// Use the icon height from the grid metrics for + /// constraining this glyph. Unlike `cell`, the value of + /// this height depends on both the constraint width and the + /// affected by the `adjust-icon-height` config option. icon, }; @@ -346,12 +348,14 @@ pub const RenderOptions = struct { const target_width = pad_width_factor * metrics.face_width; const target_height = pad_height_factor * switch (self.height) { .cell => metrics.face_height, - // icon_height only applies with single-cell constraints. - // This mirrors font_patcher. + // Like font-patcher, the icon constraint height depends on the + // constraint width. Unlike font-patcher, the multi-cell + // icon_height may be different from face_height due to the + // `adjust-icon-height` config option. .icon => if (multi_cell) - metrics.face_height + metrics.icon_height else - metrics.icon_height, + metrics.icon_height_single, }; var width_factor = target_width / group.width; @@ -528,7 +532,8 @@ test "Constraints" { .box_thickness = 1, .cursor_thickness = 1, .cursor_height = 22, - .icon_height = 44.48 / 3.0, + .icon_height = 21.12, + .icon_height_single = 44.48 / 3.0, .face_width = 9.6, .face_height = 21.12, .face_y = 0.2, From 5efb915771e3eec18592d25d57a7a82c1f7482b9 Mon Sep 17 00:00:00 2001 From: Alan Wu Date: Sun, 12 Oct 2025 13:55:21 -0400 Subject: [PATCH 094/702] Fix fish shell cursor integration in fish vi mode (#9157) Previously, the fish shell integration interfered with fish's builtin vi mode cursor switching configurations such as `$fish_cursor_default` and `$fish_cursor_insert`. ```console $ ghostty --config-default-files=false -e fish --no-config --init-command 'source "$GHOSTTY_RESOURCES_DIR"/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish; fish_vi_key_bindings' ``` The above command starts fish in vi mode with Ghostty shell integrations. Manually loading the integration is necessary due to `--no-config` blocking auto injection. 1. At the prompt, fish is in insert mode, and the cursor is a blinking beam. However, press escape and then "i" to exit then re-enter insert mode, and the cursor will be a solid beam due to the `$fish_cursor_unknown` setting. Without the shell integration, insert mode always uses a solid beam cursor. 2. A similar problem shows if we start fish with `fish_vi_key_bindings default`. The cursor ends up as a blinking beam in normal mode only due to the shell integration interfering. This glitch can also be reset away by entering then exiting insert mode. 3. Also, `$fish_cursor_external` has no effect when used with shell integration. After `fish_vi_key_bindings`, set it to `line`, run cat(1), and shell integration will give you a blinking block, not the asked for line/beam. I verified that this patch makes the shell integration stop interfering in three scenarios above, and it still changes the cursor when not using fish's vi mode. Note that `$fish_cursor_*` variables can be set when fish isn't in vi mode, so they're not great signals for the shell integration hooks. --- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 7042f892a..f745bbb13 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -54,10 +54,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if contains cursor $features # Change the cursor to a beam on prompt. function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape" - echo -en "\e[5 q" + if not functions -q fish_vi_cursor_handle + echo -en "\e[5 q" + end end function __ghostty_reset_cursor --on-event fish_preexec -d "Reset cursor shape" - echo -en "\e[0 q" + if not functions -q fish_vi_cursor_handle + echo -en "\e[0 q" + end end end From 8d8821004ecf288d6d97fe019df72558e088da2f Mon Sep 17 00:00:00 2001 From: Xiangbao Meng <134181853+bo2themax@users.noreply.github.com> Date: Sun, 12 Oct 2025 22:04:18 +0200 Subject: [PATCH 095/702] macOS: fix title misalignment in tabs (#9168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While I was testing #9166, noticed another edge case🤯. This appears both in Tahoe and Sequoia👇🏻 https://github.com/user-attachments/assets/9cecea35-1241-4f31-9c15-0f2a7a6f342a --- .../Window Styles/TerminalWindow.swift | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 8249c6cf7..16fcf227f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -205,9 +205,16 @@ class TerminalWindow: NSWindow { /// Returns true if there is a tab bar visible on this window. var hasTabBar: Bool { + // TODO: use titlebarView to find it instead contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil } + var hasMoreThanOneTabs: Bool { + /// accessing ``tabGroup?.windows`` here + /// will cause other edge cases, be careful + (tabbedWindows?.count ?? 0) > 1 + } + func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool { if childViewController.identifier == nil { // The good case @@ -321,6 +328,12 @@ class TerminalWindow: NSWindow { // Whenever we change the window title we must also update our // tab title if we're using custom fonts. tab.attributedTitle = attributedTitle + /// We also needs to update this here, just in case + /// the value is not what we want + /// + /// Check ``titlebarFont`` down below + /// to see why we need to check `hasMoreThanOneTabs` here + titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs } } @@ -330,7 +343,12 @@ class TerminalWindow: NSWindow { let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize) titlebarTextField?.font = font - titlebarTextField?.usesSingleLineMode = true + /// We check `hasMoreThanOneTabs` here because the system + /// may copy this setting to the tab’s text field at some point(e.g. entering/exiting fullscreen), + /// which can cause the title to be vertically misaligned (shifted downward). + /// + /// This behaviour is the opposite of what happens in the title bar’s text field, which is quite odd... + titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs tab.attributedTitle = attributedTitle } } From 03e71e86a486c09b7f3d24a1cc62f977d14f47c4 Mon Sep 17 00:00:00 2001 From: Xiangbao Meng <134181853+bo2themax@users.noreply.github.com> Date: Sun, 12 Oct 2025 22:07:29 +0200 Subject: [PATCH 096/702] macOS: distinguish between Debug and Release(Stable/Tip) (#9149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Background Been running Ghostty locally for a while now, and I use the Finder service a lot. It often confuses me which one is the official one, until I actually open it. ### Changes - Use blueprint to distinguish from release app, if no custom icon specified - Change BundleDisplayName to Ghostty[Debug] - Enable Info.plist preprocessing for reading `$(INFOPLIST_KEY_CFBundleDisplayName)` for providing different services with different configurations > (Preprocessing was once reverted before](https://github.com/ghostty-org/ghostty/commit/6508fec), so I'm not sure whether this follows the 'rules' here, but for now, there are no links in the plist file, so I think it’s [safe](https://developer.apple.com/library/archive/technotes/tn2175/_index.html#:~:text=can%20pass%20the-,%2Dtraditional,-flag%20to%20the) to enable it --- macos/Ghostty-Info.plist | 4 ++-- macos/Ghostty.xcodeproj/project.pbxproj | 7 +++++-- macos/Sources/App/macOS/AppDelegate.swift | 5 +++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index ff391c0f8..2bf3b0bae 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -61,7 +61,7 @@ NSMenuItem default - New Ghostty Tab Here + New $(INFOPLIST_KEY_CFBundleDisplayName) Tab Here NSMessage openTab @@ -80,7 +80,7 @@ NSMenuItem default - New Ghostty Window Here + New $(INFOPLIST_KEY_CFBundleDisplayName) Window Here NSMessage openWindow diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index c2baf834d..388122f62 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -553,6 +553,7 @@ INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; + INFOPLIST_PREPROCESS = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -775,7 +776,7 @@ EXECUTABLE_NAME = ghostty; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Ghostty-Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = Ghostty; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty[DEBUG]"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth."; @@ -793,6 +794,7 @@ INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; + INFOPLIST_PREPROCESS = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -847,6 +849,7 @@ INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; + INFOPLIST_PREPROCESS = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -865,7 +868,7 @@ A5D449A82B53AE7B000F5B83 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; + ASSETCATALOG_COMPILER_APPICON_NAME = "Ghostty-Debug"; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 6f387f4ae..3319189b9 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -121,7 +121,12 @@ class AppDelegate: NSObject, /// The custom app icon image that is currently in use. @Published private(set) var appIcon: NSImage? = nil { didSet { +#if DEBUG + // if no custom icon specified, we use blueprint to distinguish from release app + NSApplication.shared.applicationIconImage = appIcon ?? NSImage(named: "BlueprintImage") +#else NSApplication.shared.applicationIconImage = appIcon +#endif let appPath = Bundle.main.bundlePath NSWorkspace.shared.setIcon(appIcon, forFile: appPath, options: []) NSWorkspace.shared.noteFileSystemChanged(appPath) From 37b3c270204e903b493593c2117d4a6e5b32a293 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 12 Oct 2025 15:11:52 -0500 Subject: [PATCH 097/702] synthetic: use std.Io.Writer for more of the interface (#9038) --- src/synthetic/Bytes.zig | 28 +++++++----- src/synthetic/Generator.zig | 14 +++--- src/synthetic/Osc.zig | 88 +++++++++++++++++++++---------------- src/synthetic/Utf8.zig | 23 ++++++---- src/synthetic/cli.zig | 4 +- src/synthetic/cli/Ascii.zig | 4 +- src/synthetic/cli/Osc.zig | 13 +++--- src/synthetic/cli/Utf8.zig | 4 +- 8 files changed, 98 insertions(+), 80 deletions(-) diff --git a/src/synthetic/Bytes.zig b/src/synthetic/Bytes.zig index 8a8207ba9..40a94e0e3 100644 --- a/src/synthetic/Bytes.zig +++ b/src/synthetic/Bytes.zig @@ -27,27 +27,35 @@ pub fn generator(self: *Bytes) Generator { return .init(self, next); } -pub fn next(self: *Bytes, buf: []u8) Generator.Error![]const u8 { +pub fn next(self: *Bytes, writer: *std.Io.Writer, max_len: usize) Generator.Error!void { + std.debug.assert(max_len >= 1); const len = @min( self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), - buf.len, + max_len, ); - const result = buf[0..len]; - self.rand.bytes(result); - if (self.alphabet) |alphabet| { - for (result) |*byte| byte.* = alphabet[byte.* % alphabet.len]; + var buf: [8]u8 = undefined; + var remaining = len; + while (remaining > 0) { + const data = buf[0..@min(remaining, buf.len)]; + self.rand.bytes(data); + if (self.alphabet) |alphabet| { + for (data) |*byte| byte.* = alphabet[byte.* % alphabet.len]; + } + try writer.writeAll(data); + remaining -= data.len; } - - return result; } test "bytes" { const testing = std.testing; var prng = std.Random.DefaultPrng.init(0); var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); var v: Bytes = .{ .rand = prng.random() }; + v.min_len = buf.len; + v.max_len = buf.len; const gen = v.generator(); - const result = try gen.next(&buf); - try testing.expect(result.len > 0); + try gen.next(&writer, buf.len); + try testing.expectEqual(buf.len, writer.buffered().len); } diff --git a/src/synthetic/Generator.zig b/src/synthetic/Generator.zig index 7478a54c3..28929ecbe 100644 --- a/src/synthetic/Generator.zig +++ b/src/synthetic/Generator.zig @@ -6,27 +6,27 @@ const assert = std.debug.assert; /// For generators, this is the only error that is allowed to be /// returned by the next function. -pub const Error = error{NoSpaceLeft}; +pub const Error = error{WriteFailed}; /// The vtable for the generator. ptr: *anyopaque, -nextFn: *const fn (ptr: *anyopaque, buf: []u8) Error![]const u8, +nextFn: *const fn (ptr: *anyopaque, *std.Io.Writer, usize) Error!void, /// Create a new generator from a pointer and a function pointer. /// This usually is only called by generator implementations, not /// generator users. pub fn init( pointer: anytype, - comptime nextFn: fn (ptr: @TypeOf(pointer), buf: []u8) Error![]const u8, + comptime nextFn: fn (ptr: @TypeOf(pointer), *std.Io.Writer, usize) Error!void, ) Generator { const Ptr = @TypeOf(pointer); assert(@typeInfo(Ptr) == .pointer); // Must be a pointer assert(@typeInfo(Ptr).pointer.size == .one); // Must be a single-item pointer assert(@typeInfo(@typeInfo(Ptr).pointer.child) == .@"struct"); // Must point to a struct const gen = struct { - fn next(ptr: *anyopaque, buf: []u8) Error![]const u8 { + fn next(ptr: *anyopaque, writer: *std.Io.Writer, max_len: usize) Error!void { const self: Ptr = @ptrCast(@alignCast(ptr)); - return try nextFn(self, buf); + try nextFn(self, writer, max_len); } }; @@ -37,6 +37,6 @@ pub fn init( } /// Get the next value from the generator. Returns the data written. -pub fn next(self: Generator, buf: []u8) Error![]const u8 { - return try self.nextFn(self.ptr, buf); +pub fn next(self: Generator, writer: *std.Io.Writer, max_size: usize) Error!void { + try self.nextFn(self.ptr, writer, max_size); } diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig index 8d5d7d3a2..d78b95a1e 100644 --- a/src/synthetic/Osc.zig +++ b/src/synthetic/Osc.zig @@ -53,6 +53,9 @@ pub fn generator(self: *Osc) Generator { return .init(self, next); } +const osc = std.fmt.comptimePrint("{c}]", .{std.ascii.control_code.esc}); +const st = std.fmt.comptimePrint("{c}", .{std.ascii.control_code.bel}); + /// Get the next OSC request in bytes. The generated OSC request will /// have the prefix `ESC ]` and the terminator `BEL` (0x07). /// @@ -63,23 +66,22 @@ pub fn generator(self: *Osc) Generator { /// /// The buffer must be at least 3 bytes long to accommodate the /// prefix and terminator. -pub fn next(self: *Osc, buf: []u8) Generator.Error![]const u8 { - if (buf.len < 3) return error.NoSpaceLeft; - const unwrapped = try self.nextUnwrapped(buf[2 .. buf.len - 1]); - buf[0] = 0x1B; // ESC - buf[1] = ']'; - buf[unwrapped.len + 2] = 0x07; // BEL - return buf[0 .. unwrapped.len + 3]; +pub fn next(self: *Osc, writer: *std.Io.Writer, max_len: usize) Generator.Error!void { + assert(max_len >= 3); + try writer.writeAll(osc); + try self.nextUnwrapped(writer, max_len - (osc.len + st.len)); + try writer.writeAll(st); } -fn nextUnwrapped(self: *Osc, buf: []u8) Generator.Error![]const u8 { +fn nextUnwrapped(self: *Osc, writer: *std.Io.Writer, max_len: usize) Generator.Error!void { return switch (self.chooseValidity()) { .valid => valid: { const Indexer = @TypeOf(self.p_valid_kind).Indexer; const idx = self.rand.weightedIndex(f64, &self.p_valid_kind.values); break :valid try self.nextUnwrappedValidExact( - buf, + writer, Indexer.keyForIndex(idx), + max_len, ); }, @@ -87,70 +89,64 @@ fn nextUnwrapped(self: *Osc, buf: []u8) Generator.Error![]const u8 { const Indexer = @TypeOf(self.p_invalid_kind).Indexer; const idx = self.rand.weightedIndex(f64, &self.p_invalid_kind.values); break :invalid try self.nextUnwrappedInvalidExact( - buf, + writer, Indexer.keyForIndex(idx), + max_len, ); }, }; } -fn nextUnwrappedValidExact(self: *const Osc, buf: []u8, k: ValidKind) Generator.Error![]const u8 { - var fbs = std.io.fixedBufferStream(buf); +fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKind, max_len: usize) Generator.Error!void { switch (k) { .change_window_title => { - try fbs.writer().writeAll("0;"); // Set window title + try writer.writeAll("0;"); // Set window title var bytes_gen = self.bytes(); - const title = try bytes_gen.next(fbs.buffer[fbs.pos..]); - try fbs.seekBy(@intCast(title.len)); + try bytes_gen.next(writer, max_len - 2); }, .prompt_start => { - try fbs.writer().writeAll("133;A"); // Start prompt + try writer.writeAll("133;A"); // Start prompt // aid if (self.rand.boolean()) { var bytes_gen = self.bytes(); bytes_gen.max_len = 16; - try fbs.writer().writeAll(";aid="); - const aid = try bytes_gen.next(fbs.buffer[fbs.pos..]); - try fbs.seekBy(@intCast(aid.len)); + try writer.writeAll(";aid="); + try bytes_gen.next(writer, max_len); } // redraw if (self.rand.boolean()) { - try fbs.writer().writeAll(";redraw="); + try writer.writeAll(";redraw="); if (self.rand.boolean()) { - try fbs.writer().writeAll("1"); + try writer.writeAll("1"); } else { - try fbs.writer().writeAll("0"); + try writer.writeAll("0"); } } }, - .prompt_end => try fbs.writer().writeAll("133;B"), // End prompt + .prompt_end => try writer.writeAll("133;B"), // End prompt } - - return fbs.getWritten(); } fn nextUnwrappedInvalidExact( self: *const Osc, - buf: []u8, + writer: *std.Io.Writer, k: InvalidKind, -) Generator.Error![]const u8 { + max_len: usize, +) Generator.Error!void { switch (k) { .random => { var bytes_gen = self.bytes(); - return try bytes_gen.next(buf); + try bytes_gen.next(writer, max_len); }, .good_prefix => { - var fbs = std.io.fixedBufferStream(buf); - try fbs.writer().writeAll("133;"); + try writer.writeAll("133;"); var bytes_gen = self.bytes(); - const data = try bytes_gen.next(fbs.buffer[fbs.pos..]); - try fbs.seekBy(@intCast(data.len)); - return fbs.getWritten(); + try bytes_gen.next(writer, max_len - 4); }, } } @@ -177,11 +173,21 @@ const Validity = enum { valid, invalid }; const test_seed = 0xC0FFEEEEEEEEEEEE; test "OSC generator" { + const testing = std.testing; var prng = std.Random.DefaultPrng.init(test_seed); - var buf: [4096]u8 = undefined; - var v: Osc = .{ .rand = prng.random() }; - const gen = v.generator(); - for (0..50) |_| _ = try gen.next(&buf); + var buf: [256]u8 = undefined; + { + var v: Osc = .{ + .rand = prng.random(), + }; + const gen = v.generator(); + for (0..50) |_| { + var writer: std.Io.Writer = .fixed(&buf); + try gen.next(&writer, buf.len); + const result = writer.buffered(); + try testing.expect(result.len > 0); + } + } } test "OSC generator valid" { @@ -195,7 +201,9 @@ test "OSC generator valid" { .p_valid = 1.0, }; for (0..50) |_| { - const seq = try gen.next(&buf); + var writer: std.Io.Writer = .fixed(&buf); + try gen.next(&writer, buf.len); + const seq = writer.buffered(); var parser: terminal.osc.Parser = .init(); for (seq[2 .. seq.len - 1]) |c| parser.next(c); try testing.expect(parser.end(null) != null); @@ -213,7 +221,9 @@ test "OSC generator invalid" { .p_valid = 0.0, }; for (0..50) |_| { - const seq = try gen.next(&buf); + var writer: std.Io.Writer = .fixed(&buf); + try gen.next(&writer, buf.len); + const seq = writer.buffered(); var parser: terminal.osc.Parser = .init(); for (seq[2 .. seq.len - 1]) |c| parser.next(c); try testing.expect(parser.end(null) == null); diff --git a/src/synthetic/Utf8.zig b/src/synthetic/Utf8.zig index c3ace6505..0d72a8bb2 100644 --- a/src/synthetic/Utf8.zig +++ b/src/synthetic/Utf8.zig @@ -41,13 +41,12 @@ pub fn generator(self: *Utf8) Generator { return .init(self, next); } -pub fn next(self: *Utf8, buf: []u8) Generator.Error![]const u8 { +pub fn next(self: *Utf8, writer: *std.Io.Writer, max_len: usize) Generator.Error!void { const len = @min( self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), - buf.len, + max_len, ); - const result = buf[0..len]; var rem: usize = len; while (rem > 0) { // Pick a utf8 byte count to generate. @@ -75,9 +74,11 @@ pub fn next(self: *Utf8, buf: []u8) Generator.Error![]const u8 { assert(std.unicode.utf8CodepointSequenceLength( cp, ) catch unreachable == @intFromEnum(utf8_len)); - rem -= std.unicode.utf8Encode( + + var buf: [4]u8 = undefined; + const l = std.unicode.utf8Encode( cp, - result[result.len - rem ..], + &buf, ) catch |err| switch (err) { // Impossible because our generation above is hardcoded to // produce a valid range. If not, a bug. @@ -86,18 +87,22 @@ pub fn next(self: *Utf8, buf: []u8) Generator.Error![]const u8 { // Possible, in which case we redo the loop and encode nothing. error.Utf8CannotEncodeSurrogateHalf => continue, }; + try writer.writeAll(buf[0..l]); + rem -= l; } - - return result; } test "utf8" { const testing = std.testing; var prng = std.Random.DefaultPrng.init(0); var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); var v: Utf8 = .{ .rand = prng.random() }; + v.min_len = buf.len; + v.max_len = buf.len; const gen = v.generator(); - const result = try gen.next(&buf); - try testing.expect(result.len > 0); + try gen.next(&writer, buf.len); + const result = writer.buffered(); + try testing.expectEqual(256, result.len); try testing.expect(std.unicode.utf8ValidateSlice(result)); } diff --git a/src/synthetic/cli.zig b/src/synthetic/cli.zig index b32469aab..d9b6a659d 100644 --- a/src/synthetic/cli.zig +++ b/src/synthetic/cli.zig @@ -100,7 +100,9 @@ fn mainActionImpl( try impl.run(writer, rand); // Always flush - try writer.flush(); + writer.flush() catch |err| switch (err) { + error.WriteFailed => return, + }; } test { diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index 339bdee2e..b2d57fa88 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -31,10 +31,8 @@ pub fn run(self: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { .alphabet = synthetic.Bytes.Alphabet.ascii, }; - var buf: [1024]u8 = undefined; while (true) { - const data = try gen.next(&buf); - writer.writeAll(data) catch |err| { + gen.next(writer, 1024) catch |err| { const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); switch (@as(Error, err)) { error.BrokenPipe => return, // stdout closed diff --git a/src/synthetic/cli/Osc.zig b/src/synthetic/cli/Osc.zig index 23d19e4ae..8250b81de 100644 --- a/src/synthetic/cli/Osc.zig +++ b/src/synthetic/cli/Osc.zig @@ -37,14 +37,11 @@ pub fn run(self: *Osc, writer: *std.Io.Writer, rand: std.Random) !void { var buf: [1024]u8 = undefined; while (true) { - const data = try gen.next(&buf); - writer.writeAll(data) catch |err| { - const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); - switch (@as(Error, err)) { - error.BrokenPipe => return, // stdout closed - error.WriteFailed => return, // fixed buffer full - else => return err, - } + var fixed: std.Io.Writer = .fixed(&buf); + try gen.next(&fixed, buf.len); + const data = fixed.buffered(); + writer.writeAll(data) catch |err| switch (err) { + error.WriteFailed => return, }; } } diff --git a/src/synthetic/cli/Utf8.zig b/src/synthetic/cli/Utf8.zig index 3c2fddef7..635704755 100644 --- a/src/synthetic/cli/Utf8.zig +++ b/src/synthetic/cli/Utf8.zig @@ -30,10 +30,8 @@ pub fn run(self: *Utf8, writer: *std.Io.Writer, rand: std.Random) !void { .rand = rand, }; - var buf: [1024]u8 = undefined; while (true) { - const data = try gen.next(&buf); - writer.writeAll(data) catch |err| { + gen.next(writer, 1024) catch |err| { const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); switch (@as(Error, err)) { error.BrokenPipe => return, // stdout closed From cbeb6890c9ec4962ec7f5eae9506982d8eac994a Mon Sep 17 00:00:00 2001 From: Joshie <74162303+CoderJoshDK@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:48:06 -0400 Subject: [PATCH 098/702] Add `.ghostty` extension to `config` (#8885) Resolves #8689 For various reason, ghostty wants to have a unique file extension for the config files. The name was settled on `config.ghostty`. This will help with tooling. See #8438 (original discussion) for more details. This PR introduces the preferred default of `.ghostty` while still supporting the previous `config` file. If both files exist, a warning log is sent. The docs / website will need to be updated to reflect this change. > [!NOTE] > Only tested on macOS 26.0. --------- Co-authored-by: Mitchell Hashimoto --- .../Features/Settings/SettingsView.swift | 2 +- src/build/GhosttyResources.zig | 2 +- src/build/mdgen/ghostty_1_footer.md | 6 +- src/build/mdgen/ghostty_5_footer.md | 6 +- src/build/mdgen/ghostty_5_header.md | 4 +- src/cli/edit_config.zig | 6 +- src/config.zig | 2 + src/config/Config.zig | 146 +++++---------- src/config/edit.zig | 15 +- src/config/file_load.zig | 166 ++++++++++++++++++ src/extra/vim.zig | 2 +- 11 files changed, 228 insertions(+), 129 deletions(-) create mode 100644 src/config/file_load.zig diff --git a/macos/Sources/Features/Settings/SettingsView.swift b/macos/Sources/Features/Settings/SettingsView.swift index 82d24181a..6b0a2c46c 100644 --- a/macos/Sources/Features/Settings/SettingsView.swift +++ b/macos/Sources/Features/Settings/SettingsView.swift @@ -14,7 +14,7 @@ struct SettingsView: View { VStack(alignment: .leading) { Text("Coming Soon. 🚧").font(.title) Text("You can't configure settings in the GUI yet. To modify settings, " + - "edit the file at $HOME/.config/ghostty/config and restart Ghostty.") + "edit the file at $HOME/.config/ghostty/config.ghostty and restart Ghostty.") .multilineTextAlignment(.leading) .lineLimit(nil) } diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 7880a98a0..b80aef97e 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -225,7 +225,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // 'ghostty.sublime-syntax' file from zig-out to the '~.config/bat/syntaxes' // directory. The syntax then needs to be mapped to the correct language in // the config file within the '~.config/bat' directory - // (ex: --map-syntax "/Users/user/.config/ghostty/config:Ghostty Config"). + // (ex: --map-syntax "/Users/user/.config/ghostty/config.ghostty:Ghostty Config"). { const run = b.addRunArtifact(build_data_exe); run.addArg("+sublime"); diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index f8e502b45..88aa16273 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -1,15 +1,15 @@ # FILES -_\$XDG_CONFIG_HOME/ghostty/config_ +_\$XDG_CONFIG_HOME/ghostty/config.ghostty_ : Location of the default configuration file. -_\$HOME/Library/Application Support/com.mitchellh.ghostty/config_ +_\$HOME/Library/Application Support/com.mitchellh.ghostty/config.ghostty_ : **On macOS**, location of the default configuration file. This location takes precedence over the XDG environment locations. -_\$LOCALAPPDATA/ghostty/config_ +_\$LOCALAPPDATA/ghostty/config.ghostty_ : **On Windows**, if _\$XDG_CONFIG_HOME_ is not set, _\$LOCALAPPDATA_ will be searched for configuration files. diff --git a/src/build/mdgen/ghostty_5_footer.md b/src/build/mdgen/ghostty_5_footer.md index 380d83a53..d2cf024d1 100644 --- a/src/build/mdgen/ghostty_5_footer.md +++ b/src/build/mdgen/ghostty_5_footer.md @@ -1,15 +1,15 @@ # FILES -_\$XDG_CONFIG_HOME/ghostty/config_ +_\$XDG_CONFIG_HOME/ghostty/config.ghostty_ : Location of the default configuration file. -_\$HOME/Library/Application Support/com.mitchellh.ghostty/config_ +_\$HOME/Library/Application Support/com.mitchellh.ghostty/config.ghostty_ : **On macOS**, location of the default configuration file. This location takes precedence over the XDG environment locations. -_\$LOCALAPPDATA/ghostty/config_ +_\$LOCALAPPDATA/ghostty/config.ghostty_ : **On Windows**, if _\$XDG_CONFIG_HOME_ is not set, _\$LOCALAPPDATA_ will be searched for configuration files. diff --git a/src/build/mdgen/ghostty_5_header.md b/src/build/mdgen/ghostty_5_header.md index 078133861..b9d4cb751 100644 --- a/src/build/mdgen/ghostty_5_header.md +++ b/src/build/mdgen/ghostty_5_header.md @@ -8,11 +8,11 @@ To configure Ghostty, you must use a configuration file. GUI-based configuration is on the roadmap but not yet supported. The configuration file must be placed -at `$XDG_CONFIG_HOME/ghostty/config`, which defaults to `~/.config/ghostty/config` +at `$XDG_CONFIG_HOME/ghostty/config.ghostty`, which defaults to `~/.config/ghostty/config.ghostty` if the [XDG environment is not set](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html). **If you are using macOS, the configuration file can also be placed at -`$HOME/Library/Application Support/com.mitchellh.ghostty/config`.** This is the +`$HOME/Library/Application Support/com.mitchellh.ghostty/config.ghostty`.** This is the default configuration location for macOS. It will be searched before any of the XDG environment locations listed above. diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig index f103ca4a0..37f961a44 100644 --- a/src/cli/edit_config.zig +++ b/src/cli/edit_config.zig @@ -30,9 +30,9 @@ pub const Options = struct { /// this yet. /// /// The filepath opened is the default user-specific configuration -/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config`. +/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config.ghostty`. /// On macOS, this may also be located at -/// `~/Library/Application Support/com.mitchellh.ghostty/config`. +/// `~/Library/Application Support/com.mitchellh.ghostty/config.ghostty`. /// On macOS, whichever path exists and is non-empty will be prioritized, /// prioritizing the Application Support directory if neither are /// non-empty. @@ -73,7 +73,7 @@ fn runInner(alloc: Allocator, stderr: *std.Io.Writer) !u8 { defer config.deinit(); // Find the preferred path. - const path = try Config.preferredDefaultFilePath(alloc); + const path = try configpkg.preferredDefaultFilePath(alloc); defer alloc.free(path); // We don't currently support Windows because we use the exec syscall. diff --git a/src/config.zig b/src/config.zig index a596eb5e6..4abd319a6 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,5 +1,6 @@ const builtin = @import("builtin"); +const file_load = @import("config/file_load.zig"); const formatter = @import("config/formatter.zig"); pub const Config = @import("config/Config.zig"); pub const conditional = @import("config/conditional.zig"); @@ -12,6 +13,7 @@ pub const ConditionalState = conditional.State; pub const FileFormatter = formatter.FileFormatter; pub const entryFormatter = formatter.entryFormatter; pub const formatEntry = formatter.formatEntry; +pub const preferredDefaultFilePath = file_load.preferredDefaultFilePath; // Field types pub const BoldColor = Config.BoldColor; diff --git a/src/config/Config.zig b/src/config/Config.zig index 7d8c7d9ff..aabefcd8f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -24,6 +24,7 @@ const cli = @import("../cli.zig"); const conditional = @import("conditional.zig"); const Conditional = conditional.Conditional; +const file_load = @import("file_load.zig"); const formatterpkg = @import("formatter.zig"); const themepkg = @import("theme.zig"); const url = @import("url.zig"); @@ -2071,7 +2072,7 @@ keybind: Keybinds = .{}, /// When this is true, the default configuration file paths will be loaded. /// The default configuration file paths are currently only the XDG -/// config path ($XDG_CONFIG_HOME/ghostty/config). +/// config path ($XDG_CONFIG_HOME/ghostty/config.ghostty). /// /// If this is false, the default configuration paths will not be loaded. /// This is targeted directly at using Ghostty from the CLI in a way @@ -3397,7 +3398,7 @@ pub fn loadIter( /// `path` must be resolved and absolute. pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { assert(std.fs.path.isAbsolute(path)); - var file = openFile(path) catch |err| switch (err) { + var file = file_load.open(path) catch |err| switch (err) { error.NotAFile => { log.warn( "config-file {s}: not reading because it is not a file", @@ -3461,31 +3462,60 @@ fn writeConfigTemplate(path: []const u8) !void { } /// Load configurations from the default configuration files. The default -/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config`. +/// configuration file is at `$XDG_CONFIG_HOME/ghostty/config.ghostty`. /// -/// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config` +/// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/` /// is also loaded. +/// +/// The legacy `config` file (without extension) is first loaded, +/// then `config.ghostty`. pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { // Load XDG first - const xdg_path = try defaultXdgPath(alloc); + const legacy_xdg_path = try file_load.legacyDefaultXdgPath(alloc); + defer alloc.free(legacy_xdg_path); + const xdg_path = try file_load.defaultXdgPath(alloc); defer alloc.free(xdg_path); - const xdg_action = self.loadOptionalFile(alloc, xdg_path); + const xdg_loaded: bool = xdg_loaded: { + const legacy_xdg_action = self.loadOptionalFile(alloc, legacy_xdg_path); + const xdg_action = self.loadOptionalFile(alloc, xdg_path); + if (xdg_action != .not_found and legacy_xdg_action != .not_found) { + log.warn("both config files `{s}` and `{s}` exist.", .{ legacy_xdg_path, xdg_path }); + log.warn("loading them both in that order", .{}); + break :xdg_loaded true; + } + + break :xdg_loaded xdg_action != .not_found or + legacy_xdg_action != .not_found; + }; // On macOS load the app support directory as well if (comptime builtin.os.tag == .macos) { - const app_support_path = try defaultAppSupportPath(alloc); + const legacy_app_support_path = try file_load.legacyDefaultAppSupportPath(alloc); + defer alloc.free(legacy_app_support_path); + const app_support_path = try file_load.preferredAppSupportPath(alloc); defer alloc.free(app_support_path); - const app_support_action = self.loadOptionalFile(alloc, app_support_path); + const app_support_loaded: bool = loaded: { + const legacy_app_support_action = self.loadOptionalFile(alloc, legacy_app_support_path); + const app_support_action = self.loadOptionalFile(alloc, app_support_path); + if (app_support_action != .not_found and legacy_app_support_action != .not_found) { + log.warn("both config files `{s}` and `{s}` exist.", .{ legacy_app_support_path, app_support_path }); + log.warn("loading them both in that order", .{}); + break :loaded true; + } + + break :loaded app_support_action != .not_found or + legacy_app_support_action != .not_found; + }; // If both files are not found, then we create a template file. // For macOS, we only create the template file in the app support - if (app_support_action == .not_found and xdg_action == .not_found) { + if (app_support_loaded and xdg_loaded) { writeConfigTemplate(app_support_path) catch |err| { log.warn("error creating template config file err={}", .{err}); }; } } else { - if (xdg_action == .not_found) { + if (xdg_loaded) { writeConfigTemplate(xdg_path) catch |err| { log.warn("error creating template config file err={}", .{err}); }; @@ -3493,102 +3523,6 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { } } -/// Default path for the XDG home configuration file. Returned value -/// must be freed by the caller. -fn defaultXdgPath(alloc: Allocator) ![]const u8 { - return try internal_os.xdg.config( - alloc, - .{ .subdir = "ghostty/config" }, - ); -} - -/// Default path for the macOS Application Support configuration file. -/// Returned value must be freed by the caller. -fn defaultAppSupportPath(alloc: Allocator) ![]const u8 { - return try internal_os.macos.appSupportDir(alloc, "config"); -} - -/// Returns the path to the preferred default configuration file. -/// This is the file where users should place their configuration. -/// -/// This doesn't create or populate the file with any default -/// contents; downstream callers must handle this. -/// -/// The returned value must be freed by the caller. -pub fn preferredDefaultFilePath(alloc: Allocator) ![]const u8 { - switch (builtin.os.tag) { - .macos => { - // macOS prefers the Application Support directory - // if it exists. - const app_support_path = try defaultAppSupportPath(alloc); - if (openFile(app_support_path)) |f| { - f.close(); - return app_support_path; - } else |_| {} - - // Try the XDG path if it exists - const xdg_path = try defaultXdgPath(alloc); - if (openFile(xdg_path)) |f| { - f.close(); - alloc.free(app_support_path); - return xdg_path; - } else |_| {} - defer alloc.free(xdg_path); - - // Neither exist, use app support - return app_support_path; - }, - - // All other platforms use XDG only - else => return try defaultXdgPath(alloc), - } -} - -const OpenFileError = error{ - FileNotFound, - FileIsEmpty, - FileOpenFailed, - NotAFile, -}; - -/// Opens the file at the given path and returns the file handle -/// if it exists and is non-empty. This also constrains the possible -/// errors to a smaller set that we can explicitly handle. -fn openFile(path: []const u8) OpenFileError!std.fs.File { - assert(std.fs.path.isAbsolute(path)); - - var file = std.fs.openFileAbsolute( - path, - .{}, - ) catch |err| switch (err) { - error.FileNotFound => return OpenFileError.FileNotFound, - else => { - log.warn("unexpected file open error path={s} err={}", .{ - path, - err, - }); - return OpenFileError.FileOpenFailed; - }, - }; - errdefer file.close(); - - const stat = file.stat() catch |err| { - log.warn("error getting file stat path={s} err={}", .{ - path, - err, - }); - return OpenFileError.FileOpenFailed; - }; - switch (stat.kind) { - .file => {}, - else => return OpenFileError.NotAFile, - } - - if (stat.size == 0) return OpenFileError.FileIsEmpty; - - return file; -} - /// Load and parse the CLI args. pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { switch (builtin.os.tag) { diff --git a/src/config/edit.zig b/src/config/edit.zig index 07bb7ee5a..6087106e7 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -4,6 +4,7 @@ const assert = std.debug.assert; 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. /// @@ -89,20 +90,16 @@ fn configPath(alloc_arena: Allocator) ![]const u8 { /// Returns a const list of possible paths the main config file could be /// in for the current OS. fn configPathCandidates(alloc_arena: Allocator) ![]const []const u8 { - var paths: std.ArrayList([]const u8) = try .initCapacity(alloc_arena, 2); + var paths: std.ArrayList([]const u8) = try .initCapacity(alloc_arena, 4); errdefer paths.deinit(alloc_arena); if (comptime builtin.os.tag == .macos) { - paths.appendAssumeCapacity(try internal_os.macos.appSupportDir( - alloc_arena, - "config", - )); + paths.appendAssumeCapacity(try file_load.defaultAppSupportPath(alloc_arena)); + paths.appendAssumeCapacity(try file_load.legacyDefaultAppSupportPath(alloc_arena)); } - paths.appendAssumeCapacity(try internal_os.xdg.config( - alloc_arena, - .{ .subdir = "ghostty/config" }, - )); + paths.appendAssumeCapacity(try file_load.defaultXdgPath(alloc_arena)); + paths.appendAssumeCapacity(try file_load.legacyDefaultXdgPath(alloc_arena)); return paths.items; } diff --git a/src/config/file_load.zig b/src/config/file_load.zig new file mode 100644 index 000000000..8dbefeea8 --- /dev/null +++ b/src/config/file_load.zig @@ -0,0 +1,166 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const internal_os = @import("../os/main.zig"); + +const log = std.log.scoped(.config); + +/// Default path for the XDG home configuration file. Returned value +/// must be freed by the caller. +pub fn defaultXdgPath(alloc: Allocator) ![]const u8 { + return try internal_os.xdg.config( + alloc, + .{ .subdir = "ghostty/config.ghostty" }, + ); +} + +/// Ghostty <1.3.0 default path for the XDG home configuration file. +/// Returned value must be freed by the caller. +pub fn legacyDefaultXdgPath(alloc: Allocator) ![]const u8 { + return try internal_os.xdg.config( + alloc, + .{ .subdir = "ghostty/config" }, + ); +} + +/// Preferred default path for the XDG home configuration file. +/// Returned value must be freed by the caller. +pub fn preferredXdgPath(alloc: Allocator) ![]const u8 { + // If the XDG path exists, use that. + const xdg_path = try defaultXdgPath(alloc); + if (open(xdg_path)) |f| { + f.close(); + return xdg_path; + } else |_| {} + + // Try the legacy path + errdefer alloc.free(xdg_path); + const legacy_xdg_path = try legacyDefaultXdgPath(alloc); + if (open(legacy_xdg_path)) |f| { + f.close(); + alloc.free(xdg_path); + return legacy_xdg_path; + } else |_| {} + + // Legacy path and XDG path both don't exist. Return the + // new one. + alloc.free(legacy_xdg_path); + return xdg_path; +} + +/// Default path for the macOS Application Support configuration file. +/// Returned value must be freed by the caller. +pub fn defaultAppSupportPath(alloc: Allocator) ![]const u8 { + return try internal_os.macos.appSupportDir(alloc, "config.ghostty"); +} + +/// Ghostty <1.3.0 default path for the macOS Application Support +/// configuration file. Returned value must be freed by the caller. +pub fn legacyDefaultAppSupportPath(alloc: Allocator) ![]const u8 { + return try internal_os.macos.appSupportDir(alloc, "config"); +} + +/// Preferred default path for the macOS Application Support configuration file. +/// Returned value must be freed by the caller. +pub fn preferredAppSupportPath(alloc: Allocator) ![]const u8 { + // If the app support path exists, use that. + const app_support_path = try defaultAppSupportPath(alloc); + if (open(app_support_path)) |f| { + f.close(); + return app_support_path; + } else |_| {} + + // Try the legacy path + errdefer alloc.free(app_support_path); + const legacy_app_support_path = try legacyDefaultAppSupportPath(alloc); + if (open(legacy_app_support_path)) |f| { + f.close(); + alloc.free(app_support_path); + return legacy_app_support_path; + } else |_| {} + + // Legacy path and app support path both don't exist. Return the + // new one. + alloc.free(legacy_app_support_path); + return app_support_path; +} + +/// Returns the path to the preferred default configuration file. +/// This is the file where users should place their configuration. +/// +/// This doesn't create or populate the file with any default +/// contents; downstream callers must handle this. +/// +/// The returned value must be freed by the caller. +pub fn preferredDefaultFilePath(alloc: Allocator) ![]const u8 { + switch (builtin.os.tag) { + .macos => { + // macOS prefers the Application Support directory + // if it exists. + const app_support_path = try preferredAppSupportPath(alloc); + const app_support_file = open(app_support_path) catch { + // Try the XDG path if it exists + const xdg_path = try preferredXdgPath(alloc); + const xdg_file = open(xdg_path) catch { + // If neither file exists, use app support + alloc.free(xdg_path); + return app_support_path; + }; + xdg_file.close(); + alloc.free(app_support_path); + return xdg_path; + }; + app_support_file.close(); + return app_support_path; + }, + + // All other platforms use XDG only + else => return try preferredXdgPath(alloc), + } +} + +const OpenFileError = error{ + FileNotFound, + FileIsEmpty, + FileOpenFailed, + NotAFile, +}; + +/// Opens the file at the given path and returns the file handle +/// if it exists and is non-empty. This also constrains the possible +/// errors to a smaller set that we can explicitly handle. +pub fn open(path: []const u8) OpenFileError!std.fs.File { + assert(std.fs.path.isAbsolute(path)); + + var file = std.fs.openFileAbsolute( + path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => return OpenFileError.FileNotFound, + else => { + log.warn("unexpected file open error path={s} err={}", .{ + path, + err, + }); + return OpenFileError.FileOpenFailed; + }, + }; + errdefer file.close(); + + const stat = file.stat() catch |err| { + log.warn("error getting file stat path={s} err={}", .{ + path, + err, + }); + return OpenFileError.FileOpenFailed; + }; + switch (stat.kind) { + .file => {}, + else => return OpenFileError.NotAFile, + } + + if (stat.size == 0) return OpenFileError.FileIsEmpty; + + return file; +} diff --git a/src/extra/vim.zig b/src/extra/vim.zig index 2c0192d03..9140b83f8 100644 --- a/src/extra/vim.zig +++ b/src/extra/vim.zig @@ -10,7 +10,7 @@ pub const ftdetect = \\" \\" THIS FILE IS AUTO-GENERATED \\ - \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* setf ghostty + \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/*,*.ghostty setf ghostty \\ ; pub const ftplugin = From 8f1a014afd9c6724b767b2fbf65d27d26b3c01e5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 12 Oct 2025 15:20:26 -0700 Subject: [PATCH 099/702] macos: clean up the "installing" update state (#9170) This includes multiple changes to clean up the "installing" state: - Ghostty will not confirm quit, since the user has already confirmed they want to restart to install the update. - If termination fails for any reason, the popover has a button to retry restarting. - The copy and badge symbol have been updated to better match the reality of the "installing" state. CleanShot 2025-10-12 at 15 04 08@2x AI written: https://ampcode.com/threads/T-623d1030-419f-413f-a285-e79c86a4246b fully understood. --- macos/Sources/App/macOS/AppDelegate.swift | 6 ++++ .../Sources/Features/Update/UpdateBadge.swift | 2 +- .../Features/Update/UpdateController.swift | 5 +++ .../Features/Update/UpdateDriver.swift | 2 +- .../Features/Update/UpdatePopoverView.swift | 33 +++++++++++++------ .../Features/Update/UpdateSimulator.swift | 18 ++++++++-- .../Features/Update/UpdateViewModel.swift | 10 ++++-- macos/Tests/Update/UpdateStateTests.swift | 4 +-- macos/Tests/Update/UpdateViewModelTests.swift | 4 +-- 9 files changed, 62 insertions(+), 22 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 3319189b9..9a6eab47b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -322,6 +322,12 @@ class AppDelegate: NSObject, func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { let windows = NSApplication.shared.windows if (windows.isEmpty) { return .terminateNow } + + // If we've already accepted to install an update, then we don't need to + // confirm quit. The user is already expecting the update to happen. + if updateController.isInstalling { + return .terminateNow + } // This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't // quite work with SwiftUI because windows are retained on close. So instead we check diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift index a4a95f411..054fdf971 100644 --- a/macos/Sources/Features/Update/UpdateBadge.swift +++ b/macos/Sources/Features/Update/UpdateBadge.swift @@ -32,7 +32,7 @@ struct UpdateBadge: View { case .extracting(let extracting): ProgressRingView(progress: min(1, max(0, extracting.progress))) - case .checking, .installing: + case .checking: if let iconName = model.iconName { Image(systemName: iconName) .rotationEffect(.degrees(rotationAngle)) diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift index aa875567c..2dfb0a420 100644 --- a/macos/Sources/Features/Update/UpdateController.swift +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -17,6 +17,11 @@ class UpdateController { userDriver.viewModel } + /// True if we're installing an update. + var isInstalling: Bool { + installCancellable != nil + } + /// Initialize a new update controller. init() { let hostBundle = Bundle.main diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index ed58f1663..4bddda809 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -172,7 +172,7 @@ class UpdateDriver: NSObject, SPUUserDriver { } func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { - viewModel.state = .installing + viewModel.state = .installing(.init(retryTerminatingApplication: retryTerminatingApplication)) if !hasUnobtrusiveTarget { standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication) diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 236649c21..770b9aedd 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -38,8 +38,8 @@ struct UpdatePopoverView: View { case .readyToInstall(let ready): ReadyToInstallView(ready: ready, dismiss: dismiss) - case .installing: - InstallingView() + case .installing(let installing): + InstallingView(installing: installing, dismiss: dismiss) case .notFound(let notFound): NotFoundView(notFound: notFound, dismiss: dismiss) @@ -313,18 +313,31 @@ fileprivate struct ReadyToInstallView: View { } fileprivate struct InstallingView: View { + let installing: UpdateState.Installing + let dismiss: DismissAction + var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - ProgressView() - .controlSize(.small) - Text("Installing…") + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Restart Required") .font(.system(size: 13, weight: .semibold)) + + Text("The update is ready. Please restart the application to complete the installation.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } - Text("The application will relaunch shortly.") - .font(.system(size: 11)) - .foregroundColor(.secondary) + HStack { + Spacer() + Button("Restart Now") { + installing.retryTerminatingApplication() + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } } .padding(16) } diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift index f40bbee1b..c855282c0 100644 --- a/macos/Sources/Features/Update/UpdateSimulator.swift +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -28,6 +28,9 @@ enum UpdateSimulator { /// User cancels while checking: checking (1s) → cancels → idle case cancelDuringChecking + /// Shows the installing state with restart button: installing (stays until dismissed) + case installing + func simulate(with viewModel: UpdateViewModel) { switch self { case .happyPath: @@ -44,6 +47,8 @@ enum UpdateSimulator { simulateCancelDuringDownload(viewModel) case .cancelDuringChecking: simulateCancelDuringChecking(viewModel) + case .installing: + simulateInstalling(viewModel) } } @@ -260,10 +265,10 @@ enum UpdateSimulator { viewModel.state = .readyToInstall(.init( reply: { choice in if choice == .install { - viewModel.state = .installing - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + viewModel.state = .installing(.init(retryTerminatingApplication: { + print("Restart button clicked in simulator - resetting to idle") viewModel.state = .idle - } + })) } else { viewModel.state = .idle } @@ -274,4 +279,11 @@ enum UpdateSimulator { } } } + + private func simulateInstalling(_ viewModel: UpdateViewModel) { + viewModel.state = .installing(.init(retryTerminatingApplication: { + print("Restart button clicked in simulator - resetting to idle") + viewModel.state = .idle + })) + } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index ccb03e731..f0b39ed2d 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -33,7 +33,7 @@ class UpdateViewModel: ObservableObject { case .readyToInstall: return "Install Update" case .installing: - return "Installing…" + return "Restart to Complete Update" case .notFound: return "No Updates Available" case .error(let err): @@ -72,7 +72,7 @@ class UpdateViewModel: ObservableObject { case .readyToInstall: return "checkmark.circle.fill" case .installing: - return "gear" + return "power.circle" case .notFound: return "info.circle" case .error: @@ -192,7 +192,7 @@ enum UpdateState: Equatable { case downloading(Downloading) case extracting(Extracting) case readyToInstall(ReadyToInstall) - case installing + case installing(Installing) var isIdle: Bool { if case .idle = self { return true } @@ -382,4 +382,8 @@ enum UpdateState: Equatable { struct ReadyToInstall { let reply: @Sendable (SPUUserUpdateChoice) -> Void } + + struct Installing { + let retryTerminatingApplication: () -> Void + } } diff --git a/macos/Tests/Update/UpdateStateTests.swift b/macos/Tests/Update/UpdateStateTests.swift index 01819bb25..269cd3153 100644 --- a/macos/Tests/Update/UpdateStateTests.swift +++ b/macos/Tests/Update/UpdateStateTests.swift @@ -25,8 +25,8 @@ struct UpdateStateTests { } @Test func testInstallingEquality() { - let state1: UpdateState = .installing - let state2: UpdateState = .installing + let state1: UpdateState = .installing(.init(retryTerminatingApplication: {})) + let state2: UpdateState = .installing(.init(retryTerminatingApplication: {})) #expect(state1 == state2) } diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift index d3b2e060b..56748ff83 100644 --- a/macos/Tests/Update/UpdateViewModelTests.swift +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -58,8 +58,8 @@ struct UpdateViewModelTests { @Test func testInstallingText() { let viewModel = UpdateViewModel() - viewModel.state = .installing - #expect(viewModel.text == "Installing…") + viewModel.state = .installing(.init(retryTerminatingApplication: {})) + #expect(viewModel.text == "Restart to Complete Update") } @Test func testNotFoundText() { From 97a5a59cc3edf86cb939ca563b3bb43afe43a79a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 12 Oct 2025 15:30:34 -0700 Subject: [PATCH 100/702] macos: update to Sparkle 2.8 (#9171) Most of the changes seem to be Tahoe UI related and now that we have a custom UI I don't think there is anything important here but we should update nonetheless. --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index db3dd11a5..89573fb88 100644 --- a/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macos/Ghostty.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "06beff60e3b609a485290e4d5d2d1e2eedb1e55d", - "version" : "2.7.3" + "revision" : "9a1d2a19d3595fcf8d9c447173f9a1687b3dcadb", + "version" : "2.8.0" } } ], From 797c54a2d72c7f580cae894b19fdf9f8b4c69fcb Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Mon, 13 Oct 2025 08:45:21 -0500 Subject: [PATCH 101/702] deps: update libvaxis (#9177) Update libvaxis. The latest commit of libvaxis includes `uucode` as the unicode library. `uucode` has a much cleaner API and is actively developed by a Ghostty maintainer (@jacobsandlund). This also has the advantage of removing the last transitive dependency Ghostty has that is hosted on codeberg, which separates us from the frequent outages. Disclosures: I used AI to debug the import issue in `boo.zig` --------- Co-authored-by: Mitchell Hashimoto --- build.zig.zon | 4 ++-- build.zig.zon.json | 11 ++++++++--- build.zig.zon.nix | 14 +++++++++++--- build.zig.zon.txt | 3 ++- flatpak/zig-packages.json | 12 +++++++++--- src/cli/boo.zig | 2 +- src/cli/list_themes.zig | 2 +- 7 files changed, 34 insertions(+), 14 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 45f1e3692..8c05cb50a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -15,8 +15,8 @@ }, .vaxis = .{ // rockorager/libvaxis - .url = "https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz", - .hash = "vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ", + .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", .lazy = true, }, .z2d = .{ diff --git a/build.zig.zon.json b/build.zig.zon.json index e9f2b8cd8..a94c3b13a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -109,6 +109,11 @@ "url": "https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz", "hash": "sha256-/8ZooxDndgfTk/PBizJxXyI9oerExNbgV5oR345rWc8=" }, + "uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM": { + "name": "uucode", + "url": "git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732", + "hash": "sha256-sHPh+TQSdUGus/QTbj7KSJJkTuNTrK4VNmQDjS30Lf8=" + }, "uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT": { "name": "uucode", "url": "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz", @@ -119,10 +124,10 @@ "url": "git+https://github.com/rockorager/libvaxis#6eb16bb4190dc074dafaf4f0ce7dadd50e81192a", "hash": "sha256-5c+TjmiH4071lKI+U8SIJ0M+4ezzcAtLI2ZSfZYdXSA=" }, - "vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ": { + "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", - "url": "https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz", - "hash": "sha256-7H5a0J7uUsrzlO7JNAf/Ussi9WxvmsbyJSmhqvl+rqI=" + "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "hash": "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY=" }, "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t": { "name": "wayland", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 08e524e91..ec69854fe 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -258,6 +258,14 @@ in hash = "sha256-/8ZooxDndgfTk/PBizJxXyI9oerExNbgV5oR345rWc8="; }; } + { + name = "uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM"; + path = fetchZigArtifact { + name = "uucode"; + url = "git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732"; + hash = "sha256-sHPh+TQSdUGus/QTbj7KSJJkTuNTrK4VNmQDjS30Lf8="; + }; + } { name = "uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT"; path = fetchZigArtifact { @@ -275,11 +283,11 @@ in }; } { - name = "vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ"; + name = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS"; path = fetchZigArtifact { name = "vaxis"; - url = "https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz"; - hash = "sha256-7H5a0J7uUsrzlO7JNAf/Ussi9WxvmsbyJSmhqvl+rqI="; + url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz"; + hash = "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index d7b76e59f..ef854348a 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -1,3 +1,4 @@ +git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732 git+https://github.com/rockorager/libvaxis#6eb16bb4190dc074dafaf4f0ce7dadd50e81192a https://codeberg.org/chaten/zg/archive/749197a3f9d25e211615960c02380a3d659b20f9.tar.gz https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz @@ -22,7 +23,6 @@ https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3f https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz -https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz @@ -33,4 +33,5 @@ https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90 https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.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 df210bf22..ef9cb7624 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -131,6 +131,12 @@ "dest": "vendor/p/N-V-__8AAHffAgDU0YQmynL8K35WzkcnMUmBVQHQ0jlcKpjH", "sha256": "ffc668a310e77607d393f3c18b32715f223da1eac4c4d6e0579a11df8e6b59cf" }, + { + "type": "git", + "url": "https://github.com/jacobsandlund/uucode", + "commit": "5f05f8f83a75caea201f12cc8ea32a2d82ea9732", + "dest": "vendor/p/uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM" + }, { "type": "archive", "url": "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz", @@ -145,9 +151,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/vaxis-9fc9015d5f147568e18c5e7ca28f15bf8b293760.tar.gz", - "dest": "vendor/p/vaxis-0.5.1-BWNV_O8fCQAeUeVrESVc-2BdXloEXkFqReDJL7Q6XTSZ", - "sha256": "ec7e5ad09eee52caf394eec93407ff52cb22f56c6f9ac6f22529a1aaf97eaea2" + "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "dest": "vendor/p/vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", + "sha256": "2e72332bc89c5b541ec6e6bd48769e1f3fb757c4006f3d1af940b54f9b088ef6" }, { "type": "archive", diff --git a/src/cli/boo.zig b/src/cli/boo.zig index 756b6d77a..f96fd6282 100644 --- a/src/cli/boo.zig +++ b/src/cli/boo.zig @@ -6,7 +6,7 @@ const Allocator = std.mem.Allocator; const help_strings = @import("help_strings"); const vaxis = @import("vaxis"); -const framedata = @embedFile("framedata"); +const framedata = @import("framedata").compressed; const vxfw = vaxis.vxfw; diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index cc6cfaf3e..63184ddfb 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -241,7 +241,7 @@ const Preview = struct { .hex = false, .mode = .normal, .color_scheme = .light, - .text_input = .init(allocator, &self.vx.unicode), + .text_input = .init(allocator), .theme_filter = theme_filter, }; From e805a987223183896d186c8597c9434c42baa83d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 06:45:46 -0700 Subject: [PATCH 102/702] build(deps): bump cachix/install-nix-action from 31.7.0 to 31.8.0 (#9175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.7.0 to 31.8.0.
Release notes

Sourced from cachix/install-nix-action's releases.

v31.8.0

What's Changed

Full Changelog: https://github.com/cachix/install-nix-action/compare/v31.7.0...v31.8.0

Commits
  • 7ab6e7f Merge pull request #257 from cachix/create-pull-request/patch
  • a851831 nix: 2.31.2 -> 2.32.0
  • 0b2de19 docs: update the ci badge
  • b8a94d3 ci: pass correct args to the act test
  • 0ef0505 ci: adjust oldest supported version for macos-15
  • 0b43574 ci: add macos-15-intel runner
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cachix/install-nix-action&package-manager=github_actions&previous-version=31.7.0&new-version=31.8.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 ef6f96555..b28dd4299 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@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 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 af912215c..5718a85bb 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@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index d3ea88def..93af7fe26 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -34,7 +34,7 @@ jobs: with: # Important so that build number generation works fetch-depth: 0 - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -168,7 +168,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 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 6df4975b0..b71c8895b 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@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -113,7 +113,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -146,7 +146,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -180,7 +180,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -222,7 +222,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -258,7 +258,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -287,7 +287,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -320,7 +320,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -366,7 +366,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -585,7 +585,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -627,7 +627,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -675,7 +675,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -710,7 +710,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -774,7 +774,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -801,7 +801,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -829,7 +829,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -856,7 +856,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -883,7 +883,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -910,7 +910,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -937,7 +937,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -971,7 +971,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -998,7 +998,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1035,7 +1035,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1123,7 +1123,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 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 4e9db4225..3d6c2ed1f 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@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 + uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 From 1835a86a6aa94e1fbf1139773f78e8d9c44b2210 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 06:45:55 -0700 Subject: [PATCH 103/702] build(deps): bump softprops/action-gh-release from 2.4.0 to 2.4.1 (#9174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.4.0 to 2.4.1.
Release notes

Sourced from softprops/action-gh-release's releases.

v2.4.1

What's Changed

Other Changes 🔄

Full Changelog: https://github.com/softprops/action-gh-release/compare/v2...v2.4.1

Changelog

Sourced from softprops/action-gh-release's changelog.

2.4.1

What's Changed

Other Changes 🔄

2.4.0

What's Changed

Exciting New Features 🎉

2.3.4

What's Changed

Bug fixes 🐛

Other Changes 🔄

  • dependency updates

2.3.3

What's Changed

Exciting New Features 🎉

Other Changes 🔄

  • dependency updates

2.3.2

  • fix: revert fs readableWebStream change

2.3.1

Bug fixes 🐛

... (truncated)

Commits
  • 6da8fa9 release 2.4.1
  • f38efde fix: gracefully fallback to body when body_path cannot be read (#671)
  • cec1a11 fix(util): support brace expansion globs containing commas in parseInputFiles...
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=softprops/action-gh-release&package-manager=github_actions&previous-version=2.4.0&new-version=2.4.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 93af7fe26..b4ef9e1f5 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -188,7 +188,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@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -359,7 +359,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -590,7 +590,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -775,7 +775,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 with: name: 'Ghostty Tip ("Nightly")' prerelease: true From f5af3d4585e5e6c0045aa21983923f175d5656ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 06:46:03 -0700 Subject: [PATCH 104/702] build(deps): bump namespacelabs/nscloud-setup-buildx-action from 0.0.18 to 0.0.19 (#9173) Bumps [namespacelabs/nscloud-setup-buildx-action](https://github.com/namespacelabs/nscloud-setup-buildx-action) from 0.0.18 to 0.0.19.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=namespacelabs/nscloud-setup-buildx-action&package-manager=github_actions&previous-version=0.0.18&new-version=0.0.19)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b71c8895b..6757db2ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1057,7 +1057,7 @@ jobs: uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10 - name: Configure Namespace powered Buildx - uses: namespacelabs/nscloud-setup-buildx-action@01628ae51ea5d6b0c90109c7dccbf511953aff29 # v0.0.18 + uses: namespacelabs/nscloud-setup-buildx-action@7020d7d8e659afecbfec162ab4693c7e56278311 # v0.0.19 - name: Download Source Tarball Artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 From dafb9e89a3470674e0bfd5d9b01edbf1d87f330a Mon Sep 17 00:00:00 2001 From: Xiangbao Meng <134181853+bo2themax@users.noreply.github.com> Date: Mon, 13 Oct 2025 15:52:00 +0200 Subject: [PATCH 105/702] macOS: use default app for `*.ghostty` files first (#9180) A small improvement for #8885, tested `config` and `config.ghostty` image --- macos/Sources/Ghostty/Ghostty.App.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index bdc64e9e1..bf34b4a91 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -637,8 +637,9 @@ extension Ghostty { switch action.kind { case .text: - // Open with the default text editor - if let textEditor = NSWorkspace.shared.defaultTextEditor { + // Open with the default editor for `*.ghostty` file or just system text editor + let editor = NSWorkspace.shared.defaultApplicationURL(forExtension: url.pathExtension) ?? NSWorkspace.shared.defaultTextEditor + if let textEditor = editor { NSWorkspace.shared.open([url], withApplicationAt: textEditor, configuration: NSWorkspace.OpenConfiguration()) return true } From 5f287774a6a05028c926c340896f7e8c52611947 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 13 Oct 2025 15:47:58 -0500 Subject: [PATCH 106/702] osc: simplify parser init (#9184) --- src/synthetic/Osc.zig | 4 +- src/terminal/Parser.zig | 2 +- src/terminal/c/osc.zig | 2 +- src/terminal/osc.zig | 224 +++++++++++++++++++--------------------- 4 files changed, 113 insertions(+), 119 deletions(-) diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig index d78b95a1e..52940fee9 100644 --- a/src/synthetic/Osc.zig +++ b/src/synthetic/Osc.zig @@ -204,7 +204,7 @@ test "OSC generator valid" { var writer: std.Io.Writer = .fixed(&buf); try gen.next(&writer, buf.len); const seq = writer.buffered(); - var parser: terminal.osc.Parser = .init(); + var parser: terminal.osc.Parser = .init(null); for (seq[2 .. seq.len - 1]) |c| parser.next(c); try testing.expect(parser.end(null) != null); } @@ -224,7 +224,7 @@ test "OSC generator invalid" { var writer: std.Io.Writer = .fixed(&buf); try gen.next(&writer, buf.len); const seq = writer.buffered(); - var parser: terminal.osc.Parser = .init(); + var parser: terminal.osc.Parser = .init(null); for (seq[2 .. seq.len - 1]) |c| parser.next(c); try testing.expect(parser.end(null) == null); } diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index ca2fd3718..625591d3f 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -221,7 +221,7 @@ pub fn init() Parser { .params_idx = 0, .param_acc = 0, .param_acc_idx = 0, - .osc_parser = .init(), + .osc_parser = .init(null), .intermediates = undefined, .params = undefined, diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 8b6a8409c..1311eaff8 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -19,7 +19,7 @@ pub fn new( const alloc = lib_alloc.default(alloc_); const ptr = alloc.create(osc.Parser) catch return .out_of_memory; - ptr.* = .initAlloc(alloc); + ptr.* = .init(alloc); result.* = ptr; return .success; } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 12a6d1f5c..f7324636a 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -444,9 +444,9 @@ pub const Parser = struct { conemu_guimacro, }; - pub fn init() Parser { + pub fn init(alloc: ?Allocator) Parser { var result: Parser = .{ - .alloc = null, + .alloc = alloc, .state = .empty, .command = .invalid, .buf_start = 0, @@ -469,12 +469,6 @@ pub const Parser = struct { return result; } - pub fn initAlloc(alloc: Allocator) Parser { - var result: Parser = .init(); - result.alloc = alloc; - return result; - } - /// This must be called to clean up any allocated memory. pub fn deinit(self: *Parser) void { self.reset(); @@ -1763,7 +1757,7 @@ test { test "OSC 0: change_window_title" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); p.next('0'); p.next(';'); p.next('a'); @@ -1776,7 +1770,7 @@ test "OSC 0: change_window_title" { test "OSC 0: longer than buffer" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "0;" ++ "a" ** (Parser.MAX_BUF + 2); for (input) |ch| p.next(ch); @@ -1788,7 +1782,7 @@ test "OSC 0: longer than buffer" { test "OSC 0: one shorter than buffer length" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const prefix = "0;"; const title = "a" ** (Parser.MAX_BUF - prefix.len - 1); @@ -1803,7 +1797,7 @@ test "OSC 0: one shorter than buffer length" { test "OSC 0: exactly at buffer length" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const prefix = "0;"; const title = "a" ** (Parser.MAX_BUF - prefix.len); @@ -1818,7 +1812,7 @@ test "OSC 0: exactly at buffer length" { test "OSC 1: change_window_icon" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); p.next('1'); p.next(';'); p.next('a'); @@ -1831,7 +1825,7 @@ test "OSC 1: change_window_icon" { test "OSC 2: change_window_title with 2" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); p.next('2'); p.next(';'); p.next('a'); @@ -1844,7 +1838,7 @@ test "OSC 2: change_window_title with 2" { test "OSC 2: change_window_title with utf8" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); p.next('2'); p.next(';'); // '—' EM DASH U+2014 (E2 80 94) @@ -1866,7 +1860,7 @@ test "OSC 2: change_window_title with utf8" { test "OSC 2: change_window_title empty" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); p.next('2'); p.next(';'); const cmd = p.end(null).?.*; @@ -1877,7 +1871,7 @@ test "OSC 2: change_window_title empty" { test "OSC 4: empty param" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "4;;"; for (input) |ch| p.next(ch); @@ -1893,7 +1887,7 @@ test "OSC 4: empty param" { test "OSC 7: report pwd" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "7;file:///tmp/example"; for (input) |ch| p.next(ch); @@ -1906,7 +1900,7 @@ test "OSC 7: report pwd" { test "OSC 7: report pwd empty" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "7;"; for (input) |ch| p.next(ch); @@ -1918,7 +1912,7 @@ test "OSC 7: report pwd empty" { test "OSC 8: hyperlink" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;;http://example.com"; for (input) |ch| p.next(ch); @@ -1931,7 +1925,7 @@ test "OSC 8: hyperlink" { test "OSC 8: hyperlink with id set" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;id=foo;http://example.com"; for (input) |ch| p.next(ch); @@ -1945,7 +1939,7 @@ test "OSC 8: hyperlink with id set" { test "OSC 8: hyperlink with empty id" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;id=;http://example.com"; for (input) |ch| p.next(ch); @@ -1959,7 +1953,7 @@ test "OSC 8: hyperlink with empty id" { test "OSC 8: hyperlink with incomplete key" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;id;http://example.com"; for (input) |ch| p.next(ch); @@ -1973,7 +1967,7 @@ test "OSC 8: hyperlink with incomplete key" { test "OSC 8: hyperlink with empty key" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;=value;http://example.com"; for (input) |ch| p.next(ch); @@ -1987,7 +1981,7 @@ test "OSC 8: hyperlink with empty key" { test "OSC 8: hyperlink with empty key and id" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;=value:id=foo;http://example.com"; for (input) |ch| p.next(ch); @@ -2001,7 +1995,7 @@ test "OSC 8: hyperlink with empty key and id" { test "OSC 8: hyperlink with empty uri" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;id=foo;"; for (input) |ch| p.next(ch); @@ -2013,7 +2007,7 @@ test "OSC 8: hyperlink with empty uri" { test "OSC 8: hyperlink end" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "8;;"; for (input) |ch| p.next(ch); @@ -2025,7 +2019,7 @@ test "OSC 8: hyperlink end" { test "OSC 9: show desktop notification" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;Hello world"; for (input) |ch| p.next(ch); @@ -2039,7 +2033,7 @@ test "OSC 9: show desktop notification" { test "OSC 9: show single character desktop notification" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;H"; for (input) |ch| p.next(ch); @@ -2053,7 +2047,7 @@ test "OSC 9: show single character desktop notification" { test "OSC 9;1: ConEmu sleep" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;1;420"; for (input) |ch| p.next(ch); @@ -2067,7 +2061,7 @@ test "OSC 9;1: ConEmu sleep" { test "OSC 9;1: ConEmu sleep with no value default to 100ms" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;1;"; for (input) |ch| p.next(ch); @@ -2081,7 +2075,7 @@ test "OSC 9;1: ConEmu sleep with no value default to 100ms" { test "OSC 9;1: conemu sleep cannot exceed 10000ms" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;1;12345"; for (input) |ch| p.next(ch); @@ -2095,7 +2089,7 @@ test "OSC 9;1: conemu sleep cannot exceed 10000ms" { test "OSC 9;1: conemu sleep invalid input" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;1;foo"; for (input) |ch| p.next(ch); @@ -2109,7 +2103,7 @@ test "OSC 9;1: conemu sleep invalid input" { test "OSC 9;1: conemu sleep -> desktop notification 1" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;1"; for (input) |ch| p.next(ch); @@ -2123,7 +2117,7 @@ test "OSC 9;1: conemu sleep -> desktop notification 1" { test "OSC 9;1: conemu sleep -> desktop notification 2" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;1a"; for (input) |ch| p.next(ch); @@ -2137,7 +2131,7 @@ test "OSC 9;1: conemu sleep -> desktop notification 2" { test "OSC 9;2: ConEmu message box" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;2;hello world"; for (input) |ch| p.next(ch); @@ -2150,7 +2144,7 @@ test "OSC 9;2: ConEmu message box" { test "OSC 9;2: ConEmu message box invalid input" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;2"; for (input) |ch| p.next(ch); @@ -2163,7 +2157,7 @@ test "OSC 9;2: ConEmu message box invalid input" { test "OSC 9;2: ConEmu message box empty message" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;2;"; for (input) |ch| p.next(ch); @@ -2176,7 +2170,7 @@ test "OSC 9;2: ConEmu message box empty message" { test "OSC 9;2: ConEmu message box spaces only message" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;2; "; for (input) |ch| p.next(ch); @@ -2189,7 +2183,7 @@ test "OSC 9;2: ConEmu message box spaces only message" { test "OSC 9;2: message box -> desktop notification 1" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;2"; for (input) |ch| p.next(ch); @@ -2203,7 +2197,7 @@ test "OSC 9;2: message box -> desktop notification 1" { test "OSC 9;2: message box -> desktop notification 2" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;2a"; for (input) |ch| p.next(ch); @@ -2217,7 +2211,7 @@ test "OSC 9;2: message box -> desktop notification 2" { test "OSC 9;3: ConEmu change tab title" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;3;foo bar"; for (input) |ch| p.next(ch); @@ -2230,7 +2224,7 @@ test "OSC 9;3: ConEmu change tab title" { test "OSC 9;3: ConEmu change tab title reset" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;3;"; for (input) |ch| p.next(ch); @@ -2244,7 +2238,7 @@ test "OSC 9;3: ConEmu change tab title reset" { test "OSC 9;3: ConEmu change tab title spaces only" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;3; "; for (input) |ch| p.next(ch); @@ -2258,7 +2252,7 @@ test "OSC 9;3: ConEmu change tab title spaces only" { test "OSC 9;3: change tab title -> desktop notification 1" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;3"; for (input) |ch| p.next(ch); @@ -2272,7 +2266,7 @@ test "OSC 9;3: change tab title -> desktop notification 1" { test "OSC 9;3: message box -> desktop notification 2" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;3a"; for (input) |ch| p.next(ch); @@ -2286,7 +2280,7 @@ test "OSC 9;3: message box -> desktop notification 2" { test "OSC 9;4: ConEmu progress set" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;1;100"; for (input) |ch| p.next(ch); @@ -2300,7 +2294,7 @@ test "OSC 9;4: ConEmu progress set" { test "OSC 9;4: ConEmu progress set overflow" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;1;900"; for (input) |ch| p.next(ch); @@ -2314,7 +2308,7 @@ test "OSC 9;4: ConEmu progress set overflow" { test "OSC 9;4: ConEmu progress set single digit" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;1;9"; for (input) |ch| p.next(ch); @@ -2328,7 +2322,7 @@ test "OSC 9;4: ConEmu progress set single digit" { test "OSC 9;4: ConEmu progress set double digit" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;1;94"; for (input) |ch| p.next(ch); @@ -2342,7 +2336,7 @@ test "OSC 9;4: ConEmu progress set double digit" { test "OSC 9;4: ConEmu progress set extra semicolon ignored" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;1;100"; for (input) |ch| p.next(ch); @@ -2356,7 +2350,7 @@ test "OSC 9;4: ConEmu progress set extra semicolon ignored" { test "OSC 9;4: ConEmu progress remove with no progress" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;0;"; for (input) |ch| p.next(ch); @@ -2370,7 +2364,7 @@ test "OSC 9;4: ConEmu progress remove with no progress" { test "OSC 9;4: ConEmu progress remove with double semicolon" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;0;;"; for (input) |ch| p.next(ch); @@ -2384,7 +2378,7 @@ test "OSC 9;4: ConEmu progress remove with double semicolon" { test "OSC 9;4: ConEmu progress remove ignores progress" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;0;100"; for (input) |ch| p.next(ch); @@ -2398,7 +2392,7 @@ test "OSC 9;4: ConEmu progress remove ignores progress" { test "OSC 9;4: ConEmu progress remove extra semicolon" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;0;100;"; for (input) |ch| p.next(ch); @@ -2411,7 +2405,7 @@ test "OSC 9;4: ConEmu progress remove extra semicolon" { test "OSC 9;4: ConEmu progress error" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;2"; for (input) |ch| p.next(ch); @@ -2425,7 +2419,7 @@ test "OSC 9;4: ConEmu progress error" { test "OSC 9;4: ConEmu progress error with progress" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;2;100"; for (input) |ch| p.next(ch); @@ -2439,7 +2433,7 @@ test "OSC 9;4: ConEmu progress error with progress" { test "OSC 9;4: progress pause" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;4"; for (input) |ch| p.next(ch); @@ -2453,7 +2447,7 @@ test "OSC 9;4: progress pause" { test "OSC 9;4: ConEmu progress pause with progress" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;4;100"; for (input) |ch| p.next(ch); @@ -2467,7 +2461,7 @@ test "OSC 9;4: ConEmu progress pause with progress" { test "OSC 9;4: progress -> desktop notification 1" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4"; for (input) |ch| p.next(ch); @@ -2481,7 +2475,7 @@ test "OSC 9;4: progress -> desktop notification 1" { test "OSC 9;4: progress -> desktop notification 2" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;"; for (input) |ch| p.next(ch); @@ -2495,7 +2489,7 @@ test "OSC 9;4: progress -> desktop notification 2" { test "OSC 9;4: progress -> desktop notification 3" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;5"; for (input) |ch| p.next(ch); @@ -2509,7 +2503,7 @@ test "OSC 9;4: progress -> desktop notification 3" { test "OSC 9;4: progress -> desktop notification 4" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;4;5a"; for (input) |ch| p.next(ch); @@ -2523,7 +2517,7 @@ test "OSC 9;4: progress -> desktop notification 4" { test "OSC 9;5: ConEmu wait input" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;5"; for (input) |ch| p.next(ch); @@ -2535,7 +2529,7 @@ test "OSC 9;5: ConEmu wait input" { test "OSC 9;5: ConEmu wait ignores trailing characters" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "9;5;foo"; for (input) |ch| p.next(ch); @@ -2547,7 +2541,7 @@ test "OSC 9;5: ConEmu wait ignores trailing characters" { test "OSC 9;6: ConEmu guimacro 1" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "9;6;a"; @@ -2561,7 +2555,7 @@ test "OSC 9;6: ConEmu guimacro 1" { test "OSC: 9;6: ConEmu guimacro 2" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "9;6;ab"; @@ -2575,7 +2569,7 @@ test "OSC: 9;6: ConEmu guimacro 2" { test "OSC: 9;6: ConEmu guimacro 3 incomplete -> desktop notification" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "9;6"; @@ -2610,7 +2604,7 @@ test "OSC 21: kitty color protocol" { const testing = std.testing; const Kind = kitty_color.Kind; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; @@ -2681,7 +2675,7 @@ test "OSC 21: kitty color protocol" { test "OSC 21: kitty color protocol without allocator" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); defer p.deinit(); const input = "21;foreground=?"; @@ -2692,7 +2686,7 @@ test "OSC 21: kitty color protocol without allocator" { test "OSC 21: kitty color protocol double reset" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; @@ -2708,7 +2702,7 @@ test "OSC 21: kitty color protocol double reset" { test "OSC 21: kitty color protocol reset after invalid" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; @@ -2729,7 +2723,7 @@ test "OSC 21: kitty color protocol reset after invalid" { test "OSC 21: kitty color protocol no key" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "21;"; @@ -2743,7 +2737,7 @@ test "OSC 21: kitty color protocol no key" { test "OSC 22: pointer cursor" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "22;pointer"; for (input) |ch| p.next(ch); @@ -2756,7 +2750,7 @@ test "OSC 22: pointer cursor" { test "OSC 52: get/set clipboard" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "52;s;?"; for (input) |ch| p.next(ch); @@ -2770,7 +2764,7 @@ test "OSC 52: get/set clipboard" { test "OSC 52: get/set clipboard (optional parameter)" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "52;;?"; for (input) |ch| p.next(ch); @@ -2784,7 +2778,7 @@ test "OSC 52: get/set clipboard (optional parameter)" { test "OSC 52: get/set clipboard with allocator" { const testing = std.testing; - var p: Parser = .initAlloc(testing.allocator); + var p: Parser = .init(testing.allocator); defer p.deinit(); const input = "52;s;?"; @@ -2799,7 +2793,7 @@ test "OSC 52: get/set clipboard with allocator" { test "OSC 52: clear clipboard" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); defer p.deinit(); const input = "52;;"; @@ -2838,7 +2832,7 @@ test "OSC 52: clear clipboard" { test "OSC 133: prompt_start" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A"; for (input) |ch| p.next(ch); @@ -2852,7 +2846,7 @@ test "OSC 133: prompt_start" { test "OSC 133: prompt_start with single option" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;aid=14"; for (input) |ch| p.next(ch); @@ -2865,7 +2859,7 @@ test "OSC 133: prompt_start with single option" { test "OSC 133: prompt_start with redraw disabled" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;redraw=0"; for (input) |ch| p.next(ch); @@ -2878,7 +2872,7 @@ test "OSC 133: prompt_start with redraw disabled" { test "OSC 133: prompt_start with redraw invalid value" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;redraw=42"; for (input) |ch| p.next(ch); @@ -2892,7 +2886,7 @@ test "OSC 133: prompt_start with redraw invalid value" { test "OSC 133: prompt_start with continuation" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;k=c"; for (input) |ch| p.next(ch); @@ -2905,7 +2899,7 @@ test "OSC 133: prompt_start with continuation" { test "OSC 133: prompt_start with secondary" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;k=s"; for (input) |ch| p.next(ch); @@ -2918,7 +2912,7 @@ test "OSC 133: prompt_start with secondary" { test "OSC 133: prompt_start with special_key" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;special_key=1"; for (input) |ch| p.next(ch); @@ -2931,7 +2925,7 @@ test "OSC 133: prompt_start with special_key" { test "OSC 133: prompt_start with special_key invalid" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;special_key=bobr"; for (input) |ch| p.next(ch); @@ -2944,7 +2938,7 @@ test "OSC 133: prompt_start with special_key invalid" { test "OSC 133: prompt_start with special_key 0" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;special_key=0"; for (input) |ch| p.next(ch); @@ -2957,7 +2951,7 @@ test "OSC 133: prompt_start with special_key 0" { test "OSC 133: prompt_start with special_key empty" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;special_key="; for (input) |ch| p.next(ch); @@ -2970,7 +2964,7 @@ test "OSC 133: prompt_start with special_key empty" { test "OSC 133: prompt_start with click_events true" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;click_events=1"; for (input) |ch| p.next(ch); @@ -2983,7 +2977,7 @@ test "OSC 133: prompt_start with click_events true" { test "OSC 133: prompt_start with click_events false" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;click_events=0"; for (input) |ch| p.next(ch); @@ -2996,7 +2990,7 @@ test "OSC 133: prompt_start with click_events false" { test "OSC 133: prompt_start with click_events empty" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;A;click_events="; for (input) |ch| p.next(ch); @@ -3009,7 +3003,7 @@ test "OSC 133: prompt_start with click_events empty" { test "OSC 133: end_of_command no exit code" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;D"; for (input) |ch| p.next(ch); @@ -3021,7 +3015,7 @@ test "OSC 133: end_of_command no exit code" { test "OSC 133: end_of_command with exit code" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;D;25"; for (input) |ch| p.next(ch); @@ -3034,7 +3028,7 @@ test "OSC 133: end_of_command with exit code" { test "OSC 133: prompt_end" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;B"; for (input) |ch| p.next(ch); @@ -3046,7 +3040,7 @@ test "OSC 133: prompt_end" { test "OSC 133: end_of_input" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C"; for (input) |ch| p.next(ch); @@ -3058,7 +3052,7 @@ test "OSC 133: end_of_input" { test "OSC 133: end_of_input with cmdline 1" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline=echo bobr kurwa"; for (input) |ch| p.next(ch); @@ -3072,7 +3066,7 @@ test "OSC 133: end_of_input with cmdline 1" { test "OSC 133: end_of_input with cmdline 2" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline=echo bobr\\ kurwa"; for (input) |ch| p.next(ch); @@ -3086,7 +3080,7 @@ test "OSC 133: end_of_input with cmdline 2" { test "OSC 133: end_of_input with cmdline 3" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline=echo bobr\\nkurwa"; for (input) |ch| p.next(ch); @@ -3100,7 +3094,7 @@ test "OSC 133: end_of_input with cmdline 3" { test "OSC 133: end_of_input with cmdline 4" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline=$'echo bobr kurwa'"; for (input) |ch| p.next(ch); @@ -3114,7 +3108,7 @@ test "OSC 133: end_of_input with cmdline 4" { test "OSC 133: end_of_input with cmdline 5" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline='echo bobr kurwa'"; for (input) |ch| p.next(ch); @@ -3128,7 +3122,7 @@ test "OSC 133: end_of_input with cmdline 5" { test "OSC 133: end_of_input with cmdline 6" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline='echo bobr kurwa"; for (input) |ch| p.next(ch); @@ -3141,7 +3135,7 @@ test "OSC 133: end_of_input with cmdline 6" { test "OSC 133: end_of_input with cmdline 7" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline=$'echo bobr kurwa"; for (input) |ch| p.next(ch); @@ -3154,7 +3148,7 @@ test "OSC 133: end_of_input with cmdline 7" { test "OSC 133: end_of_input with cmdline 8" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline=$'"; for (input) |ch| p.next(ch); @@ -3167,7 +3161,7 @@ test "OSC 133: end_of_input with cmdline 8" { test "OSC 133: end_of_input with cmdline 9" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline=$'"; for (input) |ch| p.next(ch); @@ -3180,7 +3174,7 @@ test "OSC 133: end_of_input with cmdline 9" { test "OSC 133: end_of_input with cmdline 10" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline="; for (input) |ch| p.next(ch); @@ -3194,7 +3188,7 @@ test "OSC 133: end_of_input with cmdline 10" { test "OSC 133: end_of_input with cmdline_url 1" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline_url=echo bobr kurwa"; for (input) |ch| p.next(ch); @@ -3208,7 +3202,7 @@ test "OSC 133: end_of_input with cmdline_url 1" { test "OSC 133: end_of_input with cmdline_url 2" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline_url=echo bobr%20kurwa"; for (input) |ch| p.next(ch); @@ -3222,7 +3216,7 @@ test "OSC 133: end_of_input with cmdline_url 2" { test "OSC 133: end_of_input with cmdline_url 3" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline_url=echo bobr%3bkurwa"; for (input) |ch| p.next(ch); @@ -3236,7 +3230,7 @@ test "OSC 133: end_of_input with cmdline_url 3" { test "OSC 133: end_of_input with cmdline_url 4" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline_url=echo bobr%3kurwa"; for (input) |ch| p.next(ch); @@ -3249,7 +3243,7 @@ test "OSC 133: end_of_input with cmdline_url 4" { test "OSC 133: end_of_input with cmdline_url 5" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline_url=echo bobr%kurwa"; for (input) |ch| p.next(ch); @@ -3262,7 +3256,7 @@ test "OSC 133: end_of_input with cmdline_url 5" { test "OSC 133: end_of_input with cmdline_url 6" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline_url=echo bobr%kurwa"; for (input) |ch| p.next(ch); @@ -3275,7 +3269,7 @@ test "OSC 133: end_of_input with cmdline_url 6" { test "OSC 133: end_of_input with cmdline_url 7" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline_url=echo bobr kurwa%20"; for (input) |ch| p.next(ch); @@ -3289,7 +3283,7 @@ test "OSC 133: end_of_input with cmdline_url 7" { test "OSC 133: end_of_input with cmdline_url 8" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline_url=echo bobr kurwa%2"; for (input) |ch| p.next(ch); @@ -3302,7 +3296,7 @@ test "OSC 133: end_of_input with cmdline_url 8" { test "OSC 133: end_of_input with cmdline_url 9" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "133;C;cmdline_url=echo bobr kurwa%2"; for (input) |ch| p.next(ch); @@ -3315,7 +3309,7 @@ test "OSC 133: end_of_input with cmdline_url 9" { test "OSC: OSC 777 show desktop notification with title" { const testing = std.testing; - var p: Parser = .init(); + var p: Parser = .init(null); const input = "777;notify;Title;Body"; for (input) |ch| p.next(ch); From 14b441be1e2c23b9b10030f1a3f3216d91f42faf Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Mon, 13 Oct 2025 14:10:29 -0700 Subject: [PATCH 107/702] renderer: Include arrows block in constrained symbols (#9189) Fixes #8693 **Before** Screenshot 2025-10-13 at 14 00 28 **After** Screenshot 2025-10-13 at 14 01 14 The effect is somewhat subtle with my combination of fonts. See #8693 for the more egregious examples that this fixes. --- src/build/uucode_config.zig | 1 + src/renderer/cell.zig | 1 + 2 files changed, 2 insertions(+) diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index 085ca2561..9a3b4bec7 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -41,6 +41,7 @@ fn computeIsSymbol( _ = tracking; const block = data.block; data.is_symbol = data.general_category == .other_private_use or + block == .arrows or block == .dingbats or block == .emoticons or block == .miscellaneous_symbols or diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 8c0215673..d8427689b 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -238,6 +238,7 @@ pub fn isCovering(cp: u21) bool { /// Returns true of the codepoint is a "symbol-like" character, which /// for now we define as anything in a private use area, and anything /// in several unicode blocks: +/// - Arrows /// - Dingbats /// - Emoticons /// - Miscellaneous Symbols From 54625537417459fd18a95a8120d82988c62f6b02 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 13 Oct 2025 14:27:38 -0700 Subject: [PATCH 108/702] config: only create template file is prior was not found --- 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 aabefcd8f..da7fced08 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3509,13 +3509,13 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { // If both files are not found, then we create a template file. // For macOS, we only create the template file in the app support - if (app_support_loaded and xdg_loaded) { + if (!app_support_loaded and !xdg_loaded) { writeConfigTemplate(app_support_path) catch |err| { log.warn("error creating template config file err={}", .{err}); }; } } else { - if (xdg_loaded) { + if (!xdg_loaded) { writeConfigTemplate(xdg_path) catch |err| { log.warn("error creating template config file err={}", .{err}); }; From 17a20e5b1c0165f24b5606aa909cdebdfb2e56b6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 13 Oct 2025 20:44:32 -0700 Subject: [PATCH 109/702] termio: don't start scroll timer if its already active (#9195) This might fix #9191, but since I don't have a reproduction I can't be sure. In any case, this is a bad bug that should be fixed. The issue is that we weren't checking our scroll timer completion state. This meant that if `start_scroll_timer` was called multiple times within a single loop tick, we'd enqueue our completion multiple times, leading to various undefined behaviors. If we don't enqueue anything else in the interim, this is safe by chance. But if we enqueue something else, then we'd hit a queue assertion failure and honestly I'm not totally sure what would happen. I wasn't able to trigger the "bad" case, but I was able to trigger the benign case very easily. Our other timers such as the renderer cursor timer already have this protection. Let's fix this and continue looking... --- src/termio/Thread.zig | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index edf966df7..bb616e623 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -471,15 +471,23 @@ fn stopCallback( fn startScrollTimer(self: *Thread, cb: *CallbackData) void { self.scroll_active = true; - // Start the timer which loops - self.scroll.run( - &self.loop, - &self.scroll_c, - selection_scroll_ms, - CallbackData, - cb, - selectionScrollCallback, - ); + switch (self.scroll_c.state()) { + // If it is already active, e.g. startScrollTimer is called multiple + // times, then we just return. We can't simply check `scroll_active` + // because its possible that `stopScrollTimer` was called but there + // was no loop tick between then and now to halt out completion. + .active => return, + + // If the completion is not active then we need to start it. + .dead => self.scroll.run( + &self.loop, + &self.scroll_c, + selection_scroll_ms, + CallbackData, + cb, + selectionScrollCallback, + ), + } } fn stopScrollTimer(self: *Thread) void { From 75734a4d070b92e9b73dda1aab93f5ae1c7a3766 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 13 Oct 2025 21:00:55 -0700 Subject: [PATCH 110/702] macos: clarify the "ready to install update" state - The copy is updated to better explain what the user should do next. - The symbol is updated to make it clear the update isn't yet installed. --- macos/Sources/Features/Update/UpdateViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index f0b39ed2d..7a92337cc 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -31,7 +31,7 @@ class UpdateViewModel: ObservableObject { case .extracting(let extracting): return String(format: "Preparing: %.0f%%", extracting.progress * 100) case .readyToInstall: - return "Install Update" + return "Ready to Install Update" case .installing: return "Restart to Complete Update" case .notFound: @@ -70,7 +70,7 @@ class UpdateViewModel: ObservableObject { case .extracting: return "shippingbox" case .readyToInstall: - return "checkmark.circle.fill" + return "restart.circle.fill" case .installing: return "power.circle" case .notFound: From 41bb8d7af056e13cc1d20ff038b3edf7f6a1b0dc Mon Sep 17 00:00:00 2001 From: Peter Guy Date: Tue, 14 Oct 2025 06:46:24 -0700 Subject: [PATCH 111/702] fix make clean: change dir name from zig-cache to .zig-cache (#9192) `make clean` was not removing the `.zig-cache` directory. Instead it was removing the `zig-cache` directory. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ad8379f7e..c5511a62e 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ vendor/glad/include/glad/glad.h: vendor/glad/include/glad/gl.h clean: rm -rf \ - zig-out zig-cache \ + zig-out .zig-cache \ macos/build \ macos/GhosttyKit.xcframework .PHONY: clean From 6eb26da3b75e4cff3881502f2d304bcf2755b7b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 14 Oct 2025 06:55:09 -0700 Subject: [PATCH 112/702] macos: fix failing xcode tests --- macos/Tests/Update/UpdateViewModelTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift index 56748ff83..e41804e08 100644 --- a/macos/Tests/Update/UpdateViewModelTests.swift +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -53,7 +53,7 @@ struct UpdateViewModelTests { @Test func testReadyToInstallText() { let viewModel = UpdateViewModel() viewModel.state = .readyToInstall(.init(reply: { _ in })) - #expect(viewModel.text == "Install Update") + #expect(viewModel.text == "Ready to Install Update") } @Test func testInstallingText() { From 06ad3b77b7df11070277432244738cde9b75ceb6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 14 Oct 2025 07:11:10 -0700 Subject: [PATCH 113/702] Zig 0.15.2 (#9200) --- flake.lock | 6 +++--- flake.nix | 2 +- src/benchmark/IsSymbol.zig | 16 ++++++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/flake.lock b/flake.lock index 349248668..a9005eebe 100644 --- a/flake.lock +++ b/flake.lock @@ -97,11 +97,11 @@ ] }, "locked": { - "lastModified": 1759192380, - "narHash": "sha256-0BWJgt4OSzxCESij5oo8WLWrPZ+1qLp8KUQe32QeV4Q=", + "lastModified": 1760401936, + "narHash": "sha256-/zj5GYO5PKhBWGzbHbqT+ehY8EghuABdQ2WGfCwZpCQ=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "0bcd1401ed43d10f10cbded49624206553e92f57", + "rev": "365085b6652259753b598d43b723858184980bbe", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 18241a447..85550b989 100644 --- a/flake.nix +++ b/flake.nix @@ -49,7 +49,7 @@ pkgs = nixpkgs.legacyPackages.${system}; in { devShell.${system} = pkgs.callPackage ./nix/devShell.nix { - zig = zig.packages.${system}."0.15.1"; + zig = zig.packages.${system}."0.15.2"; wraptest = pkgs.callPackage ./nix/wraptest.nix {}; zon2nix = zon2nix; }; diff --git a/src/benchmark/IsSymbol.zig b/src/benchmark/IsSymbol.zig index dffa5071a..c4667b333 100644 --- a/src/benchmark/IsSymbol.zig +++ b/src/benchmark/IsSymbol.zig @@ -91,12 +91,14 @@ fn stepUucode(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [4096]u8 = undefined; - var r = f.reader(&read_buf); + var f_reader = f.reader(&read_buf); + var r = &f_reader.interface; + var d: UTF8Decoder = .{}; var buf: [4096]u8 align(std.atomic.cache_line) = undefined; while (true) { - const n = r.read(&buf) catch |err| { - log.warn("error reading data file err={}", .{err}); + 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 @@ -116,12 +118,14 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [4096]u8 = undefined; - var r = f.reader(&read_buf); + var f_reader = f.reader(&read_buf); + var r = &f_reader.interface; + var d: UTF8Decoder = .{}; var buf: [4096]u8 align(std.atomic.cache_line) = undefined; while (true) { - const n = r.read(&buf) catch |err| { - log.warn("error reading data file err={}", .{err}); + 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 From 9f726492ac91d47355e37f04808dd5d3317270d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 14 Oct 2025 07:13:05 -0700 Subject: [PATCH 114/702] macOS: release builds from source using `zig build` uses ReleaseLocal (#9201) This fixes codesign issues that are common. The official release process does not use this, but it is useful for local builds. --- src/build/GhosttyXcodebuild.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 0afb64007..27691d744 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -31,7 +31,7 @@ pub fn init( .ReleaseSafe, .ReleaseSmall, .ReleaseFast, - => "Release", + => "ReleaseLocal", }; const xc_arch: ?[]const u8 = switch (deps.xcframework.target) { From 3d837cbbce254f0169602119e7b3fc33179c954d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 14 Oct 2025 07:31:31 -0700 Subject: [PATCH 115/702] macos: "Check for updates" cancels whatever the current update state is (#9203) This mainly allows users who have a pending update but didn't install it for some time to re-check to see if there is something newer in the mean time. --- .../Features/Update/UpdateController.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift index 2dfb0a420..8a2a939bd 100644 --- a/macos/Sources/Features/Update/UpdateController.swift +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -93,7 +93,22 @@ class UpdateController { /// /// This is typically connected to a menu item action. @objc func checkForUpdates() { - updater.checkForUpdates() + // If we're already idle, then just check for updates immediately. + if viewModel.state == .idle { + updater.checkForUpdates() + return + } + + // If we're not idle then we need to cancel any prior state. + installCancellable?.cancel() + viewModel.state.cancel() + + // The above will take time to settle, so we delay the check for some time. + // The 100ms is arbitrary and I'd rather not, but we have to wait more than + // one loop tick it seems. + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in + self?.updater.checkForUpdates() + } } /// Validate the check for updates menu item. From 54b021f6d67f3c95008abe67fbbab6681ca6fb6d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 14 Oct 2025 14:04:42 -0500 Subject: [PATCH 116/702] core: update zf to remove zg transitive dep (#9208) --- build.zig.zon | 4 ++-- build.zig.zon.json | 16 +++------------- build.zig.zon.nix | 22 +++------------------- build.zig.zon.txt | 4 +--- flatpak/zig-packages.json | 18 +++--------------- 5 files changed, 12 insertions(+), 52 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 8c05cb50a..dd2f9cfab 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -50,8 +50,8 @@ }, .zf = .{ // natecraddock/zf - .url = "https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.tar.gz", - .hash = "zf-0.10.3-OIRy8euIAACn1wj5gnZbqxbAdAKI5JGMeex8zrdPqORg", + .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", .lazy = true, }, .gobject = .{ diff --git a/build.zig.zon.json b/build.zig.zon.json index a94c3b13a..d0193ed8b 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -119,11 +119,6 @@ "url": "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz", "hash": "sha256-VomSYOF8fRJwb/8GtVG/QqR6c95zSkQt4649C/4KXAc=" }, - "vaxis-0.5.1-BWNV_BMgCQDXdZzABeY4F_xwgE7nHFtYEP07KgEwJWo8": { - "name": "vaxis", - "url": "git+https://github.com/rockorager/libvaxis#6eb16bb4190dc074dafaf4f0ce7dadd50e81192a", - "hash": "sha256-5c+TjmiH4071lKI+U8SIJ0M+4ezzcAtLI2ZSfZYdXSA=" - }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", @@ -149,15 +144,10 @@ "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", "hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=" }, - "zf-0.10.3-OIRy8euIAACn1wj5gnZbqxbAdAKI5JGMeex8zrdPqORg": { + "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": { "name": "zf", - "url": "https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.tar.gz", - "hash": "sha256-Kaduui4Llb9Fq1lCKLOeG8cWTknjhos8cSTeJ50KP/I=" - }, - "zg-0.15.1-oGqU3M0-tALZCy7boQS86znlBloyKx6--JriGlY0Paa9": { - "name": "zg", - "url": "https://codeberg.org/chaten/zg/archive/749197a3f9d25e211615960c02380a3d659b20f9.tar.gz", - "hash": "sha256-BZhz1nPqxK6hdsJQ66n7Jk4zMgFSGLXm8eU0CX/7mDI=" + "url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "hash": "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg=" }, "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi": { "name": "zig_js", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index ec69854fe..a6d768180 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -274,14 +274,6 @@ in hash = "sha256-VomSYOF8fRJwb/8GtVG/QqR6c95zSkQt4649C/4KXAc="; }; } - { - name = "vaxis-0.5.1-BWNV_BMgCQDXdZzABeY4F_xwgE7nHFtYEP07KgEwJWo8"; - path = fetchZigArtifact { - name = "vaxis"; - url = "git+https://github.com/rockorager/libvaxis#6eb16bb4190dc074dafaf4f0ce7dadd50e81192a"; - hash = "sha256-5c+TjmiH4071lKI+U8SIJ0M+4ezzcAtLI2ZSfZYdXSA="; - }; - } { name = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS"; path = fetchZigArtifact { @@ -323,19 +315,11 @@ in }; } { - name = "zf-0.10.3-OIRy8euIAACn1wj5gnZbqxbAdAKI5JGMeex8zrdPqORg"; + name = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh"; path = fetchZigArtifact { name = "zf"; - url = "https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.tar.gz"; - hash = "sha256-Kaduui4Llb9Fq1lCKLOeG8cWTknjhos8cSTeJ50KP/I="; - }; - } - { - name = "zg-0.15.1-oGqU3M0-tALZCy7boQS86znlBloyKx6--JriGlY0Paa9"; - path = fetchZigArtifact { - name = "zg"; - url = "https://codeberg.org/chaten/zg/archive/749197a3f9d25e211615960c02380a3d659b20f9.tar.gz"; - hash = "sha256-BZhz1nPqxK6hdsJQ66n7Jk4zMgFSGLXm8eU0CX/7mDI="; + url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz"; + hash = "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index ef854348a..e789fa4eb 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -1,6 +1,4 @@ git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732 -git+https://github.com/rockorager/libvaxis#6eb16bb4190dc074dafaf4f0ce7dadd50e81192a -https://codeberg.org/chaten/zg/archive/749197a3f9d25e211615960c02380a3d659b20f9.tar.gz https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz @@ -32,6 +30,6 @@ 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/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz -https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.tar.gz +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 ef9cb7624..93d73d2ca 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -143,12 +143,6 @@ "dest": "vendor/p/uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT", "sha256": "56899260e17c7d12706fff06b551bf42a47a73de734a442de3ae3d0bfe0a5c07" }, - { - "type": "git", - "url": "https://github.com/rockorager/libvaxis", - "commit": "6eb16bb4190dc074dafaf4f0ce7dadd50e81192a", - "dest": "vendor/p/vaxis-0.5.1-BWNV_BMgCQDXdZzABeY4F_xwgE7nHFtYEP07KgEwJWo8" - }, { "type": "archive", "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", @@ -181,15 +175,9 @@ }, { "type": "archive", - "url": "https://github.com/natecraddock/zf/archive/d2c89a7d6b579981da6c9c8912134cf5a22c7194.tar.gz", - "dest": "vendor/p/zf-0.10.3-OIRy8euIAACn1wj5gnZbqxbAdAKI5JGMeex8zrdPqORg", - "sha256": "29a76eba2e0b95bf45ab594228b39e1bc7164e49e3868b3c7124de279d0a3ff2" - }, - { - "type": "archive", - "url": "https://codeberg.org/chaten/zg/archive/749197a3f9d25e211615960c02380a3d659b20f9.tar.gz", - "dest": "vendor/p/zg-0.15.1-oGqU3M0-tALZCy7boQS86znlBloyKx6--JriGlY0Paa9", - "sha256": "059873d673eac4aea176c250eba9fb264e3332015218b5e6f1e534097ffb9832" + "url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "dest": "vendor/p/zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", + "sha256": "3b015d928af04e9e26272bc15eb4dbb4d9a9d469eb6d290a0ddae673b77c4568" }, { "type": "archive", From e5247f6d10ae02cc892c77d7435319549769ba1c Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 14 Oct 2025 15:12:45 -0400 Subject: [PATCH 117/702] termio: reimplement OSC 7 URI handling (#9193) This reimplements the MAC address-aware URI parsing logic used by the OSC 7 handler and adds an additional .raw_path option that returns the full, unencoded path string (including query and fragment values), which is needed for compliant kitty-shell-cwd:// handling. Notably, this implementation takes an options-based approach that allows these additional behaviors to be enabled at runtime. It also leverages two std.Uri.parse guarantees: 1. Return slices point into the original text string. 2. .raw components don't require unescaping (.percent_encoded does). The implementation is in a new 'os.uri' module because its now generic enough to not be hostname-oriented. We use os.uri.parseUri and its parsing options to reimplement our OSC 7 file-style URI handling. This has two advantages: First, it fixes kitty-shell-cwd scheme handling. This scheme expects the full, unencoded path string, whereas the file scheme expects normal URI percent encoding. This was preventing paths containing "special" URI characters (like "path?") from working correctly in our bash, zsh, and elvish shell integrations, which report working directories using the kitty-shell-cwd scheme. (fish uses file URIs, which work as expected.) Second, we can greatly simplify our hostname and path string handling because we can now rely on the "raw" std.Uri component form to always provide the correct representation. Lastly, this lets us remove the previous URI-related code from the os.hostname module, restoring its focus to hostname-related functions. See: #5289 --- src/os/hostname.zig | 317 +--------------------------------- src/os/main.zig | 2 + src/os/uri.zig | 204 ++++++++++++++++++++++ src/termio/stream_handler.zig | 73 +++----- 4 files changed, 234 insertions(+), 362 deletions(-) create mode 100644 src/os/uri.zig diff --git a/src/os/hostname.zig b/src/os/hostname.zig index a75ca1cbb..283ece8d9 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -1,148 +1,11 @@ const std = @import("std"); -const builtin = @import("builtin"); const posix = std.posix; -pub const HostnameParsingError = error{ - NoHostnameInUri, - NoSpaceLeft, -}; - pub const UrlParsingError = std.Uri.ParseError || error{ HostnameIsNotMacAddress, NoSchemeProvided, }; -const mac_address_length = 17; - -fn isUriPathSeparator(c: u8) bool { - return switch (c) { - '?', '#' => true, - else => false, - }; -} - -fn isValidMacAddress(mac_address: []const u8) bool { - // A valid mac address has 6 two-character components with 5 colons, e.g. 12:34:56:ab:cd:ef. - if (mac_address.len != 17) { - return false; - } - - for (mac_address, 0..) |c, i| { - if ((i + 1) % 3 == 0) { - if (c != ':') { - return false; - } - } else if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, c)) { - return false; - } - } - - return true; -} - -/// Parses the provided url to a `std.Uri` struct. This is very specific to getting hostname and -/// path information for Ghostty's PWD reporting functionality. Takes into account that on macOS -/// the url passed to this function might have a mac address as its hostname and parses it -/// correctly. -pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri { - return std.Uri.parse(url) catch |e| { - // The mac-address-as-hostname issue is specific to macOS so we just return an error if we - // hit it on other platforms. - if (comptime builtin.os.tag != .macos) return e; - - // It's possible this is a mac address on macOS where the last 2 characters in the - // address are non-digits, e.g. 'ff', and thus an invalid port. - // - // Example: file://12:34:56:78:90:12/path/to/file - if (e != error.InvalidPort) return e; - - const url_without_scheme_start = std.mem.indexOf(u8, url, "://") orelse { - return error.NoSchemeProvided; - }; - const scheme = url[0..url_without_scheme_start]; - const url_without_scheme = url[url_without_scheme_start + 3 ..]; - - // The first '/' after the scheme marks the end of the hostname. If the first '/' - // following the end of the scheme is not at the right position this is not a - // valid mac address. - if (url_without_scheme.len != mac_address_length and - std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != mac_address_length) - { - return error.HostnameIsNotMacAddress; - } - - // At this point we may have a mac address as the hostname. - const mac_address = url_without_scheme[0..mac_address_length]; - - if (!isValidMacAddress(mac_address)) { - return error.HostnameIsNotMacAddress; - } - - var uri_path_end_idx: usize = mac_address_length; - while (uri_path_end_idx < url_without_scheme.len and - !isUriPathSeparator(url_without_scheme[uri_path_end_idx])) - { - uri_path_end_idx += 1; - } - - // Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI - // spec. - return .{ - .scheme = scheme, - .host = .{ .percent_encoded = mac_address }, - .path = .{ - .percent_encoded = url_without_scheme[mac_address_length..uri_path_end_idx], - }, - }; - }; -} - -/// Print the hostname from a file URI into a buffer. -pub fn bufPrintHostnameFromFileUri( - buf: []u8, - uri: std.Uri, -) HostnameParsingError![]const u8 { - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const host_component = uri.host orelse return error.NoHostnameInUri; - const host: []const u8 = switch (host_component) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - - // When the "Private Wi-Fi address" setting is toggled on macOS the hostname - // is set to a random mac address, e.g. '12:34:56:78:90:ab'. - // The URI will be parsed as if the last set of digits is a port number, so - // we need to make sure that part is included when it's set. - - // We're only interested in special port handling when the current hostname is a - // partial MAC address that's potentially missing the last component. - // If that's not the case we just return the plain URI hostname directly. - // NOTE: This implementation is not sufficient to verify a valid mac address, but - // it's probably sufficient for this specific purpose. - if (host.len != 14 or std.mem.count(u8, host, ":") != 4) return host; - - // If we don't have a port then we can return the hostname as-is because - // it's not a partial MAC-address. - const port = uri.port orelse return host; - - // If the port is not a 1 or 2-digit number we're not looking at a partial - // MAC-address, and instead just a regular port so we return the plain - // URI hostname. - if (port > 99) return host; - - var fbs = std.io.fixedBufferStream(buf); - try std.fmt.format( - fbs.writer(), - // Make sure "port" is always 2 digits, prefixed with a 0 when "port" is a 1-digit number. - "{s}:{d:0>2}", - .{ host, port }, - ); - - return fbs.getWritten(); -} - pub const LocalHostnameValidationError = error{ PermissionDenied, Unexpected, @@ -151,7 +14,7 @@ pub const LocalHostnameValidationError = error{ /// Checks if a hostname is local to the current machine. This matches /// both "localhost" and the current hostname of the machine (as returned /// by `gethostname`). -pub fn isLocalHostname(hostname: []const u8) LocalHostnameValidationError!bool { +pub fn isLocal(hostname: []const u8) LocalHostnameValidationError!bool { // A 'localhost' hostname is always considered local. if (std.mem.eql(u8, "localhost", hostname)) return true; @@ -161,185 +24,19 @@ pub fn isLocalHostname(hostname: []const u8) LocalHostnameValidationError!bool { return std.mem.eql(u8, hostname, ourHostname); } -test parseUrl { - // 1. Typical hostnames. - - var uri = try parseUrl("file://personal.computer/home/test/"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); - - uri = try parseUrl("kitty-shell-cwd://personal.computer/home/test/"); - - try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); - try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); - - // 2. Hostnames that are mac addresses. - - // Numerical mac addresses. - - uri = try parseUrl("file://12:34:56:78:90:12/home/test/"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == 12); - - uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12/home/test/"); - - try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); - try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == 12); - - // Alphabetical mac addresses. - - uri = try parseUrl("file://ab:cd:ef:ab:cd:ef/home/test/"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); - - uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef/home/test/"); - - try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); - try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); - - // 3. Hostnames that are mac addresses with no path. - - // Numerical mac addresses. - - uri = try parseUrl("file://12:34:56:78:90:12"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("", uri.path.percent_encoded); - try std.testing.expect(uri.port == 12); - - uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12"); - - try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); - try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("", uri.path.percent_encoded); - try std.testing.expect(uri.port == 12); - - // Alphabetical mac addresses. - - uri = try parseUrl("file://ab:cd:ef:ab:cd:ef"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); - - uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef"); - - try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); - try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); - try std.testing.expectEqualStrings("", uri.path.percent_encoded); - try std.testing.expect(uri.port == null); +test "isLocal returns true when provided hostname is localhost" { + try std.testing.expect(try isLocal("localhost")); } -test "parseUrl succeeds even if path component is missing" { - const uri = try parseUrl("file://12:34:56:78:90:ab"); - - try std.testing.expectEqualStrings("file", uri.scheme); - try std.testing.expectEqualStrings("12:34:56:78:90:ab", uri.host.?.percent_encoded); - try std.testing.expect(uri.path.isEmpty()); - try std.testing.expect(uri.port == null); -} - -test "bufPrintHostnameFromFileUri succeeds with ascii hostname" { - const uri = try std.Uri.parse("file://localhost/"); - - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const actual = try bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectEqualStrings("localhost", actual); -} - -test "bufPrintHostnameFromFileUri succeeds with hostname as mac address" { - const uri = try std.Uri.parse("file://12:34:56:78:90:12"); - - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const actual = try bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectEqualStrings("12:34:56:78:90:12", actual); -} - -test "bufPrintHostnameFromFileUri succeeds with hostname as mac address with the last component as ascii" { - const uri = try parseUrl("file://12:34:56:78:90:ab"); - - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const actual = try bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectEqualStrings("12:34:56:78:90:ab", actual); -} - -test "bufPrintHostnameFromFileUri succeeds with hostname as a mac address and the last section is < 10" { - const uri = try std.Uri.parse("file://12:34:56:78:90:05"); - - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const actual = try bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectEqualStrings("12:34:56:78:90:05", actual); -} - -test "bufPrintHostnameFromFileUri returns only hostname when there is a port component in the URI" { - // First: try with a non-2-digit port, to test general port handling. - const four_port_uri = try std.Uri.parse("file://has-a-port:1234"); - - var four_port_buf: [posix.HOST_NAME_MAX]u8 = undefined; - const four_port_actual = try bufPrintHostnameFromFileUri(&four_port_buf, four_port_uri); - try std.testing.expectEqualStrings("has-a-port", four_port_actual); - - // Second: try with a 2-digit port to test mac-address handling. - const two_port_uri = try std.Uri.parse("file://has-a-port:12"); - - var two_port_buf: [posix.HOST_NAME_MAX]u8 = undefined; - const two_port_actual = try bufPrintHostnameFromFileUri(&two_port_buf, two_port_uri); - try std.testing.expectEqualStrings("has-a-port", two_port_actual); - - // Third: try with a mac-address that has a port-component added to it to test mac-address handling. - const mac_with_port_uri = try std.Uri.parse("file://12:34:56:78:90:12:1234"); - - var mac_with_port_buf: [posix.HOST_NAME_MAX]u8 = undefined; - const mac_with_port_actual = try bufPrintHostnameFromFileUri(&mac_with_port_buf, mac_with_port_uri); - try std.testing.expectEqualStrings("12:34:56:78:90:12", mac_with_port_actual); -} - -test "bufPrintHostnameFromFileUri returns NoHostnameInUri error when hostname is missing from uri" { - const uri = try std.Uri.parse("file:///"); - - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const actual = bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectError(HostnameParsingError.NoHostnameInUri, actual); -} - -test "bufPrintHostnameFromFileUri returns NoSpaceLeft error when provided buffer has insufficient size" { - const uri = try std.Uri.parse("file://12:34:56:78:90:12/"); - - var buf: [5]u8 = undefined; - const actual = bufPrintHostnameFromFileUri(&buf, uri); - try std.testing.expectError(HostnameParsingError.NoSpaceLeft, actual); -} - -test "isLocalHostname returns true when provided hostname is localhost" { - try std.testing.expect(try isLocalHostname("localhost")); -} - -test "isLocalHostname returns true when hostname is local" { +test "isLocal returns true when hostname is local" { var buf: [posix.HOST_NAME_MAX]u8 = undefined; const localHostname = try posix.gethostname(&buf); - try std.testing.expect(try isLocalHostname(localHostname)); + try std.testing.expect(try isLocal(localHostname)); } -test "isLocalHostname returns false when hostname is not local" { +test "isLocal returns false when hostname is not local" { try std.testing.expectEqual( false, - try isLocalHostname("not-the-local-hostname"), + try isLocal("not-the-local-hostname"), ); } diff --git a/src/os/main.zig b/src/os/main.zig index af851f673..2d269e412 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -29,6 +29,7 @@ pub const xdg = @import("xdg.zig"); pub const windows = @import("windows.zig"); pub const macos = @import("macos.zig"); pub const shell = @import("shell.zig"); +pub const uri = @import("uri.zig"); // Functions and types pub const CFReleaseThread = @import("cf_release_thread.zig"); @@ -67,6 +68,7 @@ pub const getKernelInfo = kernel_info.getKernelInfo; test { _ = i18n; _ = path; + _ = uri; if (comptime builtin.os.tag == .linux) { _ = kernel_info; diff --git a/src/os/uri.zig b/src/os/uri.zig new file mode 100644 index 000000000..3d674870c --- /dev/null +++ b/src/os/uri.zig @@ -0,0 +1,204 @@ +const std = @import("std"); + +pub const ParseOptions = struct { + /// Parse MAC addresses in the host component. + /// + /// This is useful when the "Private Wi-Fi address" is enabled on macOS, + /// which sets the hostname to a rotating MAC address (12:34:56:ab:cd:ef). + mac_address: bool = false, + + /// Return the full, raw, unencoded path string. Any query and fragment + /// values will be return as part of the path instead of as distinct + /// fields. + raw_path: bool = false, +}; + +pub const ParseError = std.Uri.ParseError || error{InvalidMacAddress}; + +/// Parses a URI from the given string. +/// +/// This extends std.Uri.parse with some additional ParseOptions. +pub fn parse(text: []const u8, options: ParseOptions) ParseError!std.Uri { + var uri = std.Uri.parse(text) catch |err| uri: { + // We can attempt to re-parse the text as a URI that has a MAC address + // in its host field (which tripped up std.Uri.parse's port parsing): + // + // file://12:34:56:78:90:aa/path/to/file + // ^^ InvalidPort + // + if (err != error.InvalidPort or !options.mac_address) return err; + + // We can assume that the initial Uri.parse already validated the + // scheme, so we only need to find its bounds within the string. + const scheme_end = std.mem.indexOf(u8, text, "://") orelse { + return error.InvalidFormat; + }; + const scheme = text[0..scheme_end]; + + // We similarly find the bounds of the host component by looking + // for the first slash (/) after the scheme. This is all we need + // for this case because the resulting slice can be unambiguously + // determined to be a MAC address (or not). + const host_start = scheme_end + "://".len; + const host_end = std.mem.indexOfScalarPos(u8, text, host_start, '/') orelse text.len; + const mac_address = text[host_start..host_end]; + if (!isValidMacAddress(mac_address)) return error.InvalidMacAddress; + + // Parse the rest of the text (starting with the path component) as a + // partial URI and then add our MAC address as its host component. + var uri = try std.Uri.parseAfterScheme(scheme, text[host_end..]); + uri.host = .{ .percent_encoded = mac_address }; + break :uri uri; + }; + + // When MAC address parsing is enabled, we need to handle the case where + // std.Uri.parse parsed the address's last octet as a numeric port number. + // We use a few heuristics to identify this case (14 characters, 4 colons) + // and then "repair" the result by reassign the .host component to the full + // MAC address and clearing the .port component. + // + // 12:34:56:78:90:99 -> [12:34:56:78:90, 99] -> 12:34:56:78:90:99 + // (original host) (parsed host + port) (restored host) + // + if (options.mac_address and uri.host != null) mac: { + const host = uri.host.?.percent_encoded; + if (host.len != 14 or std.mem.count(u8, host, ":") != 4) break :mac; + + const port = uri.port orelse break :mac; + if (port > 99) break :mac; + + // std.Uri.parse returns slices pointing into the original text string. + const host_start = @intFromPtr(host.ptr) - @intFromPtr(text.ptr); + const path_start = @intFromPtr(uri.path.percent_encoded.ptr) - @intFromPtr(text.ptr); + const mac_address = text[host_start..path_start]; + if (!isValidMacAddress(mac_address)) return error.InvalidMacAddress; + + uri.host = .{ .percent_encoded = mac_address }; + uri.port = null; + } + + // When the raw_path option is active, return everything after the authority + // (host) in the .path component, including any query and fragment values. + if (options.raw_path) { + // std.Uri.parse returns slices pointing into the original text string. + const path_start = @intFromPtr(uri.path.percent_encoded.ptr) - @intFromPtr(text.ptr); + uri.path = .{ .raw = text[path_start..] }; + uri.query = null; + uri.fragment = null; + } + + return uri; +} + +test "parse: mac_address" { + const testing = @import("std").testing; + + // Numeric MAC address without a port + const uri1 = try parse("file://00:12:34:56:78:90/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri1.scheme); + try testing.expectEqualStrings("00:12:34:56:78:90", uri1.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri1.path.percent_encoded); + try testing.expectEqual(null, uri1.port); + + // Numeric MAC address with a port + const uri2 = try parse("file://00:12:34:56:78:90:999/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri2.scheme); + try testing.expectEqualStrings("00:12:34:56:78:90", uri2.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri2.path.percent_encoded); + try testing.expectEqual(999, uri2.port); + + // Alphabetic MAC address without a port + const uri3 = try parse("file://ab:cd:ef:ab:cd:ef/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri3.scheme); + try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri3.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri3.path.percent_encoded); + try testing.expectEqual(null, uri3.port); + + // Alphabetic MAC address with a port + const uri4 = try parse("file://ab:cd:ef:ab:cd:ef:999/path", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri4.scheme); + try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri4.host.?.percent_encoded); + try testing.expectEqualStrings("/path", uri4.path.percent_encoded); + try testing.expectEqual(999, uri4.port); + + // Numeric MAC address without a path component + const uri5 = try parse("file://00:12:34:56:78:90", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri5.scheme); + try testing.expectEqualStrings("00:12:34:56:78:90", uri5.host.?.percent_encoded); + try testing.expect(uri5.path.isEmpty()); + + // Alphabetic MAC address without a path component + const uri6 = try parse("file://ab:cd:ef:ab:cd:ef", .{ .mac_address = true }); + try testing.expectEqualStrings("file", uri6.scheme); + try testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri6.host.?.percent_encoded); + try testing.expect(uri6.path.isEmpty()); + + // Invalid MAC addresses + try testing.expectError(error.InvalidMacAddress, parse( + "file://zz:zz:zz:zz:zz:00/path", + .{ .mac_address = true }, + )); + try testing.expectError(error.InvalidMacAddress, parse( + "file://zz:zz:zz:zz:zz:zz/path", + .{ .mac_address = true }, + )); +} + +test "parse: raw_path" { + const testing = @import("std").testing; + + const text = "file://localhost/path??#fragment"; + var buf: [256]u8 = undefined; + + const uri1 = try parse(text, .{ .raw_path = false }); + try testing.expectEqualStrings("file", uri1.scheme); + try testing.expectEqualStrings("localhost", uri1.host.?.percent_encoded); + try testing.expectEqualStrings("/path", try uri1.path.toRaw(&buf)); + try testing.expectEqualStrings("?", uri1.query.?.percent_encoded); + try testing.expectEqualStrings("fragment", uri1.fragment.?.percent_encoded); + + const uri2 = try parse(text, .{ .raw_path = true }); + try testing.expectEqualStrings("file", uri2.scheme); + try testing.expectEqualStrings("localhost", uri2.host.?.percent_encoded); + try testing.expectEqualStrings("/path??#fragment", try uri2.path.toRaw(&buf)); + try testing.expectEqual(null, uri2.query); + try testing.expectEqual(null, uri2.fragment); + + const uri3 = try parse("file://localhost", .{ .raw_path = true }); + try testing.expectEqualStrings("file", uri3.scheme); + try testing.expectEqualStrings("localhost", uri3.host.?.percent_encoded); + try testing.expect(uri3.path.isEmpty()); + try testing.expectEqual(null, uri3.query); + try testing.expectEqual(null, uri3.fragment); +} + +/// Checks if a string represents a valid MAC address, e.g. 12:34:56:ab:cd:ef. +fn isValidMacAddress(s: []const u8) bool { + if (s.len != 17) return false; + + for (s, 0..) |c, i| { + if (i % 3 == 2) { + if (c != ':') return false; + } else { + switch (c) { + '0'...'9', 'A'...'F', 'a'...'f' => {}, + else => return false, + } + } + } + + return true; +} + +test isValidMacAddress { + const testing = @import("std").testing; + + try testing.expect(isValidMacAddress("01:23:45:67:89:Aa")); + try testing.expect(isValidMacAddress("Aa:Bb:Cc:Dd:Ee:Ff")); + + try testing.expect(!isValidMacAddress("")); + try testing.expect(!isValidMacAddress("00:23:45")); + try testing.expect(!isValidMacAddress("00:23:45:Xx:Yy:Zz")); + try testing.expect(!isValidMacAddress("01-23-45-67-89-Aa")); + try testing.expect(!isValidMacAddress("01:23:45:67:89:Aa:Bb")); +} diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 06ff29809..db2cf11a6 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1089,7 +1089,13 @@ pub const StreamHandler = struct { return; } - const uri: std.Uri = internal_os.hostname.parseUrl(url) catch |e| { + // Attempt to parse this file-style URI using options appropriate + // for this OSC 7 context (e.g. kitty-shell-cwd expects the full, + // unencoded path). + const uri: std.Uri = internal_os.uri.parse(url, .{ + .mac_address = comptime builtin.os.tag != .macos, + .raw_path = std.mem.startsWith(u8, url, "kitty-shell-cwd://"), + }) catch |e| { log.warn("invalid url in OSC 7: {}", .{e}); return; }; @@ -1097,26 +1103,18 @@ pub const StreamHandler = struct { if (!std.mem.eql(u8, "file", uri.scheme) and !std.mem.eql(u8, "kitty-shell-cwd", uri.scheme)) { - log.warn("OSC 7 scheme must be file, got: {s}", .{uri.scheme}); + log.warn("OSC 7 scheme must be file or kitty-shell-cwd, got: {s}", .{uri.scheme}); return; } - // RFC 793 defines port numbers as 16-bit numbers. 5 digits is sufficient to represent - // the maximum since 2^16 - 1 = 65_535. - // See https://www.rfc-editor.org/rfc/rfc793#section-3.1. - const PORT_NUMBER_MAX_DIGITS = 5; - // Make sure there is space for a max length hostname + the max number of digits. - var host_and_port_buf: [posix.HOST_NAME_MAX + PORT_NUMBER_MAX_DIGITS]u8 = undefined; - const hostname_from_uri = internal_os.hostname.bufPrintHostnameFromFileUri( - &host_and_port_buf, - uri, - ) catch |err| switch (err) { - error.NoHostnameInUri => { + var host_buffer: [std.Uri.host_name_max]u8 = undefined; + const host = uri.getHost(&host_buffer) catch |err| switch (err) { + error.UriMissingHost => { log.warn("OSC 7 uri must contain a hostname: {}", .{err}); return; }, - error.NoSpaceLeft => |e| { - log.warn("failed to get full hostname for OSC 7 validation: {}", .{e}); + error.UriHostTooLong => { + log.warn("failed to get full hostname for OSC 7 validation: {}", .{err}); return; }, }; @@ -1124,9 +1122,7 @@ pub const StreamHandler = struct { // OSC 7 is a little sketchy because anyone can send any value from // any host (such an SSH session). The best practice terminals follow // is to valid the hostname to be local. - const host_valid = internal_os.hostname.isLocalHostname( - hostname_from_uri, - ) catch |err| switch (err) { + const host_valid = internal_os.hostname.isLocal(host) catch |err| switch (err) { error.PermissionDenied, error.Unexpected, => { @@ -1135,43 +1131,16 @@ pub const StreamHandler = struct { }, }; if (!host_valid) { - log.warn("OSC 7 host must be local", .{}); + log.warn("OSC 7 host ({s}) must be local", .{host}); return; } - // We need to unescape the path. We first try to unescape onto - // the stack and fall back to heap allocation if we have to. - var path_buf: [1024]u8 = undefined; - const path, const heap = path: { - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const path = switch (uri.path) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - - // If the path doesn't have any escapes, we can use it directly. - if (std.mem.indexOfScalar(u8, path, '%') == null) - break :path .{ path, false }; - - // First try to stack-allocate - var stack_writer: std.Io.Writer = .fixed(&path_buf); - if (uri.path.formatRaw(&stack_writer)) |_| { - break :path .{ stack_writer.buffered(), false }; - } else |_| {} - - // Fall back to heap - var alloc_writer: std.Io.Writer.Allocating = .init(self.alloc); - if (uri.path.formatRaw(&alloc_writer.writer)) |_| { - break :path .{ alloc_writer.written(), true }; - } else |_| {} - - // Fall back to using it directly... - log.warn("failed to unescape OSC 7 path, using it directly path={s}", .{path}); - break :path .{ path, false }; - }; - defer if (heap) self.alloc.free(path); + // We need the raw path, which might require unescaping. We try to + // avoid making any heap allocations by using the stack first. + var arena_alloc: std.heap.ArenaAllocator = .init(self.alloc); + var stack_alloc = std.heap.stackFallback(1024, arena_alloc.allocator()); + defer arena_alloc.deinit(); + const path = try uri.path.toRawMaybeAlloc(stack_alloc.get()); log.debug("terminal pwd: {s}", .{path}); try self.terminal.setPwd(path); From bdd2e4d7347cbf16db479ecea8108b44e7e4203d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 15 Oct 2025 13:55:11 -0500 Subject: [PATCH 118/702] build: more Zig 0.15.2 updates (#9217) - update nixpkgs now that Zig 0.15.2 is available in nixpkgs - drop hack that worked around compile failures on systems with more than 32 cores - enforce patch version of Zig --- build.zig | 14 -------------- build.zig.zon | 2 +- flake.lock | 6 +++--- flatpak/dependencies.yml | 8 ++++---- src/build/zig.zig | 5 +++-- src/config/Config.zig | 2 +- src/terminal/PageList.zig | 2 +- typos.toml | 4 ++++ 8 files changed, 17 insertions(+), 26 deletions(-) diff --git a/build.zig b/build.zig index 205896390..7836b5c0d 100644 --- a/build.zig +++ b/build.zig @@ -10,10 +10,6 @@ comptime { } pub fn build(b: *std.Build) !void { - // Works around a Zig but still present in 0.15.1. Remove when fixed. - // https://github.com/ghostty-org/ghostty/issues/8924 - try limitCoresForZigBug(); - // This defines all the available build options (e.g. `-D`). If you // want to know what options are available, you can run `--help` or // you can read `src/build/Config.zig`. @@ -312,13 +308,3 @@ pub fn build(b: *std.Build) !void { try translations_step.addError("cannot update translations when i18n is disabled", .{}); } } - -// WARNING: Remove this when https://github.com/ghostty-org/ghostty/issues/8924 is resolved! -// Limit ourselves to 32 cpus on Linux because of an upstream Zig bug. -fn limitCoresForZigBug() !void { - if (comptime builtin.os.tag != .linux) return; - const pid = std.os.linux.getpid(); - var set: std.bit_set.ArrayBitSet(usize, std.os.linux.CPU_SETSIZE * 8) = .initEmpty(); - for (0..32) |cpu| set.set(cpu); - try std.os.linux.sched_setaffinity(pid, &set.masks); -} diff --git a/build.zig.zon b/build.zig.zon index dd2f9cfab..050162936 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,7 +3,7 @@ .version = "1.2.1", .paths = .{""}, .fingerprint = 0x64407a2a0b4147e5, - .minimum_zig_version = "0.15.1", + .minimum_zig_version = "0.15.2", .dependencies = .{ // Zig libs diff --git a/flake.lock b/flake.lock index a9005eebe..90b97ed4a 100644 --- a/flake.lock +++ b/flake.lock @@ -37,10 +37,10 @@ "nixpkgs": { "locked": { "lastModified": 315532800, - "narHash": "sha256-YwoXN6fthkakCFD7nXPcUK+rkNr6ZTNTuF8zdGaxZo0=", - "rev": "dc704e6102e76aad573f63b74c742cd96f8f1e6c", + "narHash": "sha256-sV6pJNzFkiPc6j9Bi9JuHBnWdVhtKB/mHgVmMPvDFlk=", + "rev": "82c2e0d6dde50b17ae366d2aa36f224dc19af469", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre870318.dc704e6102e7/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre877938.82c2e0d6dde5/nixexprs.tar.xz" }, "original": { "type": "tarball", diff --git a/flatpak/dependencies.yml b/flatpak/dependencies.yml index 667e4662c..87512a547 100644 --- a/flatpak/dependencies.yml +++ b/flatpak/dependencies.yml @@ -13,12 +13,12 @@ modules: - chmod a+x /app/zig/zig sources: - type: archive - sha256: c61c5da6edeea14ca51ecd5e4520c6f4189ef5250383db33d01848293bfafe05 - url: https://ziglang.org/download/0.15.1/zig-x86_64-linux-0.15.1.tar.xz + sha256: 02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239 + url: https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz only-arches: [x86_64] - type: archive - sha256: bb4a8d2ad735e7fba764c497ddf4243cb129fece4148da3222a7046d3f1f19fe - url: https://ziglang.org/download/0.15.1/zig-aarch64-linux-0.15.1.tar.xz + sha256: 958ed7d1e00d0ea76590d27666efbf7a932281b3d7ba0c6b01b0ff26498f667f + url: https://ziglang.org/download/0.15.2/zig-aarch64-linux-0.15.2.tar.xz only-arches: [aarch64] - name: bzip2-redirect diff --git a/src/build/zig.zig b/src/build/zig.zig index 7e327127d..3ee8ffe74 100644 --- a/src/build/zig.zig +++ b/src/build/zig.zig @@ -7,10 +7,11 @@ pub fn requireZig(comptime required_zig: []const u8) void { const current_vsn = builtin.zig_version; const required_vsn = std.SemanticVersion.parse(required_zig) catch unreachable; if (current_vsn.major != required_vsn.major or - current_vsn.minor != required_vsn.minor) + current_vsn.minor != required_vsn.minor or + current_vsn.patch < required_vsn.patch) { @compileError(std.fmt.comptimePrint( - "Your Zig version v{} does not meet the required build version of v{}", + "Your Zig version v{f} does not meet the required build version of v{f}", .{ current_vsn, required_vsn }, )); } diff --git a/src/config/Config.zig b/src/config/Config.zig index da7fced08..bb10ff439 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4109,7 +4109,7 @@ pub fn finalize(self: *Config) !void { // Clamp our contrast self.@"minimum-contrast" = @min(21, @max(1, self.@"minimum-contrast")); - // Minimmum window size + // Minimum window size if (self.@"window-width" > 0) self.@"window-width" = @max(10, self.@"window-width"); if (self.@"window-height" > 0) self.@"window-height" = @max(4, self.@"window-height"); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9bf116598..e98c6f50d 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1976,7 +1976,7 @@ pub const AdjustCapacity = struct { pub const AdjustCapacityError = Allocator.Error || Page.CloneFromError; -/// Adjust the capcaity of the given page in the list. This should +/// Adjust the capacity of the given page in the list. This should /// be used in cases where OutOfMemory is returned by some operation /// i.e to increase style counts, grapheme counts, etc. /// diff --git a/typos.toml b/typos.toml index 5a23527d9..e97951755 100644 --- a/typos.toml +++ b/typos.toml @@ -55,6 +55,10 @@ typ = "typ" kend = "kend" # GTK GIR = "GIR" +# terminfo +rin = "rin" +# sprites +ower = "ower" [type.po] extend-glob = ["*.po"] From c91bfb9dd515326bc318ded92d3c7ae0a5b2ab68 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 15 Oct 2025 13:55:30 -0500 Subject: [PATCH 119/702] bump version for development (#9218) --- build.zig.zon | 2 +- nix/package.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 050162936..245a67e7b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .ghostty, - .version = "1.2.1", + .version = "1.3.0-dev", .paths = .{""}, .fingerprint = 0x64407a2a0b4147e5, .minimum_zig_version = "0.15.2", diff --git a/nix/package.nix b/nix/package.nix index 73d31c3b9..3d00648ec 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -40,7 +40,7 @@ in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "1.2.1"; + version = "1.3.0-dev"; # We limit source like this to try and reduce the amount of rebuilds as possible # thus we only provide the source that is needed for the build From 1caab0c208a48a03d9b73fd60727d2b38e66562b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 15 Oct 2025 13:56:07 -0500 Subject: [PATCH 120/702] nix: make sure zon2nix uses Zig 0.15 in generated files (#9220) --- build.zig.zon.nix | 4 ++-- nix/build-support/check-zig-cache.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon.nix b/build.zig.zon.nix index a6d768180..3c4f4d855 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -5,7 +5,7 @@ fetchurl, fetchgit, runCommandLocal, - zig_0_14, + zig_0_15, name ? "zig-packages", }: let unpackZigArtifact = { @@ -14,7 +14,7 @@ }: runCommandLocal name { - nativeBuildInputs = [zig_0_14]; + nativeBuildInputs = [zig_0_15]; } '' hash="$(zig fetch --global-cache-dir "$TMPDIR" ${artifact})" diff --git a/nix/build-support/check-zig-cache.sh b/nix/build-support/check-zig-cache.sh index e92a27b6f..9a3927846 100755 --- a/nix/build-support/check-zig-cache.sh +++ b/nix/build-support/check-zig-cache.sh @@ -79,7 +79,7 @@ elif [ "$1" != "--update" ]; then exit 1 fi -zon2nix "$BUILD_ZIG_ZON" --14 --nix "$WORK_DIR/build.zig.zon.nix" --txt "$WORK_DIR/build.zig.zon.txt" --json "$WORK_DIR/build.zig.zon.json" --flatpak "$WORK_DIR/zig-packages.json" +zon2nix "$BUILD_ZIG_ZON" --15 --nix "$WORK_DIR/build.zig.zon.nix" --txt "$WORK_DIR/build.zig.zon.txt" --json "$WORK_DIR/build.zig.zon.json" --flatpak "$WORK_DIR/zig-packages.json" alejandra --quiet "$WORK_DIR/build.zig.zon.nix" prettier --log-level warn --write "$WORK_DIR/build.zig.zon.json" prettier --log-level warn --write "$WORK_DIR/zig-packages.json" From d460800a1710b6fa700424b12421cea0ab5b03ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 15 Oct 2025 15:44:51 -0700 Subject: [PATCH 121/702] chore: typos should ignore build artifacts (#9222) --- typos.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/typos.toml b/typos.toml index e97951755..26876aef9 100644 --- a/typos.toml +++ b/typos.toml @@ -5,6 +5,9 @@ extend-exclude = [ "build.zig.zon.nix", "build.zig.zon.txt", "build.zig.zon.json", + # Build artifacts + "macos/build/*", + "zig-out/*", # vendored code "vendor/*", "pkg/*", From 3665040b59a41540bd9946107735f5a0aa0d7af3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 15 Oct 2025 15:47:08 -0700 Subject: [PATCH 122/702] Selection dragging should not process when terminal screen changes (#9223) This hasn't caused any known bugs but leads to selection memory corruption and assertion failures in runtime safe modes. When the terminal screen changes (primary to secondary) and we have an active dragging mode going either by moving the mouse or our selection tick timer, we should halt. We still keep the mouse state active which lets selection continue once the screen switches back. --- src/Surface.zig | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 82d6615b6..456acad2c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1057,6 +1057,16 @@ fn selectionScrollTick(self: *Surface) !void { defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; + // If our screen changed while this is happening, we stop our + // selection scroll. + if (self.mouse.left_click_screen != t.active_screen) { + self.io.queueMessage( + .{ .selection_scroll = false }, + .locked, + ); + return; + } + // Scroll the viewport as required try t.scrollViewport(.{ .delta = delta }); @@ -4151,6 +4161,12 @@ pub fn cursorPosCallback( // count because we don't want to handle selection. if (self.mouse.left_click_count == 0) break :select; + // If our terminal screen changed then we don't process this. We don't + // invalidate our pin or mouse state because if the screen switches + // back then we can continue our selection. + const t: *terminal.Terminal = self.renderer_state.terminal; + if (self.mouse.left_click_screen != t.active_screen) break :select; + // All roads lead to requiring a re-render at this point. try self.queueRender(); @@ -4174,7 +4190,7 @@ pub fn cursorPosCallback( } // Convert to points - const screen = &self.renderer_state.terminal.screen; + const screen = &t.screen; const pin = screen.pages.pin(.{ .viewport = .{ .x = pos_vp.x, From 014a2e004274c6725c3f0225dd2a230c1cbeae30 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 15 Oct 2025 16:01:26 -0700 Subject: [PATCH 123/702] termio: color change operations must gracefully handle renderer mailbox full (#9224) Fixes #9191 This changes our color change operations from writing to the renderer mailbox directly to using our `rendererMailboxWriter` function which handles the scenario where the mailbox is full by yielding the lock, waking up the renderer, and retrying later. This is a known deadlock scenario we've worked around since the private beta days, but unfortunately this slipped through and I didn't catch it in review. What happens here is it's possible with certain escape sequences for the IO thread to saturate other mailboxes with messages while holding the terminal state lock. This can happen to any thread. This ultimately leads to starvation and all threads deadlock. Our IO thread is the only thread that produces this kind of massive stream of events while holding the lock, so we have helpers in it to attempt to queue (cheap, fast) and if that fails then to yield the lock, wakeup the target thread, requeue, and grab the lock again (expensive, slow). --- src/termio/stream_handler.zig | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index db2cf11a6..dd8669d90 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1192,21 +1192,21 @@ pub const StreamHandler = struct { .dynamic => |dynamic| switch (dynamic) { .foreground => { self.foreground_color = set.color; - _ = self.renderer_mailbox.push(.{ + self.rendererMessageWriter(.{ .foreground_color = set.color, - }, .{ .forever = {} }); + }); }, .background => { self.background_color = set.color; - _ = self.renderer_mailbox.push(.{ + self.rendererMessageWriter(.{ .background_color = set.color, - }, .{ .forever = {} }); + }); }, .cursor => { self.cursor_color = set.color; - _ = self.renderer_mailbox.push(.{ + self.rendererMessageWriter(.{ .cursor_color = set.color, - }, .{ .forever = {} }); + }); }, .pointer_foreground, .pointer_background, @@ -1246,9 +1246,9 @@ pub const StreamHandler = struct { .dynamic => |dynamic| switch (dynamic) { .foreground => { self.foreground_color = null; - _ = self.renderer_mailbox.push(.{ + self.rendererMessageWriter(.{ .foreground_color = self.foreground_color, - }, .{ .forever = {} }); + }); self.surfaceMessageWriter(.{ .color_change = .{ .target = target, @@ -1257,9 +1257,9 @@ pub const StreamHandler = struct { }, .background => { self.background_color = null; - _ = self.renderer_mailbox.push(.{ + self.rendererMessageWriter(.{ .background_color = self.background_color, - }, .{ .forever = {} }); + }); self.surfaceMessageWriter(.{ .color_change = .{ .target = target, @@ -1269,9 +1269,9 @@ pub const StreamHandler = struct { .cursor => { self.cursor_color = null; - _ = self.renderer_mailbox.push(.{ + self.rendererMessageWriter(.{ .cursor_color = self.cursor_color, - }, .{ .forever = {} }); + }); if (self.default_cursor_color) |color| { self.surfaceMessageWriter(.{ .color_change = .{ From e1b527fb9ac276f21d64780818e4bfd70ead802e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 15 Oct 2025 19:42:49 -0700 Subject: [PATCH 124/702] core: PageList tracks minimum metadata for rendering a scrollbar (#9225) Related to #111 This adds the necessary logic and data for the `PageList` data structure to keep track of **total length** of the screen, **offset** into the viewport, and **length** of the viewport. These three values are necessary to _render_ a scrollbar. This PR updates the renderer to grab this information but stops short of actually drawing a scrollbar (which we'll do with native UI), in the interest of having a PR that doesn't contain too many changes. **This doesn't yet draw a scrollbar, these are just the internal changes necessary to support it.** ## Background The `PageList` structure is very core to how we represent terminal state. It maintains a doubly linked list of "pages" (not literally virtual memory pages, but close). Each page stores cell information, styles, hyperlinks, etc fully self-contained in a contiguous sets of VM pages using offset addresses rather than full pointers. **Pages are not guaranteed to be equal sizes.** (This is where scrollbars get difficult) Because it is a linked list structure of non-equal sized nodes, it isn't amenable to typical scrollbar behavior. A scrollbar needs to know: full size, offset, and length in order to draw the scrollbar properly. Getting these values naively is `O(N)` within the data structure that is on the hottest IO performance path in all of Ghostty. ## Implementation ### PageList We now maintain two cached values for **total length** and **viewport offset**. The total length is relatively straightforward, we just have to be careful to update it in every operation that could add or remove rows. I've done this and ensured that every place we update it is covered with unit test coverage. The viewport offset is nasty, but I came up with what I believe is a good solution. The viewport when arbitrarily scrolled is defined as a direct pointer to the linked list node plus a row offset into that node. The only way to calculate offset from the top is `O(N)`. But we have a couple shortcuts: 1. If the viewport is at the bottom (most common) or top, calculating the offset is `O(1)`: bottom is `total_rows - active_rows`, both readily available. And top is `0` by definition. 2. Operations on the PageList typically add or remove rows. We don't do arbitrary linked list surgery. If we instrument those areas with delta updates to our cache, we can avoid the `O(N)` cost for most operations, including scrolling a scrollbar. The only expensive operation is a full, arbitrary jump (new node pointer). Point 1 was quick to implement, so I focused all the complexity on point 2. Whenever we have an operation that adds or removes rows (for example pruning the scroll back, adding more, erase rows within the active area, etc.) then I do the math to calculate the delta change required for the offset if we've already calculated it, and apply that directly. ### Renderer The other issue was how to notify the apprts of scrollbar state. Sending messages on any terminal change within the IO thread is a non-option because (1) sending messages is slow (2) the terminal changes a lot and (3) any slowness in the IO thread slows down overall terminal throughput. The solution was to **trigger scrollbar notifications with the renderer vsync**. We read the scrollbar information when we render a frame, compare it to renderer previous state, and if the scrollbar changed, send a message to the apprt _after the frame is GPU-renderer_. The renderer spends _most_ of its time sleeping compared to the IO thread, and has more opportunities for optimizing its awake time. Additionally, there's no reason to update the scrollbar information if the renderer hasn't rendered the new frames because the user can't even see the stuff the scrollbar wants to scroll to. We're talking about millisecond scale stuff here at worst but it adds up. ## Performance No noticeable performance impact for the additional metrics: image ## AI Usage I used Amp to help audit the codebase and write tests. I wrote all the main implementation code manually. I came up with the main design myself. Relevant threads: - https://ampcode.com/threads/T-95fff686-75bb-4553-a2fb-e41fe4cd4b77#message-0-block-0 - https://ampcode.com/threads/T-48e9a288-b280-4eec-83b7-ca73d029b4ef#message-91-block-0 ## Future This is just the internal changes necessary to _draw_ a scrollbar. There will be other changes we'll need to add to handle grabbing and actually jumping the scrollbar. I have a good idea of how to implement those performantly as well. --- src/renderer/generic.zig | 25 + src/terminal/PageList.zig | 1200 ++++++++++++++++++++++++++++++++++--- src/terminal/main.zig | 1 + 3 files changed, 1155 insertions(+), 71 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index d66a32286..876e3f945 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -114,6 +114,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// True if the window is focused focused: bool, + /// The most recent scrollbar state. We use this as a cache to + /// determine if we need to notify the apprt that there was a + /// scrollbar change. + scrollbar: terminal.Scrollbar, + scrollbar_dirty: bool, + /// The foreground color set by an OSC 10 sequence. If unset then /// default_foreground_color is used. foreground_color: ?terminal.color.RGB, @@ -683,6 +689,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .grid_metrics = font_critical.metrics, .size = options.size, .focused = true, + .scrollbar = .zero, + .scrollbar_dirty = false, .foreground_color = null, .default_foreground_color = options.config.foreground, .background_color = null, @@ -1087,6 +1095,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { preedit: ?renderer.State.Preedit, cursor_style: ?renderer.CursorStyle, color_palette: terminal.color.Palette, + scrollbar: terminal.Scrollbar, /// If true, rebuild the full screen. full_rebuild: bool, @@ -1111,6 +1120,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return; } + // 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 + // can be expensive) and also makes it so we don't need another + // cross-thread mailbox message within the IO path. + const scrollbar = state.terminal.screen.pages.scrollbar(); + // Swap bg/fg if the terminal is reversed const bg = self.background_color orelse self.default_background_color; const fg = self.foreground_color orelse self.default_foreground_color; @@ -1238,6 +1254,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .preedit = preedit, .cursor_style = cursor_style, .color_palette = state.terminal.color_palette.colors, + .scrollbar = scrollbar, .full_rebuild = full_rebuild, }; }; @@ -1266,6 +1283,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.draw_mutex.lock(); defer self.draw_mutex.unlock(); + // The scrollbar is only emitted during draws so we also + // check the scrollbar cache here and update if needed. + // This is pretty fast. + if (!self.scrollbar.eql(critical.scrollbar)) { + self.scrollbar = critical.scrollbar; + self.scrollbar_dirty = true; + } + // Update our background color self.uniforms.bg_color = .{ critical.bg.r, diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index e98c6f50d..b71c87faa 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -128,6 +128,10 @@ explicit_max_size: usize, /// and at least two pages for our algorithms. min_max_size: usize, +/// The total number of rows represented by this PageList. This is used +/// specifically for scrollbar information so we can have the total size. +total_rows: usize, + /// The list of tracked pins. These are kept up to date automatically. tracked_pins: PinSet, @@ -145,12 +149,35 @@ viewport: Viewport, /// never be access directly; use `viewport`. viewport_pin: *Pin, +/// The row offset from the top that the viewport pin is at. We +/// store the offset from the top because it doesn't change while more +/// data is printed to the terminal. +/// +/// This is null when it isn't calculated. It is calculated on demand +/// when the viewportRowOffset function is called, because it is only +/// required for certain operations such as rendering the scrollbar. +/// +/// In order to make this more efficient, in many places where the value +/// would be invalidated, we update it in-place instead. This is key to +/// keeping our performance decent in normal cases since recalculating +/// this from scratch, depending on the size of the scrollback and position +/// of the pin, can be very expensive. +/// +/// This is only valid if viewport is `pin`. Every other offset is +/// self-evident or quick to calculate. +viewport_pin_row_offset: ?usize, + /// The current desired screen dimensions. I say "desired" because individual /// pages may still be a different size and not yet reflowed since we lazily /// reflow text. cols: size.CellCountInt, rows: size.CellCountInt, +/// If this is true then verifyIntegrity will do nothing. This is +/// only present with runtime safety enabled. +pause_integrity_checks: if (build_options.slow_runtime_safety) usize else void = + if (build_options.slow_runtime_safety) 0 else {}, + /// The viewport location. pub const Viewport = union(enum) { /// The viewport is pinned to the active area. By using a specific marker @@ -249,7 +276,7 @@ pub fn init( errdefer tracked_pins.deinit(pool.alloc); try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {}); - return .{ + const result: PageList = .{ .cols = cols, .rows = rows, .pool = pool, @@ -258,10 +285,14 @@ pub fn init( .page_size = page_size, .explicit_max_size = max_size orelse std.math.maxInt(usize), .min_max_size = min_max_size, + .total_rows = rows, .tracked_pins = tracked_pins, .viewport = .{ .active = {} }, .viewport_pin = viewport_pin, + .viewport_pin_row_offset = null, }; + result.assertIntegrity(); + return result; } fn initPages( @@ -306,9 +337,87 @@ fn initPages( return .{ page_list, page_size }; } +/// Assert that the PageList is in a valid state. This is a no-op in +/// release builds. +pub inline fn assertIntegrity(self: *const PageList) void { + if (comptime !build_options.slow_runtime_safety) return; + + self.verifyIntegrity() catch |err| { + log.err("PageList integrity check failed: {}", .{err}); + @panic("PageList integrity check failed"); + }; +} + +/// Pause or resume integrity checks. This is useful when you're doing +/// a multi-step operation that temporarily leaves the PageList in an +/// inconsistent state. +pub inline fn pauseIntegrityChecks(self: *PageList, pause: bool) void { + if (comptime !build_options.slow_runtime_safety) return; + if (pause) { + self.pause_integrity_checks += 1; + } else { + self.pause_integrity_checks -= 1; + } +} + +const IntegrityError = error{ + TotalRowsMismatch, + ViewportPinOffsetMismatch, +}; + +/// Verify the integrity of the PageList. This is expensive and should +/// only be called in debug/test builds. +fn verifyIntegrity(self: *const PageList) IntegrityError!void { + if (comptime !build_options.slow_runtime_safety) return; + if (self.pause_integrity_checks > 0) return; + + // 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={}", + .{ self.total_rows, actual_total }, + ); + return IntegrityError.TotalRowsMismatch; + } + + // Verify that our viewport pin row offset is correct. + if (self.viewport == .pin) pin: { + const cached_offset = self.viewport_pin_row_offset orelse break :pin; + const actual_offset: usize = offset: { + var offset: usize = 0; + var node = self.pages.last; + while (node) |n| : (node = n.prev) { + offset += n.data.size.rows; + if (n == self.viewport_pin.node) { + offset -= self.viewport_pin.y; + break :offset self.total_rows - offset; + } + } + + log.warn( + "PageList integrity violation: viewport pin not in list", + .{}, + ); + return error.ViewportPinOffsetMismatch; + }; + + if (cached_offset != actual_offset) { + log.warn( + "PageList integrity violation: viewport pin offset mismatch cached={} actual={}", + .{ cached_offset, actual_offset }, + ); + return error.ViewportPinOffsetMismatch; + } + } +} + /// Deinit the pagelist. If you own the memory pool (used clonePool) then /// this will reset the pool and retain capacity. pub fn deinit(self: *PageList) void { + // Verify integrity before cleanup + self.assertIntegrity(); + // Always deallocate our hashmap. self.tracked_pins.deinit(self.pool.alloc); @@ -339,6 +448,8 @@ pub fn deinit(self: *PageList) void { /// This can't fail because we always retain at least enough allocated /// memory to fit the active area. pub fn reset(self: *PageList) void { + defer self.assertIntegrity(); + // We need enough pages/nodes to keep our active area. This should // never fail since we by definition have allocated a page already // that fits our size but I'm not confident to make that assertion. @@ -413,6 +524,9 @@ pub fn reset(self: *PageList) void { self.rows, ) catch @panic("initPages failed"); + // Our total rows always goes back to the default + self.total_rows = self.rows; + // Update all our tracked pins to point to our first page top-left { var it = self.tracked_pins.iterator(); @@ -570,9 +684,11 @@ pub fn clone( .min_max_size = self.min_max_size, .cols = self.cols, .rows = self.rows, + .total_rows = total_rows, .tracked_pins = tracked_pins, .viewport = .{ .active = {} }, .viewport_pin = viewport_pin, + .viewport_pin_row_offset = null, }; // We always need to have enough rows for our viewport because this is @@ -589,8 +705,12 @@ pub fn clone( const row = &last.data.rows.ptr(last.data.memory)[last.data.size.rows - 1]; last.data.clearCells(row, 0, result.cols); } + + // Update our total rows to be our row size. + result.total_rows = result.rows; } + result.assertIntegrity(); return result; } @@ -617,6 +737,8 @@ pub const Resize = struct { /// Resize /// TODO: docs pub fn resize(self: *PageList, opts: Resize) !void { + defer self.assertIntegrity(); + if (comptime std.debug.runtime_safety) { // Resize does not work with 0 values, this should be protected // upstream @@ -624,6 +746,12 @@ pub fn resize(self: *PageList, opts: Resize) !void { if (opts.rows) |v| assert(v > 0); } + // Resizing (especially with reflow) can cause our row offset to + // become invalid. Rather than do something fancy like we do other + // places and try to update it in place, we just invalidate it because + // its too easy to get the logic wrong in here. + self.viewport_pin_row_offset = null; + if (!opts.reflow) return try self.resizeWithoutReflow(opts); // Recalculate our minimum max size. This allows grow to work properly @@ -658,7 +786,6 @@ pub fn resize(self: *PageList, opts: Resize) !void { copy.cols = self.cols; break :opts copy; }); - try self.resizeCols(cols, opts.cursor); }, } @@ -728,16 +855,21 @@ fn resizeCols( self.pages.first = dst_node; self.pages.last = dst_node; - var dst_cursor = ReflowCursor.init(dst_node); - // Reflow all our rows. - while (it.next()) |row| { - try dst_cursor.reflowRow(self, row); + { + var dst_cursor = ReflowCursor.init(dst_node); + while (it.next()) |row| { + try dst_cursor.reflowRow(self, row); - // Once we're done reflowing a page, destroy it. - if (row.y == row.node.data.size.rows - 1) { - self.destroyNode(row.node); + // Once we're done reflowing a page, destroy it. + if (row.y == row.node.data.size.rows - 1) { + self.destroyNode(row.node); + } } + + // At the end of the reflow, setup our total row cache + // log.warn("total old={} new={}", .{ self.total_rows, dst_cursor.total_rows }); + self.total_rows = dst_cursor.total_rows; } // If our total rows is less than our active rows, we need to grow. @@ -804,6 +936,9 @@ const ReflowCursor = struct { page_cell: *pagepkg.Cell, new_rows: usize, + /// This is the final row count of the reflowed pages. + total_rows: usize, + fn init(node: *List.Node) ReflowCursor { const page = &node.data; const rows = page.rows.ptr(page.memory); @@ -816,6 +951,9 @@ const ReflowCursor = struct { .page_row = &rows[0], .page_cell = &rows[0].cells.ptr(page.memory)[0], .new_rows = 0, + + // Initially whatever size our input node is. + .total_rows = node.data.size.rows, }; } @@ -1229,12 +1367,21 @@ const ReflowCursor = struct { ) !void { const old_x = self.x; const old_y = self.y; + const old_total_rows = self.total_rows; + + self.* = .init(node: { + // Pause integrity checks because the total row count won't + // be correct during a reflow. + list.pauseIntegrityChecks(true); + defer list.pauseIntegrityChecks(false); + break :node try list.adjustCapacity( + self.node, + adjustment, + ); + }); - self.* = .init(try list.adjustCapacity( - self.node, - adjustment, - )); self.cursorAbsolute(old_x, old_y); + self.total_rows = old_total_rows; } /// True if this cursor is at the bottom of the page by capacity, @@ -1253,11 +1400,6 @@ const ReflowCursor = struct { } } - fn cursorDown(self: *ReflowCursor) void { - assert(self.y + 1 < self.page.size.rows); - self.cursorAbsolute(self.x, self.y + 1); - } - /// Create a new row and move the cursor down. /// /// Asserts that the cursor is on the bottom row of the @@ -1309,6 +1451,12 @@ const ReflowCursor = struct { list: *PageList, cap: Capacity, ) !void { + // The functions below may overwrite self so we need to cache + // our total rows. We add one because no matter what when this + // returns we'll have one more row added. + const new_total_rows: usize = self.total_rows + 1; + defer self.total_rows = new_total_rows; + if (self.bottom()) { try self.cursorNewPage(list, cap); } else { @@ -1374,6 +1522,11 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // destroy pages if we're increasing cols which will free up page_size // so that when we call grow() in the row mods, we won't prune. if (opts.cols) |cols| { + // Any column change without reflow should not result in row counts + // changing. + const old_total_rows = self.total_rows; + defer assert(self.total_rows == old_total_rows); + switch (std.math.order(cols, self.cols)) { .eq => {}, @@ -1442,7 +1595,10 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { // behavior because it seemed fine in an ocean of differing behavior // between terminal apps. I'm completely open to changing it as long // as resize behavior isn't regressed in a user-hostile way. - _ = self.trimTrailingBlankRows(self.rows - rows); + const trimmed = self.trimTrailingBlankRows(self.rows - rows); + + // Account for our trimmed rows in the total row cache + self.total_rows -= trimmed; // If we didn't trim enough, just modify our row count and this // will create additional history. @@ -1502,6 +1658,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { } if (build_options.slow_runtime_safety) { + // We never have less rows than our active screen has. assert(self.totalRows() >= self.rows); } } @@ -1673,6 +1830,10 @@ fn trailingBlankLines( /// Trims up to max trailing blank rows from the pagelist and returns the /// number of rows trimmed. A blank row is any row with no text (but may /// have styling). +/// +/// IMPORTANT: This function does NOT update `total_rows`. It returns the +/// number of rows trimmed, and the caller is responsible for decrementing +/// `total_rows` by this amount. fn trimTrailingBlankRows( self: *PageList, max: size.CellCountInt, @@ -1744,21 +1905,81 @@ pub const Scroll = union(enum) { /// previously allocated pages. pub fn scroll(self: *PageList, behavior: Scroll) void { switch (behavior) { - .active => self.viewport = .{ .active = {} }, - .top => self.viewport = .{ .top = {} }, + .active => self.viewport = .active, + .top => self.viewport = .top, .pin => |p| { if (self.pinIsActive(p)) { - self.viewport = .{ .active = {} }; + self.viewport = .active; + return; + } else if (self.pinIsTop(p)) { + self.viewport = .top; return; } self.viewport_pin.* = p; - self.viewport = .{ .pin = {} }; + self.viewport = .pin; + self.viewport_pin_row_offset = null; // invalidate cache }, .delta_prompt => |n| self.scrollPrompt(n), - .delta_row => |n| { - if (n == 0) return; + .delta_row => |n| delta_row: { + switch (self.viewport) { + // If we're at the top and we're scrolling backwards, + // we don't have to do anything, because there's nowhere to go. + .top => if (n <= 0) break :delta_row, + // If we're at active and we're scrolling forwards, we don't + // have to do anything because it'll result in staying in + // the active. + .active => if (n >= 0) break :delta_row, + + // If we're already a pin type, then we can fast-path our + // delta by simply moving the pin. This has the added benefit + // that we can update our row offset cache efficiently, too. + .pin => switch (std.math.order(n, 0)) { + .eq => break :delta_row, + + .lt => switch (self.viewport_pin.upOverflow(@intCast(-n))) { + .offset => |new_pin| { + self.viewport_pin.* = new_pin; + if (self.viewport_pin_row_offset) |*v| { + v.* -= @as(usize, @intCast(-n)); + } + break :delta_row; + }, + + // If we overflow up we're at the top. + .overflow => { + self.viewport = .top; + break :delta_row; + }, + }, + + .gt => switch (self.viewport_pin.downOverflow(@intCast(n))) { + // If we offset its a valid pin but we still have to + // check if we're in the active area. + .offset => |new_pin| { + if (self.pinIsActive(new_pin)) { + self.viewport = .active; + } else { + self.viewport_pin.* = new_pin; + if (self.viewport_pin_row_offset) |*v| { + v.* += @intCast(n); + } + } + break :delta_row; + }, + + // If we overflow down we're at active. + .overflow => { + self.viewport = .active; + break :delta_row; + }, + }, + }, + } + + // Slow path: we have to calculate the new pin by moving + // from our viewport. const top = self.getTopLeft(.viewport); const p: Pin = if (n < 0) switch (top.upOverflow(@intCast(-n))) { .offset => |v| v, @@ -1776,13 +1997,22 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { // active area, you usually expect that the viewport will now // follow the active area. if (self.pinIsActive(p)) { - self.viewport = .{ .active = {} }; + self.viewport = .active; + return; + } + + // If we're at the top, then just set the top. This is a lot + // more efficient everywhere. We must check this after the + // active check above because we prefer active if they overlap. + if (self.pinIsTop(p)) { + self.viewport = .top; return; } // Pin is not active so we need to track it. self.viewport_pin.* = p; - self.viewport = .{ .pin = {} }; + self.viewport = .pin; + self.viewport_pin_row_offset = null; // invalidate cache }, } } @@ -1818,10 +2048,11 @@ fn scrollPrompt(self: *PageList, delta: isize) void { // into the active area. Otherwise, we scroll up to the pin. if (prompt_pin) |p| { if (self.pinIsActive(p)) { - self.viewport = .{ .active = {} }; + self.viewport = .active; } else { self.viewport_pin.* = p; - self.viewport = .{ .pin = {} }; + self.viewport = .pin; + self.viewport_pin_row_offset = null; // invalidate cache } } } @@ -1829,6 +2060,8 @@ fn scrollPrompt(self: *PageList, delta: isize) void { /// Clear the screen by scrolling written contents up into the scrollback. /// This will not update the viewport. pub fn scrollClear(self: *PageList) !void { + defer self.assertIntegrity(); + // Go through the active area backwards to find the first non-empty // row. We use this to determine how many rows to scroll up. const non_empty: usize = non_empty: { @@ -1856,6 +2089,131 @@ pub fn scrollClear(self: *PageList) !void { for (0..non_empty) |_| _ = try self.grow(); } +/// This represents the state necessary to render a scrollbar for this +/// PageList. It has the total size, the offset, and the size of the viewport. +pub const Scrollbar = struct { + /// Total size of the scrollable area. + total: usize, + + /// The offset into the total area that the viewport is at. This is + /// guaranteed to be less than or equal to total. This includes the + /// visible row. + offset: usize, + + /// The length of the visible area. This is including the offset row. + len: usize, + + /// A zero-sized scrollable region. + pub const zero: Scrollbar = .{ + .total = 0, + .offset = 0, + .len = 0, + }; + + /// Comparison for scrollbars. + pub fn eql(self: Scrollbar, other: Scrollbar) bool { + return self.total == other.total and + self.offset == other.offset and + self.len == other.len; + } +}; + +/// Return the scrollbar state for this PageList. +/// +/// This may be expensive to calculate depending on where the viewport +/// is (arbitrary pins are expensive). The caller should take care to only +/// call this as needed and not too frequently. +pub fn scrollbar(self: *PageList) Scrollbar { + return .{ + .total = self.total_rows, + .offset = self.viewportRowOffset(), + .len = self.rows, // Length is always rows + }; +} + +/// Returns the offset of the current viewport from the top of the +/// screen. +/// +/// This is potentially expensive to calculate because if the viewport +/// is a pin and the pin is near the beginning of the scrollback, we +/// will traverse a lot of linked list nodes. +/// +/// The result is cached so repeated calls are cheap. +fn viewportRowOffset(self: *PageList) usize { + return switch (self.viewport) { + .top => 0, + .active => self.total_rows - self.rows, + .pin => pin: { + // We assert integrity on this code path because it verifies + // that the cached value is correct. + defer self.assertIntegrity(); + + // Return cached value if available + if (self.viewport_pin_row_offset) |cached| break :pin cached; + + // Traverse from the end and count rows until we reach the + // viewport pin. We count backwards because most of the time + // a user is scrolling near the active area. + const top_offset: usize = offset: { + var offset: usize = 0; + var node = self.pages.last; + while (node) |n| : (node = n.prev) { + offset += n.data.size.rows; + if (n == self.viewport_pin.node) { + assert(n.data.size.rows > self.viewport_pin.y); + offset -= self.viewport_pin.y; + break :offset self.total_rows - offset; + } + } + + // Invalid pins are not possible. + unreachable; + }; + + // The offset is from the bottom and our cached value and this + // function returns from the top, so we need to invert it. + self.viewport_pin_row_offset = top_offset; + break :pin top_offset; + }, + }; +} + +/// This fixes up the viewport data when rows are removed from the +/// PageList. This will update a viewport to `active` if row removal +/// puts the viewport into the active area, to `top` if the viewport +/// is now at row 0, and updates any row offset caches as necessary. +/// +/// This is unit tested transitively through other tests such as +/// eraseRows. +fn fixupViewport( + self: *PageList, + removed: usize, +) void { + switch (self.viewport) { + .active => {}, + + // For pin, we check if our pin is now in the active area and if so + // we move our viewport back to the active area. + .pin => if (self.pinIsActive(self.viewport_pin.*)) { + self.viewport = .active; + } else if (self.viewport_pin_row_offset) |*v| { + // If we have a cached row offset, we need to update it + // to account for the erased rows. + if (v.* < removed) { + self.viewport = .top; + } else { + v.* -= removed; + } + }, + + // For top, we move back to active if our erasing moved our + // top page into the active area. + .top => if (self.pinIsActive(.{ .node = self.pages.first.? })) { + self.viewport = .active; + }, + } +} + /// Returns the actual max size. This may be greater than the explicit /// value if the explicit value is less than the min_max_size. /// @@ -1887,11 +2245,17 @@ inline fn growRequiredForActive(self: *const PageList) bool { /// /// This returns the newly allocated page node if there is one. pub fn grow(self: *PageList) !?*List.Node { + defer self.assertIntegrity(); + const last = self.pages.last.?; if (last.data.capacity.rows > last.data.size.rows) { // Fast path: we have capacity in the last page. last.data.size.rows += 1; last.data.assertIntegrity(); + + // Increase our total rows by one + self.total_rows += 1; + return null; } @@ -1921,6 +2285,27 @@ pub fn grow(self: *PageList) !?*List.Node { const buf = first.data.memory; @memset(buf, 0); + // Decrease our total row count from the pruned page and then + // add one for our new row. + self.total_rows -= first.data.size.rows; + self.total_rows += 1; + + // If we have a pin viewport cache then we need to update it. + if (self.viewport == .pin) viewport: { + if (self.viewport_pin_row_offset) |*v| { + // If our offset is less than the number of rows in the + // pruned page, then we are now at the top. + if (v.* < first.data.size.rows) { + self.viewport = .top; + break :viewport; + } + + // Otherwise, our viewport pin is below what we pruned + // so we just decrement our offset. + v.* -= first.data.size.rows; + } + } + // Initialize our new page and reinsert it as the last first.data = .initBuf(.init(buf), layout); first.data.size.rows = 1; @@ -1954,6 +2339,9 @@ pub fn grow(self: *PageList) !?*List.Node { // verified the case above. next_node.data.assertIntegrity(); + // Record the increased row count + self.total_rows += 1; + return next_node; } @@ -1997,6 +2385,7 @@ pub fn adjustCapacity( node: *List.Node, adjustment: AdjustCapacity, ) AdjustCapacityError!*List.Node { + defer self.assertIntegrity(); const page: *Page = &node.data; // We always start with the base capacity of the existing page. This @@ -2110,6 +2499,10 @@ inline fn createPageExt( /// Destroy the memory of the given node in the PageList linked list /// and return it to the pool. The node is assumed to already be removed /// from the linked list. +/// +/// IMPORTANT: This function does NOT update `total_rows`. The caller is +/// responsible for accounting for the removed rows. This function only +/// updates `page_size` (byte accounting), not row accounting. fn destroyNode(self: *PageList, node: *List.Node) void { destroyNodeExt(&self.pool, node, &self.page_size); } @@ -2147,6 +2540,7 @@ pub fn eraseRow( self: *PageList, pt: point.Point, ) !void { + defer self.assertIntegrity(); const pn = self.pin(pt).?; var node = pn.node; @@ -2166,6 +2560,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(); @@ -2236,6 +2633,8 @@ pub fn eraseRowBounded( pt: point.Point, limit: usize, ) !void { + defer self.assertIntegrity(); + // This function has a lot of repeated code in it because it is a hot path. // // To get a better idea of what's happening, read eraseRow first for more @@ -2258,6 +2657,21 @@ pub fn eraseRowBounded( var dirty = node.data.dirtyBitSet(); dirty.setRangeValue(.{ .start = pn.y, .end = pn.y + limit }, 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 + // of in the pin loop below because its unlikely to be true and we + // don't want to run the conditional N times. + if (self.viewport == .pin) viewport: { + if (self.viewport_pin_row_offset) |*v| { + const p = self.viewport_pin; + if (p.node != node or + p.y < pn.y or + p.y > pn.y + limit or + p.y == 0) break :viewport; + v.* -= 1; + } + } + // Update pins in the shifted region. const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { @@ -2291,6 +2705,18 @@ pub fn eraseRowBounded( // Update tracked pins. { + // See the other places we do something similar in this function + // for a detailed explanation. + if (self.viewport == .pin) viewport: { + if (self.viewport_pin_row_offset) |*v| { + const p = self.viewport_pin; + if (p.node != node or + p.y < pn.y or + p.y == 0) break :viewport; + v.* -= 1; + } + } + const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { if (p.node == node and p.y >= pn.y) { @@ -2329,6 +2755,17 @@ pub fn eraseRowBounded( var dirty = node.data.dirtyBitSet(); dirty.setRangeValue(.{ .start = 0, .end = shifted_limit }, true); + // See the other places we do something similar in this function + // for a detailed explanation. + if (self.viewport == .pin) viewport: { + if (self.viewport_pin_row_offset) |*v| { + const p = self.viewport_pin; + if (p.node != node or + p.y > shifted_limit) break :viewport; + v.* -= 1; + } + } + // Update pins in the shifted region. const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { @@ -2353,6 +2790,16 @@ pub fn eraseRowBounded( // Account for the rows shifted in this node. shifted += node.data.size.rows; + // See the other places we do something similar in this function + // for a detailed explanation. + if (self.viewport == .pin) viewport: { + if (self.viewport_pin_row_offset) |*v| { + const p = self.viewport_pin; + if (p.node != node) break :viewport; + v.* -= 1; + } + } + // Update tracked pins. const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { @@ -2381,6 +2828,8 @@ pub fn eraseRows( tl_pt: point.Point, bl_pt: ?point.Point, ) void { + defer self.assertIntegrity(); + // The count of rows that was erased. var erased: usize = 0; @@ -2459,6 +2908,9 @@ pub fn eraseRows( dirty.setRangeValue(.{ .start = 0, .end = chunk.node.data.size.rows }, true); } + // Update our total row count + self.total_rows -= erased; + // If we deleted active, we need to regrow because one of our invariants // is that we always have full active space. if (tl_pt == .active) { @@ -2473,26 +2925,16 @@ pub fn eraseRows( } // If we have a pinned viewport, we need to adjust for active area. - switch (self.viewport) { - .active => {}, - - // For pin, we check if our pin is now in the active area and if so - // we move our viewport back to the active area. - .pin => if (self.pinIsActive(self.viewport_pin.*)) { - self.viewport = .{ .active = {} }; - }, - - // For top, we move back to active if our erasing moved our - // top page into the active area. - .top => if (self.pinIsActive(.{ .node = self.pages.first.? })) { - self.viewport = .{ .active = {} }; - }, - } + self.fixupViewport(erased); } /// Erase a single page, freeing all its resources. The page can be /// anywhere in the linked list but must NOT be the final page in the /// entire list (i.e. must not make the list empty). +/// +/// IMPORTANT: This function does NOT update `total_rows`. The caller is +/// responsible for accounting for the removed rows before or after calling +/// this function. fn erasePage(self: *PageList, node: *List.Node) void { assert(node.next != null or node.prev != null); @@ -2601,6 +3043,11 @@ fn pinIsActive(self: *const PageList, p: Pin) bool { return false; } +/// Returns true if the pin is at the top of the scrollback area. +fn pinIsTop(self: *const PageList, p: Pin) bool { + return p.y == 0 and p.node == self.pages.first.?; +} + /// Convert a pin to a point in the given context. If the pin can't fit /// within the given tag (i.e. its in the history but you requested active), /// then this will return null. @@ -3341,23 +3788,9 @@ fn totalPages(self: *const PageList) usize { } /// Grow the number of rows available in the page list by n. -/// This is only used for testing so it isn't optimized. +/// This is only used for testing so it isn't optimized in any way. fn growRows(self: *PageList, n: usize) !void { - var page = self.pages.last.?; - var n_rem: usize = n; - if (page.data.size.rows < page.data.capacity.rows) { - const add = @min(n_rem, page.data.capacity.rows - page.data.size.rows); - page.data.size.rows += add; - if (n_rem == add) return; - n_rem -= add; - } - - while (n_rem > 0) { - page = (try self.grow()).?; - const add = @min(n_rem, page.data.capacity.rows); - page.data.size.rows = add; - n_rem -= add; - } + for (0..n) |_| _ = try self.grow(); } /// Clear all dirty bits on all pages. This is not efficient since it @@ -3896,6 +4329,9 @@ test "PageList" { try testing.expect(s.pages.first != null); try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + // Initial total rows should be our row count + try testing.expectEqual(s.rows, s.total_rows); + // Our viewport pin must be defined. It isn't used until the // viewport is a pin but it prevents undefined access on clone. try testing.expect(s.viewport_pin.node == s.pages.first.?); @@ -3906,6 +4342,13 @@ test "PageList" { .y = 0, .x = 0, }, s.getTopLeft(.active)); + + // Scrollbar should be where we expect it + try testing.expectEqual(Scrollbar{ + .total = s.rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); } test "PageList init rows across two pages" { @@ -3929,6 +4372,16 @@ test "PageList init rows across two pages" { try testing.expect(s.viewport == .active); try testing.expect(s.pages.first != null); try testing.expectEqual(@as(usize, s.rows), s.totalRows()); + + // Initial total rows should be our row count + try testing.expectEqual(s.rows, s.total_rows); + + // Scrollbar should be where we expect it + try testing.expectEqual(Scrollbar{ + .total = s.rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); } test "PageList pointFromPin active no history" { @@ -4116,6 +4569,13 @@ test "PageList active after grow" { .y = 10, } }, pt); } + + // Scrollbar should be in the active area + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 10, + .len = s.rows, + }, s.scrollbar()); } test "PageList grow allows exceeding max size for active area" { @@ -4141,6 +4601,9 @@ test "PageList grow allows exceeding max size for active area" { page.data.size.rows = 1; page.data.capacity.rows = 1; } + + // Avoid integrity check failures + s.total_rows = s.totalRows(); } // Grow our row and ensure we don't prune pages because we need @@ -4192,6 +4655,13 @@ test "PageList grow prune required with a single page" { const new = try s.grow(); try testing.expect(new != null); try testing.expect(new != s.pages.first); + + // Scrollbar should be in the active area + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll top" { @@ -4220,6 +4690,12 @@ test "PageList scroll top" { } }, pt); } + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + try s.growRows(10); { const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); @@ -4229,6 +4705,12 @@ test "PageList scroll top" { } }, pt); } + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + s.scroll(.{ .active = {} }); { const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); @@ -4237,6 +4719,12 @@ test "PageList scroll top" { .y = 20, } }, pt); } + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll delta row back" { @@ -4257,6 +4745,12 @@ test "PageList scroll delta row back" { s.scroll(.{ .delta_row = -1 }); + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows - 1, + .len = s.rows, + }, s.scrollbar()); + { const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); try testing.expectEqual(point.Point{ .screen = .{ @@ -4273,6 +4767,20 @@ test "PageList scroll delta row back" { .y = 9, } }, pt); } + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows - 11, + .len = s.rows, + }, s.scrollbar()); + + s.scroll(.{ .delta_row = -1 }); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows - 12, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll delta row back overflow" { @@ -4301,6 +4809,12 @@ test "PageList scroll delta row back overflow" { } }, pt); } + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + try s.growRows(10); { const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); @@ -4309,6 +4823,12 @@ test "PageList scroll delta row back overflow" { .y = 0, } }, pt); } + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 0, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll delta row forward" { @@ -4330,6 +4850,12 @@ test "PageList scroll delta row forward" { s.scroll(.{ .top = {} }); s.scroll(.{ .delta_row = 2 }); + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 2, + .len = s.rows, + }, s.scrollbar()); + { const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); try testing.expectEqual(point.Point{ .screen = .{ @@ -4346,6 +4872,12 @@ test "PageList scroll delta row forward" { .y = 2, } }, pt); } + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 2, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll delta row forward into active" { @@ -4364,6 +4896,12 @@ test "PageList scroll delta row forward into active" { .y = 0, } }, pt); } + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } test "PageList scroll delta row back without space preserves active" { @@ -4383,6 +4921,117 @@ test "PageList scroll delta row back without space preserves active" { } try testing.expect(s.viewport == .active); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to pin" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + s.scroll(.{ .pin = s.pin(.{ .screen = .{ + .y = 4, + .x = 2, + } }).? }); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 4, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 4, + } }, pt); + } + + s.scroll(.{ .pin = s.pin(.{ .screen = .{ + .y = 5, + .x = 2, + } }).? }); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 5, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } +} + +test "PageList scroll to pin in active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + s.scroll(.{ .pin = s.pin(.{ .screen = .{ + .y = 30, + .x = 2, + } }).? }); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } +} + +test "PageList scroll to pin at top" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + s.scroll(.{ .pin = s.pin(.{ .screen = .{ + .y = 0, + .x = 2, + } }).? }); + + try testing.expect(s.viewport == .top); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } } test "PageList scroll clear" { @@ -4418,7 +5067,7 @@ test "PageList scroll clear" { } } -test "PageList: jump zero" { +test "PageList: jump zero prompts" { const testing = std.testing; const alloc = testing.allocator; @@ -4438,9 +5087,15 @@ test "PageList: jump zero" { s.scroll(.{ .delta_prompt = 0 }); try testing.expect(s.viewport == .active); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } -test "Screen: jump to prompt" { +test "Screen: jump back one prompt" { const testing = std.testing; const alloc = testing.allocator; @@ -4466,6 +5121,12 @@ test "Screen: jump to prompt" { .x = 0, .y = 1, } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 1, + .len = s.rows, + }, s.scrollbar()); } { s.scroll(.{ .delta_prompt = -1 }); @@ -4474,16 +5135,32 @@ test "Screen: jump to prompt" { .x = 0, .y = 1, } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = 1, + .len = s.rows, + }, s.scrollbar()); } // Jump forward { s.scroll(.{ .delta_prompt = 1 }); try testing.expect(s.viewport == .active); + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } { s.scroll(.{ .delta_prompt = 1 }); try testing.expect(s.viewport == .active); + try testing.expectEqual(Scrollbar{ + .total = s.totalRows(), + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); } } @@ -4567,6 +5244,15 @@ test "PageList grow prune scrollback" { defer s.untrackPin(p); try testing.expect(p.node == s.pages.first.?); + // Scroll back to create a pinned viewport (not active) + const pin_y = page1.capacity.rows / 2; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + + // Get the scrollbar state to populate the cache + const scrollbar_before = s.scrollbar(); + try testing.expectEqual(pin_y, scrollbar_before.offset); + // Next should create a new page, but it should reuse our first // page since we're at max size. const new = (try s.grow()).?; @@ -4581,6 +5267,330 @@ test "PageList grow prune scrollback" { try testing.expect(p.node == s.pages.first.?); try testing.expect(p.x == 0); try testing.expect(p.y == 0); + + // Verify the viewport offset cache was invalidated. After pruning, + // the offset should have changed because we removed rows from + // the beginning. + { + const scrollbar_after = s.scrollbar(); + const rows_pruned = page1.capacity.rows; + const expected_offset = if (pin_y >= rows_pruned) + pin_y - rows_pruned + else + 0; + try testing.expectEqual(expected_offset, scrollbar_after.offset); + } +} + +test "PageList grow prune scrollback with viewport pin not in pruned page" { + const testing = std.testing; + const alloc = testing.allocator; + + // Zero here forces minimum max size to effectively two pages. + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Grow to capacity of first page + const page1_node = s.pages.last.?; + const page1 = page1_node.data; + for (0..page1.capacity.rows - page1.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + + // Grow and allocate second page, then fill it up + const page2_node = (try s.grow()).?; + const page2 = page2_node.data; + for (0..page2.capacity.rows - page2.size.rows) |_| { + try testing.expect(try s.grow() == null); + } + + // Get our page size + const old_page_size = s.page_size; + + // Scroll back to create a pinned viewport in page2 (NOT page1) + // This is the key difference from the previous test - the viewport + // pin is NOT in the page that will be pruned. + const pin_y = page1.capacity.rows + 5; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expect(s.viewport_pin.node == page2_node); + + // Get the scrollbar state to populate the cache + const scrollbar_before = s.scrollbar(); + try testing.expectEqual(pin_y, scrollbar_before.offset); + + // Next grow will trigger pruning of the first page. + // The viewport_pin.node is page2, not page1, so it won't be moved + // by the pin update loop, but the cached offset still needs to be + // invalidated because rows were removed from the beginning. + const new = (try s.grow()).?; + try testing.expect(s.pages.last.? == new); + try testing.expectEqual(s.page_size, old_page_size); + + // Our first should now be page2 (page1 was pruned) + try testing.expectEqual(page2_node, s.pages.first.?); + + // The viewport pin should still be on page2, unchanged + try testing.expect(s.viewport_pin.node == page2_node); + + // Verify the viewport offset cache was invalidated/updated. + // After pruning, the offset should have decreased by the number + // of rows that were pruned. + const scrollbar_after = s.scrollbar(); + const rows_pruned = page1.capacity.rows; + const expected_offset = pin_y - rows_pruned; + try testing.expectEqual(expected_offset, scrollbar_after.offset); +} + +test "PageList eraseRows invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 3) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Scroll back to create a pinned viewport somewhere in the middle + // of the scrollback + const pin_y = page.capacity.rows; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase some history rows BEFORE the viewport pin. + // This removes rows from before our pin, which changes its absolute + // offset from the top, but the cache is not invalidated. + const rows_to_erase = page.capacity.rows / 2; + s.eraseRows( + .{ .history = .{} }, + .{ .history = .{ .y = rows_to_erase - 1 } }, + ); + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - rows_to_erase, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList eraseRow invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 3) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Scroll back to create a pinned viewport somewhere in the middle + // of the scrollback + const pin_y = page.capacity.rows; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase a single row from the history BEFORE the viewport pin. + // This removes one row from before our pin, which changes its absolute + // offset from the top by 1, but the cache is not invalidated. + try s.eraseRow(.{ .history = .{ .y = 0 } }); + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - 1, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList eraseRowBounded invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 3) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Scroll back to create a pinned viewport somewhere in the middle + // of the scrollback + const pin_y: u16 = 4; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase a row from the history BEFORE the viewport pin with a bounded + // shift. This removes one row from before our pin, which changes its + // absolute offset from the top by 1, but the cache is not invalidated. + try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, 10); + + // Verify the scrollbar reflects the change (offset decreased by 1) + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - 1, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList eraseRowBounded multi-page invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 3) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Scroll back to create a pinned viewport somewhere in the middle + // of the scrollback, after the first page + const pin_y = page.capacity.rows + 1; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase a row from the beginning of history with a limit that spans + // across multiple pages. This ensures we hit the code path where + // eraseRowBounded finds the limit boundary in a subsequent page. + const limit = page.capacity.rows + 10; + try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, limit); + + // Verify the scrollbar reflects the change (offset decreased by 1) + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - 1, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList eraseRowBounded full page shift invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 4) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Scroll back to create a pinned viewport somewhere well beyond + // the first two pages + const pin_y = 5; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase a row from the beginning of history with a limit that is + // larger than multiple full pages. This ensures we hit the code path + // where eraseRowBounded continues looping through entire pages, + // rotating all rows in each page until it reaches the limit or + // runs out of pages. + const limit = page.capacity.rows * 2 + 10; + try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, limit); + + // Verify the scrollbar reflects the change (offset decreased by 1) + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - 1, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList eraseRowBounded exhausts pages invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Grow so we take up several pages worth of history + const page = &s.pages.last.?.data; + { + var cur_page = s.pages.last.?; + for (0..page.capacity.rows * 3) |_| { + if (try s.grow()) |new_page| cur_page = new_page; + } + } + + // Our total rows should include history + const total_rows_before = s.totalRows(); + try testing.expect(total_rows_before > s.rows); + + // Scroll back to create a pinned viewport somewhere in the history, + // well after the erase will complete + const pin_y = page.capacity.rows * 2 + 10; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Erase a row from the beginning of history with a limit that is + // LARGER than all remaining pages combined. This ensures we exhaust + // all pages in the while loop and reach the cleanup code after the loop. + const limit = total_rows_before * 2; + try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, limit); + + // Verify the scrollbar reflects the change (offset decreased by 1) + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y - 1, + .len = s.rows, + }, s.scrollbar()); } test "PageList adjustCapacity to increase styles" { @@ -5156,8 +6166,7 @@ test "PageList erase resets viewport to active if moves within active" { // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); - try testing.expect(s.viewport == .pin); - try testing.expect(s.viewport_pin.node == s.pages.first.?); + try testing.expect(s.viewport == .top); // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); @@ -5186,13 +6195,11 @@ test "PageList erase resets viewport if inside erased page but not active" { // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); - try testing.expect(s.viewport == .pin); - try testing.expect(s.viewport_pin.node == s.pages.first.?); + try testing.expect(s.viewport == .top); // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, .{ .history = .{ .y = 2 } }); - try testing.expect(s.viewport == .pin); - try testing.expect(s.viewport_pin.node == s.pages.first.?); + try testing.expect(s.viewport == .top); } test "PageList erase resets viewport to active if top is inside active" { @@ -6096,14 +7103,16 @@ test "PageList resize (no reflow) more rows contains viewport" { // Set viewport above active by scrolling up one. s.scroll(.{ .delta_row = -1 }); // The viewport should be a pin now. - try testing.expectEqual(Viewport.pin, s.viewport); + try testing.expectEqual(Viewport.top, s.viewport); // Resize try s.resize(.{ .rows = 7, .reflow = false }); try testing.expectEqual(@as(usize, 7), s.rows); try testing.expectEqual(@as(usize, 7), s.totalRows()); - // The viewport should now be active, not a pin. - try testing.expectEqual(Viewport.active, s.viewport); + + // Question: maybe the viewport should actually be in the active + // here and not pinned to the top. + try testing.expectEqual(Viewport.top, s.viewport); } test "PageList resize (no reflow) less cols" { @@ -6657,6 +7666,55 @@ test "PageList resize reflow more cols wrapped rows" { } } +test "PageList resize reflow invalidates viewport offset cache" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, null); + defer s.deinit(); + try s.growRows(20); + + const page = &s.pages.last.?.data; + for (0..s.rows) |y| { + if (y % 2 == 0) { + const rac = page.getRowAndCell(0, y); + rac.row.wrap = true; + } else { + const rac = page.getRowAndCell(0, y); + rac.row.wrap_continuation = true; + } + + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + } + + // Scroll to a pinned viewport in history + const pin_y = 10; + s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = pin_y, + .len = s.rows, + }, s.scrollbar()); + + // Resize with reflow - unwrapping rows changes total_rows + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + + // Verify scrollbar cache was invalidated during reflow + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 8, + .len = s.rows, + }, s.scrollbar()); +} + test "PageList resize reflow more cols creates multiple pages" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 4498a5def..59b5d0d53 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -37,6 +37,7 @@ pub const Pin = PageList.Pin; pub const Point = point.Point; pub const Screen = @import("Screen.zig"); pub const ScreenType = Terminal.ScreenType; +pub const Scrollbar = PageList.Scrollbar; pub const Selection = @import("Selection.zig"); pub const SizeReportStyle = csi.SizeReportStyle; pub const StringMap = @import("StringMap.zig"); From 123d23682a4a2fe105c7148fb81ef824bc32af9e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:43:22 -0700 Subject: [PATCH 125/702] build(deps): bump namespacelabs/nscloud-setup-buildx-action from 0.0.19 to 0.0.20 (#9227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [namespacelabs/nscloud-setup-buildx-action](https://github.com/namespacelabs/nscloud-setup-buildx-action) from 0.0.19 to 0.0.20.
Release notes

Sourced from namespacelabs/nscloud-setup-buildx-action's releases.

v0.0.20

What's Changed

New Contributors

Full Changelog: https://github.com/namespacelabs/nscloud-setup-buildx-action/compare/v0...v0.0.20

Commits
  • 91c2e65 Merge pull request #11 from namespacelabs/add-wait-for-builder-input-to-eager...
  • 459dd43 Add wait-for-builder input to eagerly start build clusters and wait for them
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=namespacelabs/nscloud-setup-buildx-action&package-manager=github_actions&previous-version=0.0.19&new-version=0.0.20)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6757db2ee..e92f30083 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1057,7 +1057,7 @@ jobs: uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10 - name: Configure Namespace powered Buildx - uses: namespacelabs/nscloud-setup-buildx-action@7020d7d8e659afecbfec162ab4693c7e56278311 # v0.0.19 + uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20 - name: Download Source Tarball Artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 From 3ffbd87a0fe5c58cc673663bcc7b38d2bc12ef30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:43:35 -0700 Subject: [PATCH 126/702] build(deps): bump cachix/install-nix-action from 31.8.0 to 31.8.1 (#9226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.8.0 to 31.8.1.
Release notes

Sourced from cachix/install-nix-action's releases.

v31.8.1

What's Changed

Full Changelog: https://github.com/cachix/install-nix-action/compare/v31...v31.8.1

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cachix/install-nix-action&package-manager=github_actions&previous-version=31.8.0&new-version=31.8.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 b28dd4299..52190f020 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@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 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 5718a85bb..50a8ec7bb 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@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index b4ef9e1f5..ee2fe47b3 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -34,7 +34,7 @@ jobs: with: # Important so that build number generation works fetch-depth: 0 - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -168,7 +168,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 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 e92f30083..4f3196c3b 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@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -113,7 +113,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -146,7 +146,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -180,7 +180,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -222,7 +222,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -258,7 +258,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -287,7 +287,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -320,7 +320,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -366,7 +366,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -585,7 +585,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -627,7 +627,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -675,7 +675,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -710,7 +710,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -774,7 +774,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -801,7 +801,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -829,7 +829,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -856,7 +856,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -883,7 +883,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -910,7 +910,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -937,7 +937,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -971,7 +971,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -998,7 +998,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1035,7 +1035,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1123,7 +1123,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + - uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 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 3d6c2ed1f..b218cdb26 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@7ab6e7fd29da88e74b1e314a4ae9ac6b5cda3801 # v31.8.0 + uses: cachix/install-nix-action@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 From 5a9bd0e49ef11499eb0ccb63725cc882b93356e0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 15 Oct 2025 20:04:36 -0700 Subject: [PATCH 127/702] snap: update to Zig 0.15.2 --- snap/snapcraft.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 271deeeb2..7bdbc9b48 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -52,7 +52,7 @@ parts: rm -rf $CRAFT_PART_SRC/* if [[ -n $arch ]]; then - curl -LO --retry-connrefused --retry 10 https://ziglang.org/download/0.15.1/zig-$arch-linux-0.15.1.tar.xz + curl -LO --retry-connrefused --retry 10 https://ziglang.org/download/0.15.2/zig-$arch-linux-0.15.2.tar.xz else echo "Unsupported arch" exit 1 From da7736cd443f60649b54e87adc9c20c0ec89c13e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 15 Oct 2025 21:03:25 -0700 Subject: [PATCH 128/702] renderer: emit scrollbar apprt event when scrollbar changes --- include/ghostty.h | 9 +++++++++ src/Surface.zig | 13 +++++++++++++ src/apprt/action.zig | 4 ++++ src/apprt/surface.zig | 3 +++ src/renderer/generic.zig | 9 +++++++++ src/terminal/PageList.zig | 15 +++++++++++++++ 6 files changed, 53 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 48836ee96..acb6988d6 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -741,6 +741,13 @@ typedef struct { uint64_t duration; } ghostty_action_command_finished_s; +// terminal.Scrollbar +typedef struct { + uint64_t total; + uint64_t offset; + uint64_t len; +} ghostty_action_scrollbar_s; + // apprt.Action.Key typedef enum { GHOSTTY_ACTION_QUIT, @@ -767,6 +774,7 @@ typedef enum { GHOSTTY_ACTION_RESET_WINDOW_SIZE, GHOSTTY_ACTION_INITIAL_SIZE, GHOSTTY_ACTION_CELL_SIZE, + GHOSTTY_ACTION_SCROLLBAR, GHOSTTY_ACTION_RENDER, GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, @@ -809,6 +817,7 @@ typedef union { ghostty_action_size_limit_s size_limit; ghostty_action_initial_size_s initial_size; ghostty_action_cell_size_s cell_size; + ghostty_action_scrollbar_s scrollbar; ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; diff --git a/src/Surface.zig b/src/Surface.zig index 456acad2c..a9052896f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -983,6 +983,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .renderer_health => |health| self.updateRendererHealth(health), + .scrollbar => |scrollbar| self.updateScrollbar(scrollbar), + .report_color_scheme => |force| self.reportColorScheme(force), .present_surface => try self.presentSurface(), @@ -1459,6 +1461,17 @@ fn updateRendererHealth(self: *Surface, health: rendererpkg.Health) void { }; } +/// Called when the scrollbar state changes. +fn updateScrollbar(self: *Surface, scrollbar: terminal.Scrollbar) void { + _ = self.rt_app.performAction( + .{ .surface = self }, + .scrollbar, + scrollbar, + ) catch |err| { + log.warn("failed to notify app of scrollbar change err={}", .{err}); + }; +} + /// This should be called anytime `config_conditional_state` changes /// so that the apprt can reload the configuration. fn notifyConfigConditionalState(self: *Surface) void { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 14a8165f2..e593d4bce 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -164,6 +164,9 @@ pub const Action = union(Key) { /// The cell size has changed to the given dimensions in pixels. cell_size: CellSize, + /// The scrollbar is updating. + scrollbar: terminal.Scrollbar, + /// The target should be re-rendered. This usually has a specific /// surface target but if the app is targeted then all active /// surfaces should be redrawn. @@ -324,6 +327,7 @@ pub const Action = union(Key) { reset_window_size, initial_size, cell_size, + scrollbar, render, inspector, show_gtk_inspector, diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index a46732c16..b71bf1e6e 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -104,6 +104,9 @@ pub const Message = union(enum) { /// of the command. stop_command: ?u8, + /// The scrollbar state changed for the surface. + scrollbar: terminal.Scrollbar, + pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 876e3f945..6031bede4 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1314,6 +1314,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.draw_mutex.lock(); defer self.draw_mutex.unlock(); + // After the graphics API is complete (so we defer) we want to + // update our scrollbar state. + defer if (self.scrollbar_dirty) { + self.scrollbar_dirty = false; + _ = self.surface_mailbox.push(.{ + .scrollbar = self.scrollbar, + }, .{ .forever = {} }); + }; + // Let our graphics API do any bookkeeping, etc. // that it needs to do before / after `drawFrame`. self.api.drawFrameStart(); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b71c87faa..058314166 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2110,6 +2110,21 @@ pub const Scrollbar = struct { .len = 0, }; + // Sync with: ghostty_action_scrollbar_s + pub const C = extern struct { + total: u64, + offset: u64, + len: u64, + }; + + pub fn cval(self: Scrollbar) C { + return .{ + .total = @intCast(self.total), + .offset = @intCast(self.offset), + .len = @intCast(self.len), + }; + } + /// Comparison for scrollbars. pub fn eql(self: Scrollbar, other: Scrollbar) bool { return self.total == other.total and From 135136f733bec586cb08cbf3073f83e98e679868 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 16 Oct 2025 08:38:00 -0700 Subject: [PATCH 129/702] terminal: PageList scroll to absolute row function --- src/terminal/PageList.zig | 543 +++++++++++++++++++++++++++++++++++++- src/terminal/Screen.zig | 2 + 2 files changed, 531 insertions(+), 14 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 058314166..3aba29128 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1886,6 +1886,11 @@ pub const Scroll = union(enum) { /// the scrollback history. top, + /// Scroll to the given absolute row from the top. A value of zero + /// is the top row. This row will be the first visible row in the viewport. + /// Scrolling into or below the active area will clamp to the active area. + row: usize, + /// Scroll up (negative) or down (positive) by the given number of /// rows. This is clamped to the "top" and "active" top left. delta_row: isize, @@ -1904,6 +1909,8 @@ pub const Scroll = union(enum) { /// pages, etc. This can only be used to move the viewport within the /// previously allocated pages. pub fn scroll(self: *PageList, behavior: Scroll) void { + defer self.assertIntegrity(); + switch (behavior) { .active => self.viewport = .active, .top => self.viewport = .top, @@ -1920,6 +1927,93 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { self.viewport = .pin; self.viewport_pin_row_offset = null; // invalidate cache }, + .row => |n| row: { + // If we're at the top, pin the top. + if (n == 0) { + self.viewport = .top; + break :row; + } + + // If we're below the top of the active area, pin the active area. + if (n >= self.total_rows - self.rows) { + self.viewport = .active; + break :row; + } + + // See if there are any other faster paths we can take. + switch (self.viewport) { + .top, .active => {}, + .pin => if (self.viewport_pin_row_offset) |*v| { + // If we have a pin and we already calculated a row offset, + // then we can efficiently calculate the delta and move + // that much from that pin. + const delta: isize = delta: { + const n_isize: isize = @intCast(n); + const v_isize: isize = @intCast(v.*); + break :delta n_isize - v_isize; + }; + self.scroll(.{ .delta_row = delta }); + return; + }, + } + + // We have an accurate row offset so store it to prevent + // calculating this again. + self.viewport_pin_row_offset = n; + self.viewport = .pin; + + // Slow path, we've just got to traverse the linked list and + // get to our row. As a slight speedup, let's pick the traversal + // that's likely faster based on our absolute row and total rows. + const midpoint = self.total_rows / 2; + if (n < midpoint) { + // Iterate forward from the first node. + var node_it = self.pages.first; + var rem: size.CellCountInt = std.math.cast( + size.CellCountInt, + n, + ) orelse { + self.viewport = .active; + break :row; + }; + while (node_it) |node| : (node_it = node.next) { + if (rem < node.data.size.rows) { + self.viewport_pin.* = .{ + .node = node, + .y = rem, + }; + break :row; + } + + rem -= node.data.size.rows; + } + } else { + // Iterate backwards from the last node. + var node_it = self.pages.last; + var rem: size.CellCountInt = std.math.cast( + size.CellCountInt, + self.total_rows - n, + ) orelse { + self.viewport = .active; + break :row; + }; + while (node_it) |node| : (node_it = node.prev) { + if (rem <= node.data.size.rows) { + self.viewport_pin.* = .{ + .node = node, + .y = node.data.size.rows - rem, + }; + break :row; + } + + rem -= node.data.size.rows; + } + } + + // If we reached here, then we couldn't find the offset. + // This feels impossible? Just clamp to active, screw it lol. + self.viewport = .active; + }, .delta_prompt => |n| self.scrollPrompt(n), .delta_row => |n| delta_row: { switch (self.viewport) { @@ -5049,6 +5143,427 @@ test "PageList scroll to pin at top" { } } +test "PageList scroll to row 0" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + s.scroll(.{ .row = 0 }); + try testing.expect(s.viewport == .top); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 0, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row in scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(20); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } + + s.scroll(.{ .row = 5 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 5, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 5, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row in middle" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(50); + + const total = s.total_rows; + const midpoint = total / 2; + s.scroll(.{ .row = midpoint }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = midpoint, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = @as(size.CellCountInt, @intCast(midpoint)), + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = @as(size.CellCountInt, @intCast(midpoint)), + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = midpoint, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row at active boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(20); + + const active_start = s.total_rows - s.rows; + + s.scroll(.{ .row = active_start }); + + try testing.expect(s.viewport == .active); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = @as(size.CellCountInt, @intCast(active_start)), + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); + + try s.growRows(10); + + try testing.expect(s.viewport == .active); + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row beyond active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(10); + + s.scroll(.{ .row = 1000 }); + + try testing.expect(s.viewport == .active); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row without scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + s.scroll(.{ .row = 5 }); + + try testing.expect(s.viewport == .active); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = s.total_rows - s.rows, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row then delta" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(30); + + s.scroll(.{ .row = 10 }); + + try testing.expect(s.viewport == .pin); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 10, + .len = s.rows, + }, s.scrollbar()); + + s.scroll(.{ .delta_row = 5 }); + + try testing.expect(s.viewport == .pin); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 15, + .len = s.rows, + }, s.scrollbar()); + + s.scroll(.{ .delta_row = -3 }); + + try testing.expect(s.viewport == .pin); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 12, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 12, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row with cache fast path down" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(50); + + s.scroll(.{ .row = 10 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 10, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, pt); + } + + // Verify cache is populated + try testing.expect(s.viewport_pin_row_offset != null); + try testing.expectEqual(@as(usize, 10), s.viewport_pin_row_offset.?); + + // Now scroll to a different row - this should use the fast path + s.scroll(.{ .row = 20 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 20, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 20, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 20, + .len = s.rows, + }, s.scrollbar()); +} + +test "PageList scroll to row with cache fast path up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growRows(50); + + s.scroll(.{ .row = 30 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 30, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 30, + } }, pt); + } + + // Verify cache is populated + try testing.expect(s.viewport_pin_row_offset != null); + try testing.expectEqual(@as(usize, 30), s.viewport_pin_row_offset.?); + + // Now scroll up to a different row - this should use the fast path + s.scroll(.{ .row = 15 }); + + try testing.expect(s.viewport == .pin); + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 15, + .len = s.rows, + }, s.scrollbar()); + + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, pt); + } + + try s.growRows(10); + { + const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, pt); + } + + try testing.expectEqual(Scrollbar{ + .total = s.total_rows, + .offset = 15, + .len = s.rows, + }, s.scrollbar()); +} + test "PageList scroll clear" { const testing = std.testing; const alloc = testing.allocator; @@ -5104,7 +5619,7 @@ test "PageList: jump zero prompts" { try testing.expect(s.viewport == .active); try testing.expectEqual(Scrollbar{ - .total = s.totalRows(), + .total = s.total_rows, .offset = s.total_rows - s.rows, .len = s.rows, }, s.scrollbar()); @@ -5138,7 +5653,7 @@ test "Screen: jump back one prompt" { } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); try testing.expectEqual(Scrollbar{ - .total = s.totalRows(), + .total = s.total_rows, .offset = 1, .len = s.rows, }, s.scrollbar()); @@ -5152,7 +5667,7 @@ test "Screen: jump back one prompt" { } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); try testing.expectEqual(Scrollbar{ - .total = s.totalRows(), + .total = s.total_rows, .offset = 1, .len = s.rows, }, s.scrollbar()); @@ -5163,7 +5678,7 @@ test "Screen: jump back one prompt" { s.scroll(.{ .delta_prompt = 1 }); try testing.expect(s.viewport == .active); try testing.expectEqual(Scrollbar{ - .total = s.totalRows(), + .total = s.total_rows, .offset = s.total_rows - s.rows, .len = s.rows, }, s.scrollbar()); @@ -5172,7 +5687,7 @@ test "Screen: jump back one prompt" { s.scroll(.{ .delta_prompt = 1 }); try testing.expect(s.viewport == .active); try testing.expectEqual(Scrollbar{ - .total = s.totalRows(), + .total = s.total_rows, .offset = s.total_rows - s.rows, .len = s.rows, }, s.scrollbar()); @@ -6042,11 +6557,11 @@ test "PageList erase" { try testing.expectEqual(@as(usize, 6), s.totalPages()); // Our total rows should be large - try testing.expect(s.totalRows() > s.rows); + try testing.expect(s.total_rows > s.rows); // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // We should be back to just one page try testing.expectEqual(@as(usize, 1), s.totalPages()); @@ -6101,7 +6616,7 @@ test "PageList erase row with tracked pin resets to top-left" { cur_page.data.pauseIntegrityChecks(false); // Our total rows should be large - try testing.expect(s.totalRows() > s.rows); + try testing.expect(s.total_rows > s.rows); // Put a tracked pin in the history const p = try s.trackPin(s.pin(.{ .history = .{} }).?); @@ -6109,7 +6624,7 @@ test "PageList erase row with tracked pin resets to top-left" { // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .history = .{} }, null); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // Our pin should move to the first page try testing.expectEqual(s.pages.first.?, p.node); @@ -6130,7 +6645,7 @@ test "PageList erase row with tracked pin shifts" { // Erase only a few rows in our active s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // Our pin should move to the first page try testing.expectEqual(s.pages.first.?, p.node); @@ -6151,7 +6666,7 @@ test "PageList erase row with tracked pin is erased" { // Erase the entire history, we should be back to just our active set. s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 3 } }); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // Our pin should move to the first page try testing.expectEqual(s.pages.first.?, p.node); @@ -6180,7 +6695,7 @@ test "PageList erase resets viewport to active if moves within active" { cur_page.data.pauseIntegrityChecks(false); // Move our viewport to the top - s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); + s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); try testing.expect(s.viewport == .top); // Erase the entire history, we should be back to just our active set. @@ -6209,7 +6724,7 @@ test "PageList erase resets viewport if inside erased page but not active" { cur_page.data.pauseIntegrityChecks(false); // Move our viewport to the top - s.scroll(.{ .delta_row = -@as(isize, @intCast(s.totalRows())) }); + s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); try testing.expect(s.viewport == .top); // Erase the entire history, we should be back to just our active set. @@ -6275,7 +6790,7 @@ test "PageList erase a one-row active" { } s.eraseRows(.{ .active = .{} }, .{ .active = .{} }); - try testing.expectEqual(s.rows, s.totalRows()); + try testing.expectEqual(s.rows, s.total_rows); // The row should be empty { diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 228b87922..81d6d4ab6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1155,6 +1155,7 @@ pub const Scroll = union(enum) { active, top, pin: Pin, + row: usize, delta_row: isize, delta_prompt: isize, }; @@ -1174,6 +1175,7 @@ pub inline fn scroll(self: *Screen, behavior: Scroll) void { .active => self.pages.scroll(.{ .active = {} }), .top => self.pages.scroll(.{ .top = {} }), .pin => |p| self.pages.scroll(.{ .pin = p }), + .row => |v| self.pages.scroll(.{ .row = v }), .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), .delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }), } From c86266cd906d3ac9972012699925e9ee395203aa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 16 Oct 2025 08:22:52 -0700 Subject: [PATCH 130/702] input: scroll_to_row action --- src/Surface.zig | 25 ++++++++++++++++++++----- src/input/Binding.zig | 5 +++++ src/input/command.zig | 1 + 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index a9052896f..e75c4b409 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4827,12 +4827,27 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .unlocked); }, + .scroll_to_row => |n| { + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + t.screen.scroll(.{ .row = n }); + } + + try self.queueRender(); + }, + .scroll_to_selection => { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const sel = self.io.terminal.screen.selection orelse return false; - const tl = sel.topLeft(&self.io.terminal.screen); - self.io.terminal.screen.scroll(.{ .pin = tl }); + { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const sel = self.io.terminal.screen.selection orelse return false; + const tl = sel.topLeft(&self.io.terminal.screen); + self.io.terminal.screen.scroll(.{ .pin = tl }); + } + + try self.queueRender(); }, .scroll_page_up => { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index c44fb0b09..ad07dce55 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -347,6 +347,10 @@ pub const Action = union(enum) { /// Scroll to the selected text. scroll_to_selection, + /// Scroll to the given absolute row in the screen with 0 being + /// the first row. + scroll_to_row: usize, + /// Scroll the screen up by one page. scroll_page_up, @@ -1077,6 +1081,7 @@ pub const Action = union(enum) { .scroll_to_top, .scroll_to_bottom, .scroll_to_selection, + .scroll_to_row, .scroll_page_up, .scroll_page_down, .scroll_page_fractional, diff --git a/src/input/command.zig b/src/input/command.zig index ba55820fc..29c10527e 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -487,6 +487,7 @@ fn actionCommands(action: Action.Key) []const Command { .esc, .cursor_key, .set_font_size, + .scroll_to_row, .scroll_page_fractional, .scroll_page_lines, .adjust_selection, From 2937aff513f81f97ca2635735d6346a21329df37 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 16 Oct 2025 09:18:55 -0700 Subject: [PATCH 131/702] gtk: mark scrollbar as unimplemented --- src/apprt/gtk/class/application.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index d75a0ef7f..d0125d1eb 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -728,6 +728,7 @@ pub const Application = extern struct { .command_finished => return Action.commandFinished(target, value), // Unimplemented + .scrollbar, .secure_input, .close_all_windows, .float_window, From 7207ff08d586d96b1d95872fe397986cd84fe977 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 15 Oct 2025 20:45:30 -0700 Subject: [PATCH 132/702] macos: SurfaceScrollView --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + macos/Sources/Ghostty/Ghostty.Action.swift | 12 + macos/Sources/Ghostty/Ghostty.App.swift | 30 +++ macos/Sources/Ghostty/Package.swift | 4 + macos/Sources/Ghostty/SurfaceScrollView.swift | 229 ++++++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 22 +- 6 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 macos/Sources/Ghostty/SurfaceScrollView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 388122f62..ae0051c53 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -141,6 +141,7 @@ Ghostty/Ghostty.Surface.swift, Ghostty/InspectorView.swift, "Ghostty/NSEvent+Extension.swift", + Ghostty/SurfaceScrollView.swift, Ghostty/SurfaceView_AppKit.swift, Helpers/AppInfo.swift, Helpers/CodableBridge.swift, diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 37b1a362d..4921ef8df 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -100,6 +100,18 @@ extension Ghostty.Action { let state: State let progress: UInt8? } + + struct Scrollbar { + let total: UInt64 + let offset: UInt64 + let len: UInt64 + + init(c: ghostty_action_scrollbar_s) { + total = c.total + offset = c.offset + len = c.len + } + } } // 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 bf34b4a91..91829f95c 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -571,6 +571,9 @@ extension Ghostty { case GHOSTTY_ACTION_REDO: return redo(app, target: target) + case GHOSTTY_ACTION_SCROLLBAR: + scrollbar(app, target: target, v: action.action.scrollbar) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -1560,6 +1563,33 @@ extension Ghostty { } } + private static func scrollbar( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_scrollbar_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("scrollbar 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 scrollbar = Ghostty.Action.Scrollbar(c: v) + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.ScrollbarKey: scrollbar + ] + ) + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 85040d390..e8a3d0976 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -344,6 +344,10 @@ extension Notification.Name { /// Toggle maximize of current window static let ghosttyMaximizeDidToggle = Notification.Name("com.mitchellh.ghostty.maximizeDidToggle") + + /// Notification sent when scrollbar updates + static let ghosttyDidUpdateScrollbar = Notification.Name("com.mitchellh.ghostty.didUpdateScrollbar") + static let ScrollbarKey = ghosttyDidUpdateScrollbar.rawValue + ".scrollbar" } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift new file mode 100644 index 000000000..642d728d9 --- /dev/null +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -0,0 +1,229 @@ +import SwiftUI + +/// Wraps a Ghostty surface view in an NSScrollView to provide native macOS scrollbar support. +/// +/// ## Coordinate System +/// AppKit uses a +Y-up coordinate system (origin at bottom-left), while terminals conceptually +/// use +Y-down (row 0 at top). This class handles the inversion when converting between row +/// offsets and pixel positions. +/// +/// ## Architecture +/// - `scrollView`: The outermost NSScrollView that manages scrollbar rendering and behavior +/// - `documentView`: A blank NSView whose height represents total scrollback (in pixels) +/// - `surfaceView`: The actual Ghostty renderer, positioned to fill the visible rect +class SurfaceScrollView: NSView { + private let scrollView: NSScrollView + private let documentView: NSView + private let surfaceView: Ghostty.SurfaceView + private var observers: [NSObjectProtocol] = [] + private var isLiveScrolling = false + + /// The last row position sent via scroll_to_row action. Used to avoid + /// sending redundant actions when the user drags the scrollbar but stays + /// on the same row. + private var lastSentRow: Int? + + init(contentSize: CGSize, surfaceView: Ghostty.SurfaceView) { + self.surfaceView = surfaceView + // The scroll view is our outermost view that controls all our scrollbar + // rendering and behavior. + scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.usesPredominantAxisScrolling = true + + // The document view is what the scrollview is actually going + // to be directly scrolling. We set it up to a "blank" NSView + // with the desired content size. + documentView = NSView(frame: NSRect(origin: .zero, size: contentSize)) + scrollView.documentView = documentView + + // The document view contains our actual surface as a child. + // We synchronize the scrolling of the document with this surface + // so that our primary Ghostty renderer only needs to render the viewport. + documentView.addSubview(surfaceView) + + super.init(frame: .zero) + + // Our scroll view is our only view + addSubview(scrollView) + + // We listen for scroll events through bounds notifications on our NSClipView. + // This is based on: https://christiantietze.de/posts/2018/07/synchronize-nsscrollview/ + scrollView.contentView.postsBoundsChangedNotifications = true + observers.append(NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] notification in + self?.handleScrollChange(notification) + }) + + // Listen for scrollbar updates from Ghostty + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidUpdateScrollbar, + object: surfaceView, + queue: .main + ) { [weak self] notification in + self?.handleScrollbarUpdate(notification) + }) + + // Listen for live scroll events + observers.append(NotificationCenter.default.addObserver( + forName: NSScrollView.willStartLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.isLiveScrolling = true + }) + + observers.append(NotificationCenter.default.addObserver( + forName: NSScrollView.didEndLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.isLiveScrolling = false + }) + + observers.append(NotificationCenter.default.addObserver( + forName: NSScrollView.didLiveScrollNotification, + object: scrollView, + queue: .main + ) { [weak self] _ in + self?.handleLiveScroll() + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + + // Force layout to be called to fix up our various subviews. + needsLayout = true + } + + override func layout() { + super.layout() + + // Fill entire bounds with scroll view + scrollView.frame = bounds + + // Use contentSize to account for visible scrollers + // + // Only update sizes if we have a valid (non-zero) content size. The content size + // can be zero when this is added early to a view, or to an invisible hierarchy. + // Practically, this happened in the quick terminal. + let contentSize = scrollView.contentSize + if contentSize.width > 0 && contentSize.height > 0 { + // Keep document width synchronized with content width + documentView.setFrameSize(CGSize( + width: contentSize.width, + height: documentView.frame.height + )) + + // Inform the actual pty of our size change + surfaceView.sizeDidChange(contentSize) + } + + // When our scrollview changes make sure our surface view is synchronized + synchronizeSurfaceView() + } + + // MARK: Scrolling + + private func synchronizeAppearance() { + let scrollbarConfig = surfaceView.derivedConfig.scrollbar + scrollView.hasVerticalScroller = scrollbarConfig != .never + } + + /// Positions the surface view to fill the currently visible rectangle. + /// + /// This is called whenever the scroll position changes. The surface view (which does the + /// actual terminal rendering) always fills exactly the visible portion of the document view, + /// so the renderer only needs to render what's currently on screen. + private func synchronizeSurfaceView() { + let visibleRect = scrollView.contentView.documentVisibleRect + surfaceView.frame = visibleRect + } + + // MARK: Notifications + + /// Handles bounds changes in the scroll view's clip view, keeping the surface view synchronized. + private func handleScrollChange(_ notification: Notification) { + synchronizeSurfaceView() + } + + /// Handles live scroll events (user actively dragging the scrollbar). + /// + /// Converts the current scroll position to a row number and sends a `scroll_to_row` action + /// to the terminal core. Only sends actions when the row changes to avoid IPC spam. + private func handleLiveScroll() { + // If our cell height is currently zero then we avoid a div by zero below + // and just don't scroll (there's no where to scroll anyways). This can + // happen with a tiny terminal. + let cellHeight = surfaceView.cellSize.height + guard cellHeight > 0 else { return } + + // AppKit views are +Y going up, so we calculate from the bottom + let visibleRect = scrollView.contentView.documentVisibleRect + let documentHeight = documentView.frame.height + let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height + let row = Int(scrollOffset / cellHeight) + + // Only send action if the row changed to avoid action spam + guard row != lastSentRow else { return } + lastSentRow = row + + // Use the keybinding action to scroll. + _ = surfaceView.surfaceModel?.perform(action: "scroll_to_row:\(row)") + } + + /// Handles scrollbar state updates from the terminal core. + /// + /// Updates the document view size to reflect total scrollback and adjusts scroll position + /// to match the terminal's viewport. During live scrolling, updates document size but skips + /// programmatic position changes to avoid fighting the user's drag. + /// + /// ## Scrollbar State + /// The scrollbar struct contains: + /// - `total`: Total rows in scrollback + active area + /// - `offset`: First visible row (0 = top of history) + /// - `len`: Number of visible rows (viewport height) + private func handleScrollbarUpdate(_ notification: Notification) { + guard let scrollbar = notification.userInfo?[SwiftUI.Notification.Name.ScrollbarKey] as? Ghostty.Action.Scrollbar else { + return + } + + // Convert row units to pixels using cell height, ignore zero height. + let cellHeight = surfaceView.cellSize.height + guard cellHeight > 0 else { return } + + // Our width should be the content width to account for visible scrollers. + // We don't do horizontal scrolling in terminals. + let totalHeight = CGFloat(scrollbar.total) * cellHeight + let newSize = CGSize(width: scrollView.contentSize.width, height: totalHeight) + documentView.setFrameSize(newSize) + + // Only update our actual scroll position if we're not actively scrolling. + if !isLiveScrolling { + // Invert coordinate system: terminal offset is from top, AppKit position from bottom + let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight + scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) + + // Track the current row position to avoid redundant movements when we + // move the scrollbar. + lastSentRow = Int(scrollbar.offset) + } + + // Always update our scrolled view with the latest dimensions + scrollView.reflectScrolledClipView(scrollView.contentView) + } +} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index aca17c0fc..c650bdf8f 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -386,10 +386,6 @@ 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. - /// - /// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible - /// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to - /// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with. struct SurfaceRepresentable: OSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView @@ -404,16 +400,26 @@ extension Ghostty { /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size. let size: CGSize + #if canImport(AppKit) + func makeOSView(context: Context) -> SurfaceScrollView { + // On macOS, wrap the surface view in a scroll view + return SurfaceScrollView(contentSize: size, surfaceView: view) + } + + func updateOSView(_ scrollView: SurfaceScrollView, context: Context) { + // Our scrollview always takes up the full size. + scrollView.frame.size = size + } + #else func makeOSView(context: Context) -> SurfaceView { - // We need the view as part of the state to be created previously because - // the view is sent to the Ghostty API so that it can manipulate it - // directly since we draw on a render thread. - return view; + // On iOS, return the surface view directly + return view } func updateOSView(_ view: SurfaceView, context: Context) { view.sizeDidChange(size) } + #endif } /// The configuration for a surface. For any configuration not set, defaults will be chosen from From 4b34b2389a38159de8adf3abdc249829a773e944 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 16 Oct 2025 09:49:03 -0700 Subject: [PATCH 133/702] config: add `scrollbar` config to control when scrollbars appear --- macos/Sources/Ghostty/Ghostty.Config.swift | 16 +++++++++++++ macos/Sources/Ghostty/SurfaceScrollView.swift | 16 ++++++++++++- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 +++ src/config/Config.zig | 24 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 0d75922cb..f380345c7 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -603,6 +603,17 @@ extension Ghostty { let str = String(cString: ptr) return MacShortcuts(rawValue: str) ?? defaultValue } + + var scrollbar: Scrollbar { + let defaultValue = Scrollbar.system + 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 let ptr = v else { return defaultValue } + let str = String(cString: ptr) + return Scrollbar(rawValue: str) ?? defaultValue + } } } @@ -641,6 +652,11 @@ extension Ghostty.Config { case ask } + enum Scrollbar: String { + case system + case never + } + enum ResizeOverlay : String { case always case never diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 642d728d9..44003e85f 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Combine /// Wraps a Ghostty surface view in an NSScrollView to provide native macOS scrollbar support. /// @@ -16,6 +17,7 @@ class SurfaceScrollView: NSView { private let documentView: NSView private let surfaceView: Ghostty.SurfaceView private var observers: [NSObjectProtocol] = [] + private var cancellables: Set = [] private var isLiveScrolling = false /// The last row position sent via scroll_to_row action. Used to avoid @@ -28,7 +30,7 @@ class SurfaceScrollView: NSView { // The scroll view is our outermost view that controls all our scrollbar // rendering and behavior. scrollView = NSScrollView() - scrollView.hasVerticalScroller = true + scrollView.hasVerticalScroller = false scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.usesPredominantAxisScrolling = true @@ -49,6 +51,9 @@ class SurfaceScrollView: NSView { // Our scroll view is our only view addSubview(scrollView) + // Apply initial scrollbar settings + synchronizeAppearance() + // We listen for scroll events through bounds notifications on our NSClipView. // This is based on: https://christiantietze.de/posts/2018/07/synchronize-nsscrollview/ scrollView.contentView.postsBoundsChangedNotifications = true @@ -93,6 +98,15 @@ class SurfaceScrollView: NSView { ) { [weak self] _ in self?.handleLiveScroll() }) + + // Listen for derived config changes to update scrollbar settings live + surfaceView.$derivedConfig + .sink { [weak self] _ in + DispatchQueue.main.async { [weak self] in + self?.synchronizeAppearance() + } + } + .store(in: &cancellables) } required init?(coder: NSCoder) { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2b3fd261c..410646f6f 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1532,6 +1532,7 @@ extension Ghostty { let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? + let scrollbar: Ghostty.Config.Scrollbar init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) @@ -1539,6 +1540,7 @@ extension Ghostty { self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil + self.scrollbar = .system } init(_ config: Ghostty.Config) { @@ -1547,6 +1549,7 @@ extension Ghostty { self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) + self.scrollbar = config.scrollbar } } diff --git a/src/config/Config.zig b/src/config/Config.zig index bb10ff439..b3085c4c4 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1197,6 +1197,24 @@ input: RepeatableReadableIO = .{}, /// This can be changed at runtime but will only affect new terminal surfaces. @"scrollback-limit": usize = 10_000_000, // 10MB +/// Control when the scrollbar is shown to scroll the scrollback buffer. +/// +/// The default value is `system`. +/// +/// Valid values: +/// +/// * `system` - Respect the system settings for when to show scrollbars. +/// For example, on macOS, this will respect the "Scrollbar behavior" +/// system setting which by default usually only shows scrollbars while +/// actively scrolling or hovering the gutter. +/// +/// * `never` - Never show a scrollbar. You can still scroll using the mouse, +/// keybind actions, etc. but you will not have a visual UI widget showing +/// a scrollbar. +/// +/// This only applies to macOS currently. GTK doesn't yet support scrollbars. +scrollbar: Scrollbar = .system, + /// Match a regular expression against the terminal text and associate clicking /// it with an action. This can be used to match URLs, file paths, etc. Actions /// can be opening using the system opener (e.g. `open` or `xdg-open`) or @@ -8379,6 +8397,12 @@ pub const WindowPadding = struct { } }; +/// See scrollbar +pub const Scrollbar = enum { + system, + never, +}; + /// See scroll-to-bottom pub const ScrollToBottom = packed struct { keystroke: bool = true, From cc91e2ad1679b730ed8f174c35fa4d09eac77632 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Thu, 16 Oct 2025 23:52:07 +0200 Subject: [PATCH 134/702] macOS: remove background from SurfaceScrollView --- macos/Sources/Ghostty/SurfaceScrollView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 44003e85f..7bec9249f 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -34,6 +34,8 @@ class SurfaceScrollView: NSView { scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.usesPredominantAxisScrolling = true + // hide default background to show blur effect properly + scrollView.drawsBackground = false // The document view is what the scrollview is actually going // to be directly scrolling. We set it up to a "blank" NSView From ffead466c7f40e7602963f7ed2c9d7176770276c Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Thu, 16 Oct 2025 22:32:57 -0700 Subject: [PATCH 135/702] Remove hidden titlebar safe area for SurfaceScrollView --- macos/Sources/Ghostty/SurfaceScrollView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 7bec9249f..af15a71fb 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -118,7 +118,12 @@ class SurfaceScrollView: NSView { deinit { observers.forEach { NotificationCenter.default.removeObserver($0) } } - + + // The entire bounds is a safe area, so we override any default + // insets. This is necessary for the content view to match the + // surface view if we have the "hidden" titlebar style. + override var safeAreaInsets: NSEdgeInsets { return NSEdgeInsetsZero } + override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) From 51b2374616e94b653242523d6399c259c409e881 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Fri, 17 Oct 2025 00:06:00 -0700 Subject: [PATCH 136/702] Add window padding to scrollView document height --- macos/Sources/Ghostty/SurfaceScrollView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index af15a71fb..714227cd1 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -226,11 +226,17 @@ class SurfaceScrollView: NSView { // Convert row units to pixels using cell height, ignore zero height. let cellHeight = surfaceView.cellSize.height guard cellHeight > 0 else { return } - + + // The full document height must include the vertical padding around the cell + // grid, otherwise the content view ends up misaligned with the surface. + let documentGridHeight = CGFloat(scrollbar.total) * cellHeight + let gridHeight = CGFloat(scrollbar.len) * cellHeight + let padding = scrollView.contentSize.height - gridHeight + let documentHeight = documentGridHeight + padding + // Our width should be the content width to account for visible scrollers. // We don't do horizontal scrolling in terminals. - let totalHeight = CGFloat(scrollbar.total) * cellHeight - let newSize = CGSize(width: scrollView.contentSize.width, height: totalHeight) + let newSize = CGSize(width: scrollView.contentSize.width, height: documentHeight) documentView.setFrameSize(newSize) // Only update our actual scroll position if we're not actively scrolling. From ed443bc6ed45fb26e50ff750290992e3e2c609d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 16 Oct 2025 15:49:30 -0700 Subject: [PATCH 137/702] gtk: Scrollbars --- src/apprt/gtk/build/gresource.zig | 1 + src/apprt/gtk/class/application.zig | 13 +- src/apprt/gtk/class/split_tree.zig | 5 +- src/apprt/gtk/class/surface.zig | 229 ++++++++++++++++++ .../gtk/class/surface_scrolled_window.zig | 209 ++++++++++++++++ src/apprt/gtk/ui/1.2/surface.blp | 1 + .../gtk/ui/1.5/surface-scrolled-window.blp | 11 + 7 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 src/apprt/gtk/class/surface_scrolled_window.zig create mode 100644 src/apprt/gtk/ui/1.5/surface-scrolled-window.blp diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index fabd5763e..cc701d7c2 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -46,6 +46,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "split-tree" }, .{ .major = 1, .minor = 5, .name = "split-tree-split" }, .{ .major = 1, .minor = 2, .name = "surface" }, + .{ .major = 1, .minor = 5, .name = "surface-scrolled-window" }, .{ .major = 1, .minor = 5, .name = "surface-title-dialog" }, .{ .major = 1, .minor = 3, .name = "surface-child-exited" }, .{ .major = 1, .minor = 5, .name = "tab" }, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index d0125d1eb..ceea6fee5 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -709,6 +709,8 @@ pub const Application = extern struct { .ring_bell => Action.ringBell(target), + .scrollbar => Action.scrollbar(target, value), + .set_title => Action.setTitle(target, value), .show_child_exited => return Action.showChildExited(target, value), @@ -728,7 +730,6 @@ pub const Application = extern struct { .command_finished => return Action.commandFinished(target, value), // Unimplemented - .scrollbar, .secure_input, .close_all_windows, .float_window, @@ -2328,6 +2329,16 @@ const Action = struct { } } + pub fn scrollbar( + target: apprt.Target, + value: apprt.Action.Value(.scrollbar), + ) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.surface.setScrollbar(value), + } + } + pub fn setTitle( target: apprt.Target, value: apprt.action.SetTitle, diff --git a/src/apprt/gtk/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig index a498ca5dc..1c901b1bb 100644 --- a/src/apprt/gtk/class/split_tree.zig +++ b/src/apprt/gtk/class/split_tree.zig @@ -22,6 +22,7 @@ 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; +const SurfaceScrolledWindow = @import("surface_scrolled_window.zig").SurfaceScrolledWindow; const log = std.log.scoped(.gtk_ghostty_split_tree); @@ -874,7 +875,9 @@ pub const SplitTree = extern struct { current: Surface.Tree.Node.Handle, ) *gtk.Widget { return switch (tree.nodes[current.idx()]) { - .leaf => |v| v.as(gtk.Widget), + .leaf => |v| gobject.ext.newInstance(SurfaceScrolledWindow, .{ + .surface = v, + }).as(gtk.Widget), .split => |s| SplitTreeSplit.new( current, &s, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 51e4ea7b2..646ad5dbd 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -40,12 +40,16 @@ pub const Surface = extern struct { const Self = @This(); parent_instance: Parent, pub const Parent = adw.Bin; + pub const Implements = [_]type{gtk.Scrollable}; pub const getGObjectType = gobject.ext.defineClass(Self, .{ .name = "GhosttySurface", .instanceInit = &init, .classInit = &Class.init, .parent_class = &Class.parent, .private = .{ .Type = Private, .offset = &Private.offset }, + .implements = &.{ + gobject.ext.implement(gtk.Scrollable, .{}), + }, }); /// A SplitTree implementation that stores surfaces. @@ -301,6 +305,62 @@ pub const Surface = extern struct { }, ); }; + + pub const hadjustment = struct { + pub const name = "hadjustment"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gtk.Adjustment, + .{ + .accessor = .{ + .getter = getHAdjustmentValue, + .setter = setHAdjustmentValue, + }, + }, + ); + }; + + pub const vadjustment = struct { + pub const name = "vadjustment"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*gtk.Adjustment, + .{ + .accessor = .{ + .getter = getVAdjustmentValue, + .setter = setVAdjustmentValue, + }, + }, + ); + }; + + pub const @"hscroll-policy" = struct { + pub const name = "hscroll-policy"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.ScrollablePolicy, + .{ + .default = .natural, + .accessor = C.privateShallowFieldAccessor("hscroll_policy"), + }, + ); + }; + + pub const @"vscroll-policy" = struct { + pub const name = "vscroll-policy"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.ScrollablePolicy, + .{ + .default = .natural, + .accessor = C.privateShallowFieldAccessor("vscroll_policy"), + }, + ); + }; }; pub const signals = struct { @@ -548,6 +608,13 @@ pub const Surface = extern struct { action_group: ?*gio.SimpleActionGroup = null, + // Gtk.Scrollable interface adjustments + hadj: ?*gtk.Adjustment = null, + vadj: ?*gtk.Adjustment = null, + hscroll_policy: gtk.ScrollablePolicy = .natural, + vscroll_policy: gtk.ScrollablePolicy = .natural, + vadj_signal_group: ?*gobject.SignalGroup = null, + // Template binds child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, @@ -714,6 +781,47 @@ pub const Surface = extern struct { return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0; } + /// Set the scrollbar state for this surface. This will setup the + /// properties for our Gtk.Scrollable interface properly. + pub fn setScrollbar(self: *Self, scrollbar: terminal.Scrollbar) void { + // Update existing adjustment in-place. If we don't have an + // adjustment then we do nothing because we're not part of a + // scrolled window. + const vadj = self.getVAdjustment() orelse return; + + // Check if values match existing adjustment and skip update if so + const value: f64 = @floatFromInt(scrollbar.offset); + const upper: f64 = @floatFromInt(scrollbar.total); + const page_size: f64 = @floatFromInt(scrollbar.len); + + if (std.math.approxEqAbs(f64, vadj.getValue(), value, 0.001) and + std.math.approxEqAbs(f64, vadj.getUpper(), upper, 0.001) and + std.math.approxEqAbs(f64, vadj.getPageSize(), page_size, 0.001)) + { + return; + } + + // If we have a vadjustment we MUST have the signal group since + // it is setup in the prop handler. + const priv = self.private(); + const group = priv.vadj_signal_group.?; + + // During manual scrollbar changes from Ghostty core we don't + // want to emit value-changed signals so we block them. This would + // cause a waste of resources at best and infinite loops at worst. + group.block(); + defer group.unblock(); + + vadj.configure( + value, // value: current scroll position + 0, // lower: minimum value + upper, // upper: maximum value (total scrollable area) + 1, // step_increment: amount to scroll on arrow click + page_size, // page_increment: amount to scroll on page up/down + page_size, // page_size: size of visible area + ); + } + /// Set the current progress report state. pub fn setProgressReport( self: *Self, @@ -1519,6 +1627,7 @@ pub const Surface = extern struct { priv.mouse_hidden = false; priv.focused = true; priv.size = .{ .width = 0, .height = 0 }; + priv.vadj_signal_group = null; // If our configuration is null then we get the configuration // from the application. @@ -1583,6 +1692,22 @@ pub const Surface = extern struct { priv.config = null; } + if (priv.vadj_signal_group) |group| { + group.setTarget(null); + group.as(gobject.Object).unref(); + priv.vadj_signal_group = null; + } + + if (priv.hadj) |v| { + v.as(gobject.Object).unref(); + priv.hadj = null; + } + + if (priv.vadj) |v| { + v.as(gobject.Object).unref(); + priv.vadj = null; + } + if (priv.progress_bar_timer) |timer| { if (glib.Source.remove(timer) == 0) { log.warn("unable to remove progress bar timer", .{}); @@ -1996,6 +2121,43 @@ pub const Surface = extern struct { self.as(gtk.Widget).setCursorFromName(name.ptr); } + fn vadjValueChanged(adj: *gtk.Adjustment, self: *Self) callconv(.c) void { + // This will trigger for every single pixel change in the adjustment, + // but our core surface handles the noise from this so that identical + // rows are cheap. + const core_surface = self.core() orelse return; + const row: usize = @intFromFloat(@round(adj.getValue())); + _ = core_surface.performBindingAction(.{ .scroll_to_row = row }) catch |err| { + log.err("error performing scroll_to_row action err={}", .{err}); + }; + } + + fn propVAdjustment( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + + // When vadjustment is first set, we setup the signal group lazily. + // This makes it so that if we don't use scrollbars, we never + // pay the memory cost of this. + const group: *gobject.SignalGroup = priv.vadj_signal_group orelse group: { + const group = gobject.SignalGroup.new(gtk.Adjustment.getGObjectType()); + group.connect( + "value-changed", + @ptrCast(&vadjValueChanged), + self, + ); + + priv.vadj_signal_group = group; + break :group group; + }; + + // Setup our signal group target + group.setTarget(if (priv.vadj) |v| v.as(gobject.Object) else null); + } + /// Handle bell features that need to happen every time a BEL is received /// Currently this is audio and system but this could change in the future. fn ringBell(self: *Self) void { @@ -2060,6 +2222,66 @@ pub const Surface = extern struct { } } + //--------------------------------------------------------------- + // Gtk.Scrollable interface implementation + + pub fn getHAdjustment(self: *Self) ?*gtk.Adjustment { + return self.private().hadj; + } + + pub fn setHAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void { + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.hadjustment.impl.param_spec); + + const priv = self.private(); + if (priv.hadj) |old| { + old.as(gobject.Object).unref(); + priv.hadj = null; + } + + const adj = adj_ orelse return; + _ = adj.as(gobject.Object).ref(); + priv.hadj = adj; + } + + fn getHAdjustmentValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set(value, self.getHAdjustment()); + } + + fn setHAdjustmentValue(self: *Self, value: *const gobject.Value) void { + self.setHAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment)); + } + + pub fn getVAdjustment(self: *Self) ?*gtk.Adjustment { + return self.private().vadj; + } + + pub fn setVAdjustment(self: *Self, adj_: ?*gtk.Adjustment) void { + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.vadjustment.impl.param_spec); + + const priv = self.private(); + + if (priv.vadj) |old| { + old.as(gobject.Object).unref(); + priv.vadj = null; + } + + const adj = adj_ orelse return; + _ = adj.as(gobject.Object).ref(); + priv.vadj = adj; + } + + fn getVAdjustmentValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set(value, self.getVAdjustment()); + } + + fn setVAdjustmentValue(self: *Self, value: *const gobject.Value) void { + self.setVAdjustment(gobject.ext.Value.get(value, ?*gtk.Adjustment)); + } + //--------------------------------------------------------------- // Signal Handlers @@ -3013,6 +3235,7 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); + class.bindTemplateCallback("notify_vadjustment", &propVAdjustment); class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); @@ -3034,6 +3257,12 @@ pub const Surface = extern struct { properties.@"title-override".impl, properties.zoom.impl, properties.@"is-split".impl, + + // For Gtk.Scrollable + properties.hadjustment.impl, + properties.vadjustment.impl, + properties.@"hscroll-policy".impl, + properties.@"vscroll-policy".impl, }); // Signals diff --git a/src/apprt/gtk/class/surface_scrolled_window.zig b/src/apprt/gtk/class/surface_scrolled_window.zig new file mode 100644 index 000000000..3095b4c78 --- /dev/null +++ b/src/apprt/gtk/class/surface_scrolled_window.zig @@ -0,0 +1,209 @@ +const std = @import("std"); +const assert = std.debug.assert; +const adw = @import("adw"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const gresource = @import("../build/gresource.zig"); +const Common = @import("../class.zig").Common; +const Surface = @import("surface.zig").Surface; +const Config = @import("config.zig").Config; + +const log = std.log.scoped(.gtk_ghostty_surface_scrolled_window); + +/// A wrapper widget that embeds a Surface inside a GtkScrolledWindow. +/// This provides scrollbar functionality for the terminal surface. +/// The surface property can be set during initialization or changed +/// dynamically via the surface property. +pub const SurfaceScrolledWindow = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhostttySurfaceScrolledWindow", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const config = struct { + pub const name = "config"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Config, + .{ + .accessor = C.privateObjFieldAccessor("config"), + }, + ); + }; + + pub const surface = struct { + pub const name = "surface"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*Surface, + .{ + .accessor = .{ + .getter = getSurfaceValue, + .setter = setSurfaceValue, + }, + }, + ); + }; + }; + + const Private = struct { + config: ?*Config = null, + config_binding: ?*gobject.Binding = null, + surface: ?*Surface = null, + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + } + + fn dispose(self: *Self) callconv(.c) void { + const priv = self.private(); + + if (priv.config_binding) |binding| { + binding.as(gobject.Object).unref(); + priv.config_binding = null; + } + + if (priv.config) |v| { + v.unref(); + priv.config = null; + } + + 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 { + gobject.Object.virtual_methods.finalize.call( + Class.parent, + self.as(Parent), + ); + } + + fn getSurfaceValue(self: *Self, value: *gobject.Value) void { + gobject.ext.Value.set( + value, + self.private().surface, + ); + } + + fn setSurfaceValue(self: *Self, value: *const gobject.Value) void { + self.setSurface(gobject.ext.Value.get( + value, + ?*Surface, + )); + } + + pub fn getSurface(self: *Self) ?*Surface { + return self.private().surface; + } + + pub fn setSurface(self: *Self, surface_: ?*Surface) void { + const priv = self.private(); + + if (surface_ == priv.surface) return; + + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.surface.impl.param_spec); + + priv.surface = surface_; + } + + fn closureScrollbarPolicy( + _: *Self, + config_: ?*Config, + ) callconv(.c) gtk.PolicyType { + const config = if (config_) |c| c.get() else return .automatic; + return switch (config.scrollbar) { + .never => .never, + .system => .automatic, + }; + } + + fn propSurface( + self: *Self, + _: *gobject.ParamSpec, + _: ?*anyopaque, + ) callconv(.c) void { + const priv = self.private(); + const child: *gtk.Widget = self.as(Parent).getChild().?; + const scrolled_window = gobject.ext.cast(gtk.ScrolledWindow, child).?; + scrolled_window.setChild(if (priv.surface) |s| s.as(gtk.Widget) else null); + + // Unbind old config binding if it exists + if (priv.config_binding) |binding| { + binding.as(gobject.Object).unref(); + priv.config_binding = null; + } + + // Bind config from surface to our config property + if (priv.surface) |surface| { + priv.config_binding = surface.as(gobject.Object).bindProperty( + properties.config.name, + self.as(gobject.Object), + properties.config.name, + .{ .sync_create = true }, + ); + } + } + + 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 = 5, + .name = "surface-scrolled-window", + }), + ); + + // Bindings + class.bindTemplateCallback("scrollbar_policy", &closureScrollbarPolicy); + class.bindTemplateCallback("notify_surface", &propSurface); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.config.impl, + properties.surface.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; + pub const bindTemplateCallback = C.Class.bindTemplateCallback; + }; +}; diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 84e00ac4a..0596bf15d 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -174,6 +174,7 @@ template $GhosttySurface: Adw.Bin { notify::mouse-hover-url => $notify_mouse_hover_url(); notify::mouse-hidden => $notify_mouse_hidden(); notify::mouse-shape => $notify_mouse_shape(); + notify::vadjustment => $notify_vadjustment(); // Some history: we used to use a Stack here and swap between the // terminal and error pages as needed. But a Stack doesn't play nice // with our SplitTree and Gtk.Paned usage[^1]. Replacing this with diff --git a/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp b/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp new file mode 100644 index 000000000..722c4427b --- /dev/null +++ b/src/apprt/gtk/ui/1.5/surface-scrolled-window.blp @@ -0,0 +1,11 @@ +using Gtk 4.0; +using Adw 1; + +template $GhostttySurfaceScrolledWindow: Adw.Bin { + notify::surface => $notify_surface(); + + Gtk.ScrolledWindow { + hscrollbar-policy: never; + vscrollbar-policy: bind $scrollbar_policy(template.config) as ; + } +} From 1cc22f93ca2f4828ab45ba2247798104b2bc4f92 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 17 Oct 2025 14:46:07 -0700 Subject: [PATCH 138/702] renderer: force a full rebuild on any font grid change Fixes #2731 (again) This regressed in 1.2 due to the renderer rework missing porting this. I believe this issue is still valid even with the rework since the font grid changes the atlas and if there are still cached cells that reference the old atlas coordinates it will produce garbage. --- src/renderer/generic.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 6031bede4..d18e78afb 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1068,6 +1068,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update relevant uniforms self.updateFontGridUniforms(); + + // 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; } /// Update uniforms that are based on the font grid. From ad9f9dc11ee28ca5de2834692d6a2357541b42ee Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Fri, 17 Oct 2025 14:57:10 -0700 Subject: [PATCH 139/702] font: Default to light hinting in FreeType --- src/config/Config.zig | 11 ++++++++++- src/font/face/freetype.zig | 6 +++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index b3085c4c4..d0e086710 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -476,6 +476,11 @@ pub const compatibility = std.StaticStringMap( /// /// * `autohint` - Enable the freetype auto-hinter. Enabled by default. /// +/// * `light` - Use a light hinting style, better preserving glyph shapes. +/// This is the most common setting in GTK apps and therefore also Ghostty's +/// default. This has no effect if `monochrome` is enabled. Enabled by +/// default. +/// /// Example: `hinting`, `no-hinting`, `force-autohint`, `no-force-autohint` @"freetype-load-flags": FreetypeLoadFlags = .{}, @@ -7886,11 +7891,15 @@ pub const BackgroundImageFit = enum { pub const FreetypeLoadFlags = packed struct { // The defaults here at the time of writing this match the defaults // for Freetype itself. Ghostty hasn't made any opinionated changes - // to these defaults. + // to these defaults. (Strictly speaking, `light` isn't FreeType's + // own default, but appears to be the effective default with most + // Fontconfig-aware software using FreeType, so until Ghostty + // implements Fontconfig support we default to `light`.) hinting: bool = true, @"force-autohint": bool = false, monochrome: bool = false, autohint: bool = true, + light: bool = true, }; /// See linux-cgroup diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 4958c48c8..95f05881b 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -378,6 +378,10 @@ pub const Face = struct { // 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, + // NO_SVG set to true because we don't currently support rendering // SVG glyphs under FreeType, since that requires bundling another // dependency to handle rendering the SVG. @@ -1143,7 +1147,7 @@ test { ft_font.glyphIndex('A').?, .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); - try testing.expectEqual(@as(u32, 20), g2.height); + try testing.expectEqual(@as(u32, 21), g2.height); } } From 5b7f1456407824b624afd2b53438db126fbee90b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 17 Oct 2025 19:48:59 -0700 Subject: [PATCH 140/702] macos: make terminal smaller to account for legacy scrollbar When the preferred scrollbar style is "legacy", the scrollbar takes up space that offsets the actual terminal. To prevent reflow, we detect this before the scrollbar becomes visible and shrink our terminal width to prepare for it. This doesn't account for the style changing at runtime, yet. --- macos/Sources/Ghostty/SurfaceScrollView.swift | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 714227cd1..b1e1b9baf 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -142,18 +142,35 @@ class SurfaceScrollView: NSView { // Only update sizes if we have a valid (non-zero) content size. The content size // can be zero when this is added early to a view, or to an invisible hierarchy. // Practically, this happened in the quick terminal. - let contentSize = scrollView.contentSize - if contentSize.width > 0 && contentSize.height > 0 { - // Keep document width synchronized with content width - documentView.setFrameSize(CGSize( - width: contentSize.width, - height: documentView.frame.height - )) - - // Inform the actual pty of our size change - surfaceView.sizeDidChange(contentSize) + var contentSize = scrollView.contentSize + guard contentSize.width > 0 && contentSize.height > 0 else { + synchronizeSurfaceView() + return } + // If we have a legacy scrollbar and its not visible, then we account for that + // in advance, because legacy scrollbars change our contentSize and force reflow + // of our terminal which is not desirable. + // See: https://github.com/ghostty-org/ghostty/discussions/9254 + let style = scrollView.verticalScroller?.scrollerStyle ?? NSScroller.preferredScrollerStyle + if style == .legacy { + if (scrollView.verticalScroller?.isHidden ?? true) { + let scrollerWidth = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + contentSize.width -= scrollerWidth + } + } + + // Keep document width synchronized with content width + documentView.setFrameSize(CGSize( + width: contentSize.width, + height: documentView.frame.height + )) + + // Inform the actual pty of our size change. This doesn't change the actual view + // frame because we do want to render the whole thing, but it will prevent our + // rows/cols from going into the non-content area. + surfaceView.sizeDidChange(contentSize) + // When our scrollview changes make sure our surface view is synchronized synchronizeSurfaceView() } From 3e6bda1fffe1e09037423dc45062f6ecb1064f7b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 17 Oct 2025 21:00:31 -0700 Subject: [PATCH 141/702] ci: run release-tip even if prior step failed --- .github/workflows/release-tip.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index ee2fe47b3..8f7bd2665 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -19,7 +19,6 @@ jobs: if: | github.event_name == 'workflow_dispatch' || ( - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' ) @@ -151,7 +150,6 @@ jobs: ( github.event_name == 'workflow_dispatch' || ( - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' ) @@ -206,7 +204,6 @@ jobs: ( github.event_name == 'workflow_dispatch' || ( - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' ) @@ -373,7 +370,6 @@ jobs: # Create our appcast for Sparkle - name: Generate Appcast if: | - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' env: @@ -408,7 +404,6 @@ jobs: # gets out of sync with the binaries. - name: Prep R2 Storage for Appcast if: | - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' run: | @@ -418,7 +413,6 @@ jobs: - name: Upload Appcast to R2 if: | - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 @@ -444,7 +438,6 @@ jobs: ( github.event_name == 'workflow_dispatch' || ( - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' ) @@ -629,7 +622,6 @@ jobs: ( github.event_name == 'workflow_dispatch' || ( - github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'ghostty-org' && github.ref_name == 'main' ) From ea505ec51db01eb15ac035ab20dea80c9d793582 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 17 Oct 2025 20:33:32 -0700 Subject: [PATCH 142/702] macos: use stable display UUID for quick terminal screen tracking NSScreen instances can be garbage collected at any time, even for screens that remain connected, making NSMapTable with weak keys unreliable for tracking per-screen state. This changes the quick terminal to use CGDisplay UUIDs as stable identifiers, keyed in a strong dictionary. Each entry stores the window frame along with screen dimensions, scale factor, and last-seen timestamp. Rules for pruning: - Entries are invalidated when screens shrink or change scale - Entries persist and update when screens grow (allowing cached state to work with larger resolutions) - Stale entries for disconnected screens expire after 14 days. - Maximum of 10 screen entries to prevent unbounded growth --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../QuickTerminalController.swift | 34 ++---- .../QuickTerminalScreenStateCache.swift | 113 ++++++++++++++++++ .../Extensions/NSScreen+Extension.swift | 7 ++ .../Helpers/Extensions/UUID+Extension.swift | 9 ++ 5 files changed, 137 insertions(+), 27 deletions(-) create mode 100644 macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift create mode 100644 macos/Sources/Helpers/Extensions/UUID+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index ae0051c53..86292fbe2 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -96,6 +96,7 @@ Features/QuickTerminal/QuickTerminalController.swift, Features/QuickTerminal/QuickTerminalPosition.swift, Features/QuickTerminal/QuickTerminalScreen.swift, + Features/QuickTerminal/QuickTerminalScreenStateCache.swift, Features/QuickTerminal/QuickTerminalSize.swift, Features/QuickTerminal/QuickTerminalSpaceBehavior.swift, Features/QuickTerminal/QuickTerminalWindow.swift, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 37c9985c9..4669e108a 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -21,13 +21,8 @@ class QuickTerminalController: BaseTerminalController { // The active space when the quick terminal was last shown. private var previousActiveSpace: CGSSpace? = nil - /// The saved state when the quick terminal's surface tree becomes empty. - /// - /// This preserves the user's window size and position when all terminal surfaces - /// are closed (e.g., via the `exit` command). When a new surface is created, - /// the window will be restored to this frame, preventing SwiftUI from resetting - /// the window to its default minimum size. - private var lastClosedFrames: NSMapTable + /// Cache for per-screen window state. + private let screenStateCache = QuickTerminalScreenStateCache() /// Non-nil if we have hidden dock state. private var hiddenDock: HiddenDock? = nil @@ -45,10 +40,6 @@ class QuickTerminalController: BaseTerminalController { ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) - - // This is a weak to strong mapping, so that our keys being NSScreens - // can remove themselves when they disappear. - self.lastClosedFrames = .weakToStrongObjects() // Important detail here: we initialize with an empty surface tree so // that we don't start a terminal process. This gets started when the @@ -379,17 +370,15 @@ class QuickTerminalController: BaseTerminalController { private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } - // Grab our last closed frame to use, and clear our state since we're animating in. - // We only use the last closed frame if we're opening on the same screen. - let lastClosedFrame: NSRect? = lastClosedFrames.object(forKey: screen)?.frame - lastClosedFrames.removeObject(forKey: screen) + // Grab our last closed frame to use from the cache. + let closedFrame = screenStateCache.frame(for: screen) // Move our window off screen to the initial animation position. position.setInitial( in: window, on: screen, terminalSize: derivedConfig.quickTerminalSize, - closedFrame: lastClosedFrame) + closedFrame: closedFrame) // We need to set our window level to a high value. In testing, only // popUpMenu and above do what we want. This gets it above the menu bar @@ -424,7 +413,7 @@ class QuickTerminalController: BaseTerminalController { in: window.animator(), on: screen, terminalSize: derivedConfig.quickTerminalSize, - closedFrame: lastClosedFrame) + closedFrame: closedFrame) }, completionHandler: { // There is a very minor delay here so waiting at least an event loop tick // keeps us safe from the view not being on the window. @@ -513,7 +502,7 @@ class QuickTerminalController: BaseTerminalController { // terminal is reactivated with a new surface. Without this, SwiftUI // would reset the window to its minimum content size. if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen { - lastClosedFrames.setObject(.init(frame: window.frame), forKey: screen) + screenStateCache.save(frame: window.frame, for: screen) } // If we hid the dock then we unhide it. @@ -598,7 +587,6 @@ class QuickTerminalController: BaseTerminalController { alert.alertStyle = .warning alert.beginSheetModal(for: window) } - // MARK: First Responder @IBAction override func closeWindow(_ sender: Any) { @@ -736,14 +724,6 @@ class QuickTerminalController: BaseTerminalController { hidden = false } } - - private class LastClosedState { - let frame: NSRect - - init(frame: NSRect) { - self.frame = frame - } - } } extension Notification.Name { diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift new file mode 100644 index 000000000..7dc53816c --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift @@ -0,0 +1,113 @@ +import Foundation +import Cocoa + +/// Manages cached window state per screen for the quick terminal. +/// +/// This cache tracks the last closed window frame for each screen, allowing the quick terminal +/// to restore to its previous size and position when reopened. It uses stable display UUIDs +/// to survive NSScreen garbage collection and automatically prunes stale entries. +class QuickTerminalScreenStateCache { + /// The maximum number of saved screen states we retain. This is to avoid some kind of + /// pathological memory growth in case we get our screen state serializing wrong. I don't + /// know anyone with more than 10 screens, so let's just arbitrarily go with that. + private static let maxSavedScreens = 10 + + /// Time-to-live for screen entries that are no longer present (14 days). + private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60 + + /// Keyed by display UUID to survive NSScreen garbage collection. + private var stateByDisplay: [UUID: DisplayEntry] = [:] + + init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(onScreensChanged(_:)), + name: NSApplication.didChangeScreenParametersNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /// Save the window frame for a screen. + func save(frame: NSRect, for screen: NSScreen) { + guard let key = screen.displayUUID else { return } + let entry = DisplayEntry( + frame: frame, + screenSize: screen.frame.size, + scale: screen.backingScaleFactor, + lastSeen: Date() + ) + stateByDisplay[key] = entry + pruneCapacity() + } + + /// Retrieve the last closed frame for a screen, if valid. + func frame(for screen: NSScreen) -> NSRect? { + guard let key = screen.displayUUID, var entry = stateByDisplay[key] else { return nil } + + // Drop on dimension/scale change that makes the entry invalid + if !entry.isValid(for: screen) { + stateByDisplay.removeValue(forKey: key) + return nil + } + + entry.lastSeen = Date() + stateByDisplay[key] = entry + return entry.frame + } + + @objc private func onScreensChanged(_ note: Notification) { + let screens = NSScreen.screens + let now = Date() + let currentIDs = Set(screens.compactMap { $0.displayUUID }) + + for screen in screens { + guard let key = screen.displayUUID else { continue } + if var entry = stateByDisplay[key] { + // Drop on dimension/scale change that makes the entry invalid + if !entry.isValid(for: screen) { + stateByDisplay.removeValue(forKey: key) + } else { + // Update the screen size if it grew (keep entry valid for larger screens) + entry.screenSize = screen.frame.size + entry.lastSeen = now + stateByDisplay[key] = entry + } + } + } + + // TTL prune for non-present screens + stateByDisplay = stateByDisplay.filter { key, entry in + currentIDs.contains(key) || now.timeIntervalSince(entry.lastSeen) < Self.screenStaleTTL + } + + pruneCapacity() + } + + private func pruneCapacity() { + guard stateByDisplay.count > Self.maxSavedScreens else { return } + let toRemove = stateByDisplay + .sorted { $0.value.lastSeen < $1.value.lastSeen } + .prefix(stateByDisplay.count - Self.maxSavedScreens) + for (key, _) in toRemove { + stateByDisplay.removeValue(forKey: key) + } + } + + private struct DisplayEntry { + var frame: NSRect + var screenSize: CGSize + var scale: CGFloat + var lastSeen: Date + + /// Returns true if this entry is still valid for the given screen. + /// Valid if the scale matches and the cached size is not larger than the current screen size. + /// This allows entries to persist when screens grow, but invalidates them when screens shrink. + func isValid(for screen: NSScreen) -> Bool { + guard scale == screen.backingScaleFactor else { return false } + return screenSize.width <= screen.frame.size.width && screenSize.height <= screen.frame.size.height + } + } +} diff --git a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift index f46106004..a8eb7b876 100644 --- a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift @@ -5,6 +5,13 @@ extension NSScreen { var displayID: UInt32? { deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32 } + + /// The stable UUID for this display, suitable for tracking across reconnects and NSScreen garbage collection. + var displayUUID: UUID? { + guard let displayID = displayID else { return nil } + guard let cfuuid = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() else { return nil } + return UUID(cfuuid) + } // Returns true if the given screen has a visible dock. This isn't // point-in-time visible, this is true if the dock is always visible diff --git a/macos/Sources/Helpers/Extensions/UUID+Extension.swift b/macos/Sources/Helpers/Extensions/UUID+Extension.swift new file mode 100644 index 000000000..e536353c5 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/UUID+Extension.swift @@ -0,0 +1,9 @@ +import Foundation + +extension UUID { + /// Initialize a UUID from a CFUUID. + init?(_ cfuuid: CFUUID) { + guard let uuidString = CFUUIDCreateString(nil, cfuuid) as String? else { return nil } + self.init(uuidString: uuidString) + } +} From be0da4845cb629bcf1f5f1890a28850bb7adfe16 Mon Sep 17 00:00:00 2001 From: tdslot Date: Sat, 18 Oct 2025 19:27:39 +0200 Subject: [PATCH 143/702] =?UTF-8?q?=F0=9F=8C=90=20i18n(locale):=20add=20li?= =?UTF-8?q?thuanian=20language=20support=20(#8711)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Andrius Budvytis <154380884+abudvytis@users.noreply.github.com> --- CODEOWNERS | 1 + po/lt_LT.UTF-8.po | 318 ++++++++++++++++++++++++++++++++++++++++ src/os/i18n_locales.zig | 1 + 3 files changed, 320 insertions(+) create mode 100644 po/lt_LT.UTF-8.po diff --git a/CODEOWNERS b/CODEOWNERS index 9e854b06c..8a4f797d8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -184,6 +184,7 @@ /po/ko_KR.UTF-8.po @ghostty-org/ko_KR /po/he_IL.UTF-8.po @ghostty-org/he_IL /po/it_IT.UTF-8.po @ghostty-org/it_IT +/po/lt_LT.UTF-8.po @ghostty-org/lt_LT /po/zh_TW.UTF-8.po @ghostty-org/zh_TW /po/hr_HR.UTF-8.po @ghostty-org/hr_HR diff --git a/po/lt_LT.UTF-8.po b/po/lt_LT.UTF-8.po new file mode 100644 index 000000000..0c466d3a4 --- /dev/null +++ b/po/lt_LT.UTF-8.po @@ -0,0 +1,318 @@ +# Language LT translations for com.mitchellh.ghostty package. +# Copyright (C) 2025 "Mitchell Hashimoto, Ghostty contributors" +# This file is distributed under the same license as the com.mitchellh.ghostty package. +# Tadas Lotuzas , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: com.mitchellh.ghostty\n" +"Report-Msgid-Bugs-To: m@mitchellh.com\n" +"POT-Creation-Date: 2025-07-22 17:18+0000\n" +"PO-Revision-Date: 2025-09-17 13:27+0200\n" +"Last-Translator: Tadas Lotuzas \n" +"Language-Team: Language LT\n" +"Language: LT\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5 +msgid "Change Terminal Title" +msgstr "Keisti terminalo pavadinimą" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 +msgid "Leave blank to restore the default title." +msgstr "Palikite tuščią, kad atkurtumėte numatytąjį pavadinimą." + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10 +#: src/apprt/gtk/CloseDialog.zig:44 +msgid "Cancel" +msgstr "Atšaukti" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10 +msgid "OK" +msgstr "Gerai" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:5 +msgid "Configuration Errors" +msgstr "Konfigūracijos klaidos" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:6 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6 +msgid "" +"One or more configuration errors were found. Please review the errors below, " +"and either reload your configuration or ignore these errors." +msgstr "" +"Rasta viena ar daugiau konfigūracijos klaidų. Peržiūrėkite žemiau esančias klaidas " +"ir arba iš naujo įkelkite konfigūraciją, arba ignoruokite šias klaidas." + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:9 +msgid "Ignore" +msgstr "Ignoruoti" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:10 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:97 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:100 +#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 +msgid "Reload Configuration" +msgstr "Iš naujo įkelti konfigūraciją" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:6 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:38 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:50 +msgid "Split Up" +msgstr "Padalinti aukštyn" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:11 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:43 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:55 +msgid "Split Down" +msgstr "Padalinti žemyn" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:16 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:48 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:60 +msgid "Split Left" +msgstr "Padalinti kairėn" + +#: src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp:21 +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:53 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:65 +msgid "Split Right" +msgstr "Padalinti dešinėn" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "Vykdyti komandą…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 +msgid "Copy" +msgstr "Kopijuoti" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:11 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11 +msgid "Paste" +msgstr "Įklijuoti" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:18 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:73 +msgid "Clear" +msgstr "Išvalyti" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:23 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:78 +msgid "Reset" +msgstr "Atstatyti" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:30 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:42 +msgid "Split" +msgstr "Padalinti" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45 +msgid "Change Title…" +msgstr "Keisti pavadinimą…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59 +msgid "Tab" +msgstr "Kortelė" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:62 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:30 +#: src/apprt/gtk/Window.zig:265 +msgid "New Tab" +msgstr "Nauja kortelė" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:67 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:35 +msgid "Close Tab" +msgstr "Uždaryti kortelę" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73 +msgid "Window" +msgstr "Langas" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:76 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:18 +msgid "New Window" +msgstr "Naujas langas" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:81 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:23 +msgid "Close Window" +msgstr "Uždaryti langą" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89 +msgid "Config" +msgstr "Konfigūracija" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:92 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:95 +msgid "Open Configuration" +msgstr "Atidaryti konfigūraciją" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "Komandų paletė" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +msgid "Terminal Inspector" +msgstr "Terminalo inspektorius" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1038 +msgid "About Ghostty" +msgstr "Apie Ghostty" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 +msgid "Quit" +msgstr "Išeiti" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:6 +msgid "Authorize Clipboard Access" +msgstr "Leisti prieigą prie iškarpinės" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:7 +msgid "" +"An application is attempting to read from the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Programa bando skaityti iš iškarpinės. Žemiau rodomas dabartinis " +"iškarpinės turinys." + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:10 +msgid "Deny" +msgstr "Drausti" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:11 +msgid "Allow" +msgstr "Leisti" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 +msgid "Remember choice for this split" +msgstr "Prisiminti pasirinkimą šiam padalijimui" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 +msgid "Reload configuration to show this prompt again" +msgstr "Iš naujo įkelkite konfigūraciją, kad vėl būtų rodoma ši užuomina" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 +#: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 +msgid "" +"An application is attempting to write to the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Programa bando rašyti į iškarpinę. Žemiau rodomas dabartinis " +"iškarpinės turinys." + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 src/apprt/gtk/ui/1.2/ccw-paste.blp:6 +msgid "Warning: Potentially Unsafe Paste" +msgstr "Įspėjimas: galimai nesaugus įklijavimas" + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 src/apprt/gtk/ui/1.2/ccw-paste.blp:7 +msgid "" +"Pasting this text into the terminal may be dangerous as it looks like some " +"commands may be executed." +msgstr "" +"Šio teksto įklijavimas į terminalą gali būti pavojingas, nes panašu, kad " +"gali būti vykdomos tam tikros komandos." + +#: src/apprt/gtk/CloseDialog.zig:47 src/apprt/gtk/Surface.zig:2531 +msgid "Close" +msgstr "Uždaryti" + +#: src/apprt/gtk/CloseDialog.zig:87 +msgid "Quit Ghostty?" +msgstr "Išeiti iš Ghostty?" + +#: src/apprt/gtk/CloseDialog.zig:88 +msgid "Close Window?" +msgstr "Uždaryti langą?" + +#: src/apprt/gtk/CloseDialog.zig:89 +msgid "Close Tab?" +msgstr "Uždaryti kortelę?" + +#: src/apprt/gtk/CloseDialog.zig:90 +msgid "Close Split?" +msgstr "Uždaryti padalijimą?" + +#: src/apprt/gtk/CloseDialog.zig:96 +msgid "All terminal sessions will be terminated." +msgstr "Visos terminalo sesijos bus nutrauktos." + +#: src/apprt/gtk/CloseDialog.zig:97 +msgid "All terminal sessions in this window will be terminated." +msgstr "Visos terminalo sesijos šiame lange bus nutrauktos." + +#: src/apprt/gtk/CloseDialog.zig:98 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Visos terminalo sesijos šioje kortelėje bus nutrauktos." + +#: src/apprt/gtk/CloseDialog.zig:99 +msgid "The currently running process in this split will be terminated." +msgstr "Šiuo metu vykdomas procesas šiame padalijime bus nutrauktas." + +#: src/apprt/gtk/Surface.zig:1266 +msgid "Copied to clipboard" +msgstr "Nukopijuota į iškarpinę" + +#: src/apprt/gtk/Surface.zig:1268 +msgid "Cleared clipboard" +msgstr "Iškarpinė išvalyta" + +#: src/apprt/gtk/Surface.zig:2525 +msgid "Command succeeded" +msgstr "Komanda sėkminga" + +#: src/apprt/gtk/Surface.zig:2527 +msgid "Command failed" +msgstr "Komanda nepavyko" + +#: src/apprt/gtk/Window.zig:216 +msgid "Main Menu" +msgstr "Pagrindinis meniu" + +#: src/apprt/gtk/Window.zig:239 +msgid "View Open Tabs" +msgstr "Peržiūrėti atidarytas korteles" + +#: src/apprt/gtk/Window.zig:266 +msgid "New Split" +msgstr "Naujas padalijimas" + +#: src/apprt/gtk/Window.zig:329 +msgid "" +"⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ Naudojate Ghostty derinimo versiją! Našumas bus sumažintas." + +#: src/apprt/gtk/Window.zig:775 +msgid "Reloaded the configuration" +msgstr "Konfigūracija įkelta iš naujo" + +#: src/apprt/gtk/Window.zig:1019 +msgid "Ghostty Developers" +msgstr "Ghostty kūrėjai" + +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: terminalo inspektorius" diff --git a/src/os/i18n_locales.zig b/src/os/i18n_locales.zig index 0fca223b9..ac1673a94 100644 --- a/src/os/i18n_locales.zig +++ b/src/os/i18n_locales.zig @@ -52,4 +52,5 @@ pub const locales = [_][:0]const u8{ "he_IL.UTF-8", "zh_TW.UTF-8", "hr_HR.UTF-8", + "lt_LT.UTF-8", }; From 92c9ba67d5c2b2e40b6b26d7ea6ef8aa1c239e43 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 19 Oct 2025 00:15:26 +0000 Subject: [PATCH 144/702] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 245a67e7b..1bd4d6a5b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz", - .hash = "N-V-__8AALIsAwAgV4B4dOJYBqTmOftgMMm4D4BxFPzns5Bl", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251013-150525-147b9d3/ghostty-themes.tgz", + .hash = "N-V-__8AAPk1AwDvSIEm8ftVzPLJr-Uuzz65Ss2R4ljTXjSq", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index d0193ed8b..cf2857147 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AALIsAwAgV4B4dOJYBqTmOftgMMm4D4BxFPzns5Bl": { + "N-V-__8AAPk1AwDvSIEm8ftVzPLJr-Uuzz65Ss2R4ljTXjSq": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz", - "hash": "sha256-yTN88FFxGVeK07fSQK+jWy9XLAln7f/W+xcDH+tLOEY=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251013-150525-147b9d3/ghostty-themes.tgz", + "hash": "sha256-vFn6MiR8tVkGyjSV7pz4k4msviSCjGHKM2SU84lJp/k=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 3c4f4d855..1ac748b69 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AALIsAwAgV4B4dOJYBqTmOftgMMm4D4BxFPzns5Bl"; + name = "N-V-__8AAPk1AwDvSIEm8ftVzPLJr-Uuzz65Ss2R4ljTXjSq"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz"; - hash = "sha256-yTN88FFxGVeK07fSQK+jWy9XLAln7f/W+xcDH+tLOEY="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251013-150525-147b9d3/ghostty-themes.tgz"; + hash = "sha256-vFn6MiR8tVkGyjSV7pz4k4msviSCjGHKM2SU84lJp/k="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index e789fa4eb..398231198 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -29,7 +29,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/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251006-150522-c07f0e8/ghostty-themes.tgz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251013-150525-147b9d3/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 93d73d2ca..d762d82c1 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-20251006-150522-c07f0e8/ghostty-themes.tgz", - "dest": "vendor/p/N-V-__8AALIsAwAgV4B4dOJYBqTmOftgMMm4D4BxFPzns5Bl", - "sha256": "c9337cf0517119578ad3b7d240afa35b2f572c0967edffd6fb17031feb4b3846" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251013-150525-147b9d3/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AAPk1AwDvSIEm8ftVzPLJr-Uuzz65Ss2R4ljTXjSq", + "sha256": "bc59fa32247cb55906ca3495ee9cf89389acbe24828c61ca336494f38949a7f9" }, { "type": "archive", From 3a9eedcd15f7c108d4f762c4b3924b0068523049 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 18 Oct 2025 21:28:47 -0700 Subject: [PATCH 145/702] renderer: don't allow the scrollbar state to block the renderer This fixes the source of a deadlock that some tip users have hit. If our surface mailbox is full and there is a dirty scrollbar state, then drawFrame would block forever trying to queue to the surface mailbox. We now fail instantly if the queue is full and keep the scrollbar state dirty. We can try again on the next frame, it's not a critical thing to get updated. --- src/renderer/generic.zig | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index d18e78afb..9e13d0b41 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1322,10 +1322,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // After the graphics API is complete (so we defer) we want to // update our scrollbar state. defer if (self.scrollbar_dirty) { - self.scrollbar_dirty = false; - _ = self.surface_mailbox.push(.{ + // Fail instantly if the surface mailbox if full, we'll just + // get it on the next frame. + if (self.surface_mailbox.push(.{ .scrollbar = self.scrollbar, - }, .{ .forever = {} }); + }, .instant) > 0) self.scrollbar_dirty = false; }; // Let our graphics API do any bookkeeping, etc. From c6788dd17839ef916c6096fe913c341a765ffb8d Mon Sep 17 00:00:00 2001 From: Sola Date: Sun, 19 Oct 2025 20:42:22 +0800 Subject: [PATCH 146/702] fix: fish shell integration should not modify universal path variable `fish_add_path` by default updates the `fish_user_paths` universal variable which makes the modification persist across shell sessions. The integration also tries to update the `fish_user_paths` when the desired path already appears in the `PATH` environment variable but not in `fish_user_paths`. Because `fish_user_paths` will always be inserted before the inherited `PATH` env. This makes the added path unintentionally has a higher priority. This patch makes the above issues by adding `--global` and `--path` options to `fish_user_paths` which limits the modification scope and ensures that the path won't be added if it already exists in `PATH`. --- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index f745bbb13..47af9be98 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -67,7 +67,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" # Add Ghostty binary to PATH if the path feature is enabled if contains path $features; and test -n "$GHOSTTY_BIN_DIR" - fish_add_path --append "$GHOSTTY_BIN_DIR" + fish_add_path --global --path --append "$GHOSTTY_BIN_DIR" end # When using sudo shell integration feature, ensure $TERMINFO is set From 73da748390178e4bdd814aeb8cccd4f37e27825a Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 19 Oct 2025 13:12:32 -0400 Subject: [PATCH 147/702] os: remove unused UrlParsingError This is no longer used as of e5247f6d. --- src/os/hostname.zig | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 283ece8d9..1d5ce428b 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -1,11 +1,6 @@ const std = @import("std"); const posix = std.posix; -pub const UrlParsingError = std.Uri.ParseError || error{ - HostnameIsNotMacAddress, - NoSchemeProvided, -}; - pub const LocalHostnameValidationError = error{ PermissionDenied, Unexpected, From 010cbce2201c8bd6ff57ce160ddf8fa69829e5b8 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 19 Oct 2025 14:27:18 -0400 Subject: [PATCH 148/702] os: add RFC 1123-compliant hostname.isValid std.net.isValidHostname is currently too generous. It considers strings like ".example.com", "exa..mple.com", and "-example.com" to be valid hostnames, which is incorrect according to RFC 1123 (the currently accepted standard). Until the standard library function is improved, we can use this local implementation that does follow the RFC 1123 standard. I asked Claude to perform an audit of the code based on its understand of the RFC. It suggested some additional test cases and considers the overall implementation to be robust (its words) and standards compliant. Ref: https://www.rfc-editor.org/rfc/rfc1123 --- src/os/hostname.zig | 85 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 283ece8d9..14cd61e7a 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -11,6 +11,91 @@ pub const LocalHostnameValidationError = error{ Unexpected, }; +/// Validates a hostname according to [RFC 1123](https://www.rfc-editor.org/rfc/rfc1123) +/// +/// std.net.isValidHostname is (currently) too generous. It considers strings like +/// ".example.com", "exa..mple.com", and "-example.com" to be valid hostnames, which +/// is incorrect. +pub fn isValid(hostname: []const u8) bool { + if (hostname.len == 0) return false; + if (hostname[0] == '.') return false; + + // Ignore trailing dot (FQDN). It doesn't count toward our length. + const end = if (hostname[hostname.len - 1] == '.') end: { + if (hostname.len == 1) return false; + break :end hostname.len - 1; + } else hostname.len; + + if (end > 253) return false; + + // Hostnames are divided into dot-separated "labels", which: + // + // - Start with a letter or digit + // - Can contain letters, digits, or hyphens + // - Must end with a letter or digit + // - Have a minimum of 1 character and a maximum of 63 + var label_start: usize = 0; + var label_len: usize = 0; + for (hostname[0..end], 0..) |c, i| { + switch (c) { + '.' => { + if (label_len == 0 or label_len > 63) return false; + if (!std.ascii.isAlphanumeric(hostname[label_start])) return false; + if (!std.ascii.isAlphanumeric(hostname[i - 1])) return false; + + label_start = i + 1; + label_len = 0; + }, + '-' => { + label_len += 1; + }, + else => { + if (!std.ascii.isAlphanumeric(c)) return false; + label_len += 1; + }, + } + } + + // Validate the final label + if (label_len == 0 or label_len > 63) return false; + if (!std.ascii.isAlphanumeric(hostname[label_start])) return false; + if (!std.ascii.isAlphanumeric(hostname[end - 1])) return false; + + return true; +} + +test isValid { + const testing = std.testing; + + // Valid hostnames + try testing.expect(isValid("example")); + try testing.expect(isValid("example.com")); + try testing.expect(isValid("www.example.com")); + try testing.expect(isValid("sub.domain.example.com")); + try testing.expect(isValid("example.com.")); + try testing.expect(isValid("host-name.example.com.")); + try testing.expect(isValid("123.example.com.")); + try testing.expect(isValid("a-b.com")); + try testing.expect(isValid("a.b.c.d.e.f.g")); + try testing.expect(isValid("127.0.0.1")); // Also a valid hostname + try testing.expect(isValid("a" ** 63 ++ ".com")); // Label exactly 63 chars (valid) + try testing.expect(isValid("a." ** 126 ++ "a")); // Total length 253 (valid) + + // Invalid hostnames + try testing.expect(!isValid("")); + try testing.expect(!isValid(".example.com")); + try testing.expect(!isValid("example.com..")); + try testing.expect(!isValid("host..domain")); + try testing.expect(!isValid("-hostname")); + try testing.expect(!isValid("hostname-")); + try testing.expect(!isValid("a.-.b")); + try testing.expect(!isValid("host_name.com")); + try testing.expect(!isValid(".")); + try testing.expect(!isValid("..")); + try testing.expect(!isValid("a" ** 64 ++ ".com")); // Label length 64 (too long) + try testing.expect(!isValid("a." ** 126 ++ "ab")); // Total length 254 (too long) +} + /// Checks if a hostname is local to the current machine. This matches /// both "localhost" and the current hostname of the machine (as returned /// by `gethostname`). From b2559e8d92da08fbff2f1d4188afa0d0c81d0b81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 00:09:59 +0000 Subject: [PATCH 149/702] build(deps): bump flatpak/flatpak-github-actions from 6.5 to 6.6 Bumps [flatpak/flatpak-github-actions](https://github.com/flatpak/flatpak-github-actions) from 6.5 to 6.6. - [Release notes](https://github.com/flatpak/flatpak-github-actions/releases) - [Commits](https://github.com/flatpak/flatpak-github-actions/compare/10a3c29f0162516f0f68006be14c92f34bd4fa6c...92ae9851ad316786193b1fd3f40c4b51eb5cb101) --- updated-dependencies: - dependency-name: flatpak/flatpak-github-actions dependency-version: '6.6' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f3196c3b..1356ec3e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1095,7 +1095,7 @@ jobs: needs: test steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - uses: flatpak/flatpak-github-actions/flatpak-builder@10a3c29f0162516f0f68006be14c92f34bd4fa6c # v6.5 + - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 with: bundle: com.mitchellh.ghostty manifest-path: flatpak/com.mitchellh.ghostty.yml From d01697e7d591de951e587122a42b875ea291d66d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 00:10:08 +0000 Subject: [PATCH 150/702] build(deps): bump namespacelabs/nscloud-cache-action Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.18 to 1.2.19. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/7baedde84bbf5063413d621f282834bc2654d0c1...caff5c9dc51d8126e7d16141fb015c478374256b) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.2.19 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 | 2 +- .github/workflows/snap.yml | 2 +- .github/workflows/test.yml | 48 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 52190f020..da669b073 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 50a8ec7bb..e5af4ac38 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 8f7bd2665..2331dbba9 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -161,7 +161,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 4e9aa168c..cfe5591f4 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -38,7 +38,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f3196c3b..320ac0f91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,7 +72,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -106,7 +106,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -139,7 +139,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -173,7 +173,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -215,7 +215,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -251,7 +251,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -280,7 +280,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -313,7 +313,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -359,7 +359,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -578,7 +578,7 @@ jobs: echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -668,7 +668,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -703,7 +703,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -767,7 +767,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -796,7 +796,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -824,7 +824,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -851,7 +851,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -878,7 +878,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -905,7 +905,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -932,7 +932,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -966,7 +966,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -993,7 +993,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -1028,7 +1028,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix @@ -1116,7 +1116,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index b218cdb26..c14ee56a6 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 + uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 with: path: | /nix From e5224827105f2a27a73cc69cc35f240f809a42c6 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 19 Oct 2025 20:19:34 -0400 Subject: [PATCH 151/702] cli: fix +ssh-cache IPv6 address validation The host validation code previously expected IPv6 addresses to be enclosed in [brackets], but that's not how ssh(1) expects them. This change removes that requirement and reimplements the host validation routine to check for valid hostnames and IP addresses (IPv4 and IPv6) using standard routines rather than custom logic. --- src/cli/ssh-cache/DiskCache.zig | 115 +++++++++++++------------------- 1 file changed, 45 insertions(+), 70 deletions(-) diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index 608155dfd..25d2cd42e 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -7,8 +7,9 @@ const std = @import("std"); const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const xdg = @import("../../os/main.zig").xdg; -const TempDir = @import("../../os/main.zig").TempDir; +const internal_os = @import("../../os/main.zig"); +const xdg = internal_os.xdg; +const TempDir = internal_os.TempDir; const Entry = @import("Entry.zig"); // 512KB - sufficient for approximately 10k entries @@ -334,48 +335,28 @@ fn isValidCacheKey(key: []const u8) bool { if (std.mem.indexOf(u8, key, "@")) |at_pos| { const user = key[0..at_pos]; const hostname = key[at_pos + 1 ..]; - return isValidUser(user) and isValidHostname(hostname); + return isValidUser(user) and isValidHost(hostname); } - return isValidHostname(key); + return isValidHost(key); } -// Basic hostname validation - accepts domains and IPs -// (including IPv6 in brackets) -fn isValidHostname(host: []const u8) bool { - if (host.len == 0 or host.len > 253) return false; - - // Handle IPv6 addresses in brackets - if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') { - const ipv6_part = host[1 .. host.len - 1]; - if (ipv6_part.len == 0) return false; - var has_colon = false; - for (ipv6_part) |c| { - switch (c) { - 'a'...'f', 'A'...'F', '0'...'9' => {}, - ':' => has_colon = true, - else => return false, - } - } - return has_colon; +// Checks if a host is a valid hostname or IP address +fn isValidHost(host: []const u8) bool { + // First check for valid hostnames because this is assumed to be the more + // likely ssh host format. + if (internal_os.hostname.isValid(host)) { + return true; } - // Standard hostname/domain validation - for (host) |c| { - switch (c) { - 'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {}, - else => return false, - } - } - - // No leading/trailing dots or hyphens, no consecutive dots - if (host[0] == '.' or host[0] == '-' or - host[host.len - 1] == '.' or host[host.len - 1] == '-') - { + // We also accept valid IP addresses. In practice, IPv4 addresses are also + // considered valid hostnames due to their overlapping syntax, so we can + // simplify this check to be IPv6-specific. + if (std.net.Address.parseIp6(host, 0)) |_| { + return true; + } else |_| { return false; } - - return std.mem.indexOf(u8, host, "..") == null; } fn isValidUser(user: []const u8) bool { @@ -475,42 +456,36 @@ test "disk cache operations" { } // Tests -test "hostname validation - valid cases" { - const testing = std.testing; - try testing.expect(isValidHostname("example.com")); - try testing.expect(isValidHostname("sub.example.com")); - try testing.expect(isValidHostname("host-name.domain.org")); - try testing.expect(isValidHostname("192.168.1.1")); - try testing.expect(isValidHostname("a")); - try testing.expect(isValidHostname("1")); -} -test "hostname validation - IPv6 addresses" { +test isValidHost { const testing = std.testing; - try testing.expect(isValidHostname("[::1]")); - try testing.expect(isValidHostname("[2001:db8::1]")); - try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported - try testing.expect(!isValidHostname("[]")); // Empty IPv6 - try testing.expect(!isValidHostname("[invalid]")); // No colons -} -test "hostname validation - invalid cases" { - const testing = std.testing; - try testing.expect(!isValidHostname("")); - try testing.expect(!isValidHostname("host\nname")); - try testing.expect(!isValidHostname(".example.com")); - try testing.expect(!isValidHostname("example.com.")); - try testing.expect(!isValidHostname("host..domain")); - try testing.expect(!isValidHostname("-hostname")); - try testing.expect(!isValidHostname("hostname-")); - try testing.expect(!isValidHostname("host name")); - try testing.expect(!isValidHostname("host_name")); - try testing.expect(!isValidHostname("host@domain")); - try testing.expect(!isValidHostname("host:port")); + // Valid hostnames + try testing.expect(isValidHost("localhost")); + try testing.expect(isValidHost("example.com")); + try testing.expect(isValidHost("sub.example.com")); - // Too long - const long_host = "a" ** 254; - try testing.expect(!isValidHostname(long_host)); + // IPv4 addresses + try testing.expect(isValidHost("127.0.0.1")); + try testing.expect(isValidHost("192.168.1.1")); + + // IPv6 addresses + try testing.expect(isValidHost("::1")); + try testing.expect(isValidHost("2001:db8::1")); + try testing.expect(isValidHost("2001:db8:0:1:1:1:1:1")); + try testing.expect(!isValidHost("fe80::1%eth0")); // scopes not supported + + // Invalid hosts + try testing.expect(!isValidHost("")); + try testing.expect(!isValidHost("host\nname")); + try testing.expect(!isValidHost(".example.com")); + try testing.expect(!isValidHost("host..domain")); + try testing.expect(!isValidHost("-hostname")); + try testing.expect(!isValidHost("hostname-")); + try testing.expect(!isValidHost("host name")); + try testing.expect(!isValidHost("host_name")); + try testing.expect(!isValidHost("host@domain")); + try testing.expect(!isValidHost("host:port")); } test "user validation - valid cases" { @@ -551,7 +526,7 @@ test "cache key validation - hostname format" { try testing.expect(isValidCacheKey("example.com")); try testing.expect(isValidCacheKey("sub.example.com")); try testing.expect(isValidCacheKey("192.168.1.1")); - try testing.expect(isValidCacheKey("[::1]")); + try testing.expect(isValidCacheKey("::1")); try testing.expect(!isValidCacheKey("")); try testing.expect(!isValidCacheKey(".invalid.com")); } @@ -563,7 +538,7 @@ test "cache key validation - user@hostname format" { try testing.expect(isValidCacheKey("test-user@192.168.1.1")); try testing.expect(isValidCacheKey("user_name@host.domain.org")); try testing.expect(isValidCacheKey("git@github.com")); - try testing.expect(isValidCacheKey("ubuntu@[::1]")); + try testing.expect(isValidCacheKey("ubuntu@::1")); try testing.expect(!isValidCacheKey("@example.com")); try testing.expect(!isValidCacheKey("user@")); try testing.expect(!isValidCacheKey("user@@host")); From 014de2992eb696b50db9a6bc160695921b09c3b6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 19 Oct 2025 20:29:36 -0700 Subject: [PATCH 152/702] macos: goto_split direction is performable (#9284) Fixes #9283 There was a comment here noting this deficiency. GTK implements this properly. --- macos/Ghostty.xcodeproj/project.pbxproj | 2 +- .../Terminal/BaseTerminalController.swift | 13 +------- macos/Sources/Ghostty/Ghostty.App.swift | 28 ++++++++++++----- macos/Sources/Ghostty/Package.swift | 31 +++++++++++++++++++ 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 86292fbe2..ca420afaa 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -870,7 +870,7 @@ A5D449A82B53AE7B000F5B83 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = "Ghostty-Debug"; + ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index b9f9c5a05..552f864ee 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -569,23 +569,12 @@ class BaseTerminalController: NSWindowController, // Get the direction from the notification guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return } guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return } - - // Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection - let focusDirection: SplitTree.FocusDirection - switch direction { - case .previous: focusDirection = .previous - case .next: focusDirection = .next - case .up: focusDirection = .spatial(.up) - case .down: focusDirection = .spatial(.down) - case .left: focusDirection = .spatial(.left) - case .right: focusDirection = .spatial(.right) - } // Find the node for the target surface guard let targetNode = surfaceTree.root?.node(view: target) else { return } // Find the next surface to focus - guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else { + guard let nextSurface = surfaceTree.focusTarget(for: direction.toSplitTreeFocusDirection(), from: targetNode) else { return } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 91829f95c..3db8e7a11 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1029,26 +1029,38 @@ extension Ghostty { guard let surfaceView = self.surfaceView(from: surface) else { return false } guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false } - // For now, we return false if the window has no splits and we return - // true if the window has ANY splits. This isn't strictly correct because - // we should only be returning true if we actually performed the action, - // but this handles the most common case of caring about goto_split performability - // which is the no-split case. + // If the window has no splits, the action is not performable guard controller.surfaceTree.isSplit else { return false } + // Convert the C API direction to our Swift type + guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return false } + + // Find the current node in the tree + guard let targetNode = controller.surfaceTree.root?.node(view: surfaceView) else { return false } + + // Check if a split actually exists in the target direction before + // returning true. This ensures performable keybinds only consume + // the key event when we actually perform navigation. + let focusDirection: SplitTree.FocusDirection = splitDirection.toSplitTreeFocusDirection() + guard controller.surfaceTree.focusTarget(for: focusDirection, from: targetNode) != nil else { + return false + } + + // We have a valid target, post the notification to perform the navigation NotificationCenter.default.post( name: Notification.ghosttyFocusSplit, object: surfaceView, userInfo: [ - Notification.SplitDirectionKey: SplitFocusDirection.from(direction: direction) as Any, + Notification.SplitDirectionKey: splitDirection as Any, ] ) + return true + default: assertionFailure() + return false } - - return true } private static func resizeSplit( diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index e8a3d0976..26804be78 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -223,7 +223,38 @@ extension Ghostty { } } } +} +#if canImport(AppKit) +// MARK: SplitFocusDirection Extensions + +extension Ghostty.SplitFocusDirection { + /// Convert to a SplitTree.FocusDirection for the given ViewType. + func toSplitTreeFocusDirection() -> SplitTree.FocusDirection { + switch self { + case .previous: + return .previous + + case .next: + return .next + + case .up: + return .spatial(.up) + + case .down: + return .spatial(.down) + + case .left: + return .spatial(.left) + + case .right: + return .spatial(.right) + } + } +} +#endif + +extension Ghostty { /// The type of a clipboard request enum ClipboardRequest { /// A direct paste of clipboard contents From 1b866918963421b81f44cff8a5c886c8813a5788 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 19 Oct 2025 20:43:28 -0700 Subject: [PATCH 153/702] termio: use a union to represent how a process is started (#9278) This cleans up some of our termio exec code by unifying process launch state into a single union type. This makes it easier to distinguish between the current two mutually exclusive modes of launching a process: fork/exec and flatpak dbus commands. It also ensures everyplace we touch related to process launching is forced to address every case (exhaustive switch handling). I did find one resource cleanup bug based on this cleanup, which is also fixed here. This just improves memory slightly so it's not a big deal. If we add future ways to launch processes, we can add a new union case. For example, I originally had a `posix_spawn` option while I was experimenting with that before abandoning it (see #9274). --- src/termio/Exec.zig | 182 ++++++++++++++++++++++---------------------- 1 file changed, 93 insertions(+), 89 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 319ae0ee6..5dfda9a14 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -102,24 +102,17 @@ pub fn threadEnter( errdefer self.subprocess.stop(); // Watcher to detect subprocess exit - var process: ?xev.Process = process: { + var process: ?xev.Process = if (self.subprocess.process) |v| switch (v) { + .fork_exec => |cmd| try xev.Process.init( + cmd.pid orelse return error.ProcessNoPid, + ), + // If we're executing via Flatpak then we can't do // traditional process watching (its implemented // as a special case in os/flatpak.zig) since the // command is on the host. - if (comptime build_config.flatpak) { - if (self.subprocess.flatpak_command != null) { - break :process null; - } - } - - // Get the pid from the subprocess - const command = self.subprocess.command orelse - return error.ProcessNotStarted; - const pid = command.pid orelse - return error.ProcessNoPid; - break :process try xev.Process.init(pid); - }; + .flatpak => null, + } else return error.ProcessNotStarted; errdefer if (process) |*p| p.deinit(); // Track our process start time for abnormal exits @@ -167,17 +160,19 @@ pub fn threadEnter( termio.Termio.ThreadData, td, processExit, - ) else if (comptime build_config.flatpak) { - // If we're in flatpak and we have a flatpak command - // then we can run the special flatpak logic for watching. - if (self.subprocess.flatpak_command) |*c| { - c.waitXev( + ) else if (comptime build_config.flatpak) flatpak: { + switch (self.subprocess.process orelse break :flatpak) { + // If we're in flatpak and we have a flatpak command + // then we can run the special flatpak logic for watching. + .flatpak => |*c| c.waitXev( td.loop, &td.backend.exec.flatpak_wait_c, termio.Termio.ThreadData, td, flatpakExit, - ); + ), + + .fork_exec => {}, } } @@ -587,10 +582,18 @@ const Subprocess = struct { grid_size: renderer.GridSize, screen_size: renderer.ScreenSize, pty: ?Pty = null, - command: ?Command = null, - flatpak_command: ?FlatpakHostCommand = null, + process: ?Process = null, linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default, + /// Union that represents the running process type. + const Process = union(enum) { + /// Standard POSIX fork/exec + fork_exec: Command, + + /// Flatpak DBus command + flatpak: FlatpakHostCommand, + }; + const ArgsFormatter = struct { args: []const [:0]const u8, @@ -883,7 +886,7 @@ const Subprocess = struct { read: Pty.Fd, write: Pty.Fd, } { - assert(self.pty == null and self.command == null); + assert(self.pty == null and self.process == null); // This function is funny because on POSIX systems it can // fail in the forked process. This is flipped to true if @@ -908,6 +911,23 @@ const Subprocess = struct { self.pty = null; }; + // Cleanup we only run in our parent when we successfully start + // the process. + defer if (!in_child and self.process != null) { + if (comptime builtin.os.tag != .windows) { + // Once our subcommand is started we can close the slave + // side. This prevents the slave fd from being leaked to + // future children. + _ = posix.close(pty.slave); + } + + // Successful start we can clear out some memory. + if (self.env) |*env| { + env.deinit(); + self.env = null; + } + }; + log.debug("starting command command={f}", .{ArgsFormatter{ .args = self.args }}); // If we can't access the cwd, then don't set any cwd and inherit. @@ -959,15 +979,15 @@ const Subprocess = struct { } // Flatpak command must have a stable pointer. - self.flatpak_command = .{ + self.process = .{ .flatpak = .{ .argv = self.args, .cwd = cwd, .env = if (self.env) |*env| env else null, .stdin = pty.slave, .stdout = pty.slave, .stderr = pty.slave, - }; - var cmd = &self.flatpak_command.?; + } }; + var cmd = &self.process.?.flatpak; const pid = try cmd.spawn(alloc); errdefer killCommandFlatpak(cmd); @@ -976,11 +996,6 @@ const Subprocess = struct { pid, }); - // Once started, we can close the pty child side. We do this after - // wait right now but that is fine too. This lets us read the - // parent and detect EOF. - _ = posix.close(pty.slave); - return .{ .read = pty.master, .write = pty.master, @@ -1033,20 +1048,7 @@ const Subprocess = struct { log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); } - if (comptime builtin.os.tag != .windows) { - // Once our subcommand is started we can close the slave - // side. This prevents the slave fd from being leaked to - // future children. - _ = posix.close(pty.slave); - } - - // Successful start we can clear out some memory. - if (self.env) |*env| { - env.deinit(); - self.env = null; - } - - self.command = cmd; + self.process = .{ .fork_exec = cmd }; return switch (builtin.os.tag) { .windows => .{ .read = pty.out_pipe, @@ -1071,7 +1073,7 @@ const Subprocess = struct { /// Called to notify that we exited externally so we can unset our /// running state. pub fn externalExit(self: *Subprocess) void { - self.command = null; + self.process = null; } /// Stop the subprocess. This is safe to call anytime. This will wait @@ -1079,25 +1081,23 @@ const Subprocess = struct { /// for it to terminate, so it will not block. /// This does not close the pty. pub fn stop(self: *Subprocess) void { - // Kill our command - if (self.command) |*cmd| { - // Note: this will also wait for the command to exit, so - // DO NOT call cmd.wait - killCommand(cmd) catch |err| - log.err("error sending SIGHUP to command, may hang: {}", .{err}); - self.command = null; - } + switch (self.process orelse return) { + .fork_exec => |*cmd| { + // Note: this will also wait for the command to exit, so + // DO NOT call cmd.wait + killCommand(cmd) catch |err| + log.err("error sending SIGHUP to command, may hang: {}", .{err}); + }, - // Kill our Flatpak command - if (comptime build_config.flatpak) { - if (self.flatpak_command) |*cmd| { + .flatpak => |*cmd| if (comptime build_config.flatpak) { killCommandFlatpak(cmd) catch |err| log.err("error sending SIGHUP to command, may hang: {}", .{err}); _ = cmd.wait() catch |err| log.err("error waiting for command to exit: {}", .{err}); - self.flatpak_command = null; - } + }, } + + self.process = null; } /// Resize the pty subprocess. This is safe to call anytime. @@ -1137,41 +1137,45 @@ const Subprocess = struct { _ = try command.wait(false); }, - else => if (getpgid(pid)) |pgid| { - // It is possible to send a killpg between the time that - // our child process calls setsid but before or simultaneous - // to calling execve. In this case, the direct child dies - // but grandchildren survive. To work around this, we loop - // and repeatedly kill the process group until all - // descendents are well and truly dead. We will not rest - // until the entire family tree is obliterated. - while (true) { - switch (posix.errno(c.killpg(pgid, c.SIGHUP))) { - .SUCCESS => log.debug("process group killed pgid={}", .{pgid}), - else => |err| killpg: { - if ((comptime builtin.target.os.tag.isDarwin()) and - err == .PERM) - { - log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{}); - break :killpg; - } + else => try killPid(pid), + } + } + } - log.warn("error killing process group pgid={} err={}", .{ pgid, err }); - return error.KillFailed; - }, - } + fn killPid(pid: c.pid_t) !void { + const pgid = getpgid(pid) orelse return; - // See Command.zig wait for why we specify WNOHANG. - // The gist is that it lets us detect when children - // are still alive without blocking so that we can - // kill them again. - const res = posix.waitpid(pid, std.c.W.NOHANG); - log.debug("waitpid result={}", .{res.pid}); - if (res.pid != 0) break; - std.Thread.sleep(10 * std.time.ns_per_ms); + // It is possible to send a killpg between the time that + // our child process calls setsid but before or simultaneous + // to calling execve. In this case, the direct child dies + // but grandchildren survive. To work around this, we loop + // and repeatedly kill the process group until all + // descendents are well and truly dead. We will not rest + // until the entire family tree is obliterated. + while (true) { + switch (posix.errno(c.killpg(pgid, c.SIGHUP))) { + .SUCCESS => log.debug("process group killed pgid={}", .{pgid}), + else => |err| killpg: { + if ((comptime builtin.target.os.tag.isDarwin()) and + err == .PERM) + { + log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{}); + break :killpg; } + + log.warn("error killing process group pgid={} err={}", .{ pgid, err }); + return error.KillFailed; }, } + + // See Command.zig wait for why we specify WNOHANG. + // The gist is that it lets us detect when children + // are still alive without blocking so that we can + // kill them again. + const res = posix.waitpid(pid, std.c.W.NOHANG); + log.debug("waitpid result={}", .{res.pid}); + if (res.pid != 0) break; + std.Thread.sleep(10 * std.time.ns_per_ms); } } From 2696d50ca42003d648aa0172315b6f4d3b66443e Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk <49077192+matthew-hre@users.noreply.github.com> Date: Sun, 19 Oct 2025 21:45:37 -0600 Subject: [PATCH 154/702] feat: added mouse-reporting / toggle-mouse-reporting (#9282) Closes #8430 A few questions: * Should I set a default keybind for `toggle-mouse-reporting`? The issue mentioned one, it's currently unset. * Am I handling the `toggle-mouse-reporting` action properly in `performAction` (gtk) / `action` (macos)? Copilot was used to understand the codebase, but code was authored manually. --- src/Surface.zig | 28 +++++++++++++++++++++++----- src/config/Config.zig | 12 ++++++++++++ src/input/Binding.zig | 12 ++++++++++++ src/input/command.zig | 6 ++++++ 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index e75c4b409..c9c40f466 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -268,6 +268,7 @@ const DerivedConfig = struct { font: font.SharedGridSet.DerivedConfig, mouse_interval: u64, mouse_hide_while_typing: bool, + mouse_reporting: bool, mouse_scroll_multiplier: configpkg.MouseScrollMultiplier, mouse_shift_capture: configpkg.MouseShiftCapture, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, @@ -341,6 +342,7 @@ const DerivedConfig = struct { .font = try font.SharedGridSet.DerivedConfig.init(alloc, config), .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms .mouse_hide_while_typing = config.@"mouse-hide-while-typing", + .mouse_reporting = config.@"mouse-reporting", .mouse_scroll_multiplier = config.@"mouse-scroll-multiplier", .mouse_shift_capture = config.@"mouse-shift-capture", .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen", @@ -2984,7 +2986,7 @@ pub fn scrollCallback( // If we have an active mouse reporting mode, clear the selection. // The selection can occur if the user uses the shift mod key to // override mouse grabbing from the window. - if (self.io.terminal.flags.mouse_event != .none) { + if (self.isMouseReporting()) { try self.setSelection(null); } @@ -3027,7 +3029,7 @@ pub fn scrollCallback( // the normal logic. // If we're scrolling up or down, then send a mouse event. - if (self.io.terminal.flags.mouse_event != .none) { + if (self.isMouseReporting()) { for (0..@abs(y.delta)) |_| { const pos = try self.rt_surface.getCursorPos(); try self.mouseReport(switch (y.direction()) { @@ -3100,6 +3102,13 @@ pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) ! /// The type of action to report for a mouse event. const MouseReportAction = enum { press, release, motion }; +/// Returns true if mouse reporting is enabled both in the config and +/// the terminal state. +fn isMouseReporting(self: *const Surface) bool { + return self.config.mouse_reporting and + self.io.terminal.flags.mouse_event != .none; +} + fn mouseReport( self: *Surface, button: ?input.MouseButton, @@ -3107,9 +3116,13 @@ fn mouseReport( mods: input.Mods, pos: apprt.CursorPos, ) !void { + // Mouse reporting must be enabled by both config and terminal state + assert(self.config.mouse_reporting); + assert(self.io.terminal.flags.mouse_event != .none); + // Depending on the event, we may do nothing at all. switch (self.io.terminal.flags.mouse_event) { - .none => return, + .none => unreachable, // checked by assert above // X10 only reports clicks with mouse button 1, 2, 3. We verify // the button later. @@ -3504,7 +3517,7 @@ pub fn mouseButtonCallback( { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - if (self.io.terminal.flags.mouse_event != .none) report: { + if (self.isMouseReporting()) report: { // If we have shift-pressed and we aren't allowed to capture it, // then we do not do a mouse report. if (mods.shift and !shift_capture) break :report; @@ -4142,7 +4155,7 @@ pub fn cursorPosCallback( } // Do a mouse report - if (self.io.terminal.flags.mouse_event != .none) report: { + if (self.isMouseReporting()) report: { // Shift overrides mouse "grabbing" in the window, taken from Kitty. // This only applies if there is a mouse button pressed so that // movement reports are not affected. @@ -5035,6 +5048,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle, ), + .toggle_mouse_reporting => { + self.config.mouse_reporting = !self.config.mouse_reporting; + log.debug("mouse reporting toggled: {}", .{self.config.mouse_reporting}); + }, + .toggle_command_palette => return try self.rt_app.performAction( .{ .surface = self }, .toggle_command_palette, diff --git a/src/config/Config.zig b/src/config/Config.zig index d0e086710..c9ae121e4 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -839,6 +839,18 @@ palette: Palette = .{}, /// * `never` @"mouse-shift-capture": MouseShiftCapture = .false, +/// Enable or disable mouse reporting. When set to `false`, mouse events will +/// not be reported to terminal applications even if they request it. This +/// allows you to always use the mouse for selection and other terminal UI +/// interactions without applications capturing mouse input. +/// +/// When set to `true` (the default), terminal applications can request mouse +/// reporting and will receive mouse events according to their requested mode. +/// +/// This can be toggled at runtime using the `toggle_mouse_reporting` keybind +/// action. +@"mouse-reporting": bool = true, + /// Multiplier for scrolling distance with the mouse wheel. /// /// A prefix of `precision:` or `discrete:` can be used to set the multiplier diff --git a/src/input/Binding.zig b/src/input/Binding.zig index ad07dce55..9bdd858c1 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -637,6 +637,17 @@ pub const Action = union(enum) { /// Only implemented on macOS, as this uses a built-in system API. toggle_secure_input, + /// Toggle mouse reporting on or off. + /// + /// When mouse reporting is disabled, mouse events will not be reported to + /// terminal applications even if they request it. This allows you to always + /// use the mouse for selection and other terminal UI interactions without + /// applications capturing mouse input. + /// + /// This can also be controlled via the `mouse-reporting` configuration + /// option. + toggle_mouse_reporting, + /// Toggle the command palette. /// /// The command palette is a popup that lets you see what actions @@ -1099,6 +1110,7 @@ pub const Action = union(enum) { .toggle_window_decorations, .toggle_window_float_on_top, .toggle_secure_input, + .toggle_mouse_reporting, .toggle_command_palette, .show_on_screen_keyboard, .reset_window_size, diff --git a/src/input/command.zig b/src/input/command.zig index 29c10527e..0904ef2bb 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -449,6 +449,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle secure input mode.", }}, + .toggle_mouse_reporting => comptime &.{.{ + .action = .toggle_mouse_reporting, + .title = "Toggle Mouse Reporting", + .description = "Toggle whether mouse events are reported to terminal applications.", + }}, + .check_for_updates => comptime &.{.{ .action = .check_for_updates, .title = "Check for Updates", From 0546606e059b197479cc8f548b0990a341df6249 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 20 Oct 2025 13:14:42 -0700 Subject: [PATCH 155/702] build: add -Demit-themes option to emit/omit theme resources (#9288) We'll backport this to 1.2.x for distros that would prefer this. --- src/build/Config.zig | 7 +++++++ src/build/GhosttyResources.zig | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/build/Config.zig b/src/build/Config.zig index 745fc926f..124cf7299 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -55,6 +55,7 @@ emit_macos_app: bool = false, emit_terminfo: bool = false, emit_termcap: bool = false, emit_test_exe: bool = false, +emit_themes: bool = false, emit_xcframework: bool = false, emit_webdata: bool = false, emit_unicode_table_gen: bool = false, @@ -368,6 +369,12 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, }; + config.emit_themes = b.option( + bool, + "emit-themes", + "Install bundled iTerm2-Color-Schemes Ghostty themes", + ) orelse true; + config.emit_webdata = b.option( bool, "emit-webdata", diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index b80aef97e..1ac8fe2a9 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -125,14 +125,16 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { } // Themes - if (b.lazyDependency("iterm2_themes", .{})) |upstream| { - const install_step = b.addInstallDirectory(.{ - .source_dir = upstream.path(""), - .install_dir = .{ .custom = "share" }, - .install_subdir = b.pathJoin(&.{ "ghostty", "themes" }), - .exclude_extensions = &.{".md"}, - }); - try steps.append(b.allocator, &install_step.step); + if (cfg.emit_themes) { + if (b.lazyDependency("iterm2_themes", .{})) |upstream| { + const install_step = b.addInstallDirectory(.{ + .source_dir = upstream.path(""), + .install_dir = .{ .custom = "share" }, + .install_subdir = b.pathJoin(&.{ "ghostty", "themes" }), + .exclude_extensions = &.{".md"}, + }); + try steps.append(b.allocator, &install_step.step); + } } // Fish shell completions From da165fc3cf6f8ef514f8f7b52c5bdb080509aa74 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 20 Oct 2025 14:41:35 -0700 Subject: [PATCH 156/702] input: modify other keys 2 should use all mods, ignore consumed mods (#9289) Fixes #8900 Our xterm modify other keys state 2 encoding was stripped consumed mods from the keyboard event. This doesn't match xterm or other popular terminal emulators (but most importantly: xterm). Use the full set of mods and add a test to verify this. Reproduction: ``` printf '\033[>4;2m' cat ``` Then press `ctrl+shift+h` and compare across terminals. --- src/input/key_encode.zig | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index fa641c1aa..f411deb19 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -77,7 +77,7 @@ pub fn encode( event: key.KeyEvent, opts: Options, ) std.Io.Writer.Error!void { - // log.warn("KEYENCODER self={}", .{self.*}); + //std.log.warn("KEYENCODER event={} opts={}", .{ event, opts }); return if (opts.kitty_flags.int() != 0) try kitty( writer, event, @@ -411,6 +411,10 @@ fn legacy( // ever be a multi-codepoint sequence that triggers this. if (it.nextCodepoint() != null) break :modify_other; + // The mods we encode for this are just the binding mods (shift, ctrl, + // super, alt). + const mods = event.mods.binding(); + // This copies xterm's `ModifyOtherKeys` function that returns // whether modify other keys should be encoded for the given // input. @@ -420,7 +424,7 @@ fn legacy( break :should_modify true; // If we have anything other than shift pressed, encode. - var mods_no_shift = binding_mods; + var mods_no_shift = mods; mods_no_shift.shift = false; if (!mods_no_shift.empty()) break :should_modify true; @@ -435,7 +439,7 @@ fn legacy( if (should_modify) { for (function_keys.modifiers, 2..) |modset, code| { - if (!binding_mods.equal(modset)) continue; + if (!mods.equal(modset)) continue; return try writer.print( "\x1B[27;{};{}~", .{ code, codepoint }, @@ -1970,6 +1974,20 @@ test "legacy: ctrl+shift+char with modify other state 2" { try testing.expectEqualStrings("\x1b[27;6;72~", writer.buffered()); } +test "legacy: ctrl+shift+char with modify other state 2 and consumed mods" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .key_h, + .mods = .{ .ctrl = true, .shift = true }, + .consumed_mods = .{ .shift = true }, + .utf8 = "H", + }, .{ + .modify_other_keys_state_2 = true, + }); + try testing.expectEqualStrings("\x1b[27;6;72~", writer.buffered()); +} + test "legacy: fixterm awkward letters" { var buf: [128]u8 = undefined; { From 3548acfac63e7674b5e25896f6b393474fe8ea65 Mon Sep 17 00:00:00 2001 From: Jared Gizersky <77700596+jaredgizersky@users.noreply.github.com> Date: Mon, 20 Oct 2025 23:27:04 -0400 Subject: [PATCH 157/702] os: handle nil languageCode/countryCode in setLangFromCocoa (#9290) Fixes a crash when NSLocale returns nil for languageCode or countryCode properties. This can happen when the app launches without locale environment variables set. The crash occurs at `src/os/locale.zig:87-88` when trying to call `getProperty()` on a nil object. The fix adds a null check and falls back to `en_US.UTF-8` instead of dereferencing null. ## Testing Tested by running with locale variables unset: ```bash unset LC_ALL && ./zig-out/Ghostty.app/Contents/MacOS/ghostty ``` Before: segmentation fault After: launches successfully with fallback locale --- src/os/locale.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/os/locale.zig b/src/os/locale.zig index b391d690f..92a63741f 100644 --- a/src/os/locale.zig +++ b/src/os/locale.zig @@ -83,6 +83,11 @@ fn setLangFromCocoa() void { const lang = locale.getProperty(objc.Object, "languageCode"); const country = locale.getProperty(objc.Object, "countryCode"); + if (lang.value == null or country.value == null) { + log.warn("languageCode or countryCode not found. Locale may be incorrect.", .{}); + return; + } + // Get our UTF8 string values const c_lang = lang.getProperty([*:0]const u8, "UTF8String"); const c_country = country.getProperty([*:0]const u8, "UTF8String"); From 9dc2e5978f6ee69f4d784b61865589b502c4012d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 21 Oct 2025 20:55:54 -0700 Subject: [PATCH 158/702] lib-vt: enable freestanding wasm builds (#9301) This makes `libghostty-vt` build for freestanding wasm targets (aka a browser) and produce a `ghostty-vt.wasm` file. This exports the same C API that libghostty-vt does. This commit specifically makes the changes necessary for the build to build properly and for us to run the build in CI. We don't yet actually try using it... --- .github/workflows/test.yml | 1 + build.zig | 17 +++++-- src/build/Config.zig | 8 +++- src/build/GhosttyLibVt.zig | 43 ++++++++++++++--- src/lib/allocator.zig | 6 +++ src/lib_vt.zig | 23 +++++++++ src/os/wasm.zig | 90 ----------------------------------- src/os/wasm/log.zig | 9 ++-- src/os/wasm/target.zig | 2 +- src/terminal/c/key_encode.zig | 4 +- 10 files changed, 94 insertions(+), 109 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6520a9ed5..ef03c5f32 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -204,6 +204,7 @@ jobs: aarch64-linux, x86_64-linux, x86_64-windows, + wasm32-freestanding, ] runs-on: namespace-profile-ghostty-sm needs: test diff --git a/build.zig b/build.zig index 7836b5c0d..68dc0028b 100644 --- a/build.zig +++ b/build.zig @@ -101,10 +101,19 @@ pub fn build(b: *std.Build) !void { ); // libghostty-vt - const libghostty_vt_shared = try buildpkg.GhosttyLibVt.initShared( - b, - &mod, - ); + const libghostty_vt_shared = shared: { + if (config.target.result.cpu.arch.isWasm()) { + break :shared try buildpkg.GhosttyLibVt.initWasm( + b, + &mod, + ); + } + + break :shared try buildpkg.GhosttyLibVt.initShared( + b, + &mod, + ); + }; libghostty_vt_shared.install(libvt_step); libghostty_vt_shared.install(b.getInstallStep()); diff --git a/src/build/Config.zig b/src/build/Config.zig index 124cf7299..e88213d71 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -173,7 +173,13 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { bool, "simd", "Build with SIMD-accelerated code paths. Results in significant performance improvements.", - ) orelse true; + ) orelse simd: { + // We can't build our SIMD dependencies for Wasm. Note that we may + // still use SIMD features in the Wasm-builds. + if (target.result.cpu.arch.isWasm()) break :simd false; + + break :simd true; + }; config.wayland = b.option( bool, diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 590792ef3..d1ab5d1ba 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -1,6 +1,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"); @@ -17,7 +18,35 @@ artifact: *std.Build.Step.InstallArtifact, /// The final library file output: std.Build.LazyPath, dsym: ?std.Build.LazyPath, -pkg_config: std.Build.LazyPath, +pkg_config: ?std.Build.LazyPath, + +pub fn initWasm( + b: *std.Build, + zig: *const GhosttyZig, +) !GhosttyLibVt { + const target = zig.vt.resolved_target.?; + assert(target.result.cpu.arch.isWasm()); + + const exe = b.addExecutable(.{ + .name = "ghostty-vt", + .root_module = zig.vt_c, + .version = std.SemanticVersion{ .major = 0, .minor = 1, .patch = 0 }, + }); + + // Allow exported symbols to actually be exported. + exe.rdynamic = true; + + // There is no entrypoint for this wasm module. + exe.entry = .disabled; + + return .{ + .step = &exe.step, + .artifact = b.addInstallArtifact(exe, .{}), + .output = exe.getEmittedBin(), + .dsym = null, + .pkg_config = null, + }; +} pub fn initShared( b: *std.Build, @@ -82,9 +111,11 @@ pub fn install( ) void { const b = step.owner; step.dependOn(&self.artifact.step); - step.dependOn(&b.addInstallFileWithDir( - self.pkg_config, - .prefix, - "share/pkgconfig/libghostty-vt.pc", - ).step); + if (self.pkg_config) |pkg_config| { + step.dependOn(&b.addInstallFileWithDir( + pkg_config, + .prefix, + "share/pkgconfig/libghostty-vt.pc", + ).step); + } } diff --git a/src/lib/allocator.zig b/src/lib/allocator.zig index bcd7f9dcc..ccea7ae29 100644 --- a/src/lib/allocator.zig +++ b/src/lib/allocator.zig @@ -20,11 +20,17 @@ pub fn default(c_alloc_: ?*const Allocator) std.mem.Allocator { // If we're given an allocator, use it. if (c_alloc_) |c_alloc| return c_alloc.zig(); + // Tests always use the test allocator so we can detect leaks. + if (comptime builtin.is_test) return testing.allocator; + // If we have libc, use that. We prefer libc if we have it because // its generally fast but also lets the embedder easily override // malloc/free with custom allocators like mimalloc or something. if (comptime builtin.link_libc) return std.heap.c_allocator; + // Wasm + if (comptime builtin.target.cpu.arch.isWasm()) return std.heap.wasm_allocator; + // No libc, use the preferred allocator for releases which is the // Zig SMP allocator. return std.heap.smp_allocator; diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1df8330ea..322f391ab 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -9,6 +9,9 @@ //! this in the future. const lib = @This(); +const std = @import("std"); +const builtin = @import("builtin"); + // The public API below reproduces a lot of terminal/main.zig but // is separate because (1) we need our root file to be in `src/` // so we can access other directories and (2) we may want to withhold @@ -126,6 +129,26 @@ comptime { } } +pub const std_options: std.Options = options: { + if (builtin.target.cpu.arch.isWasm()) break :options .{ + // Wasm builds we specifically want to optimize for space with small + // releases so we bump up to warn. Everything else acts pretty normal. + .log_level = switch (builtin.mode) { + .Debug => .debug, + .ReleaseSmall => .warn, + else => .info, + }, + + // Wasm doesn't have access to stdio so we have a custom log function. + .logFn = @import("os/wasm/log.zig").log, + }; + + // For everything else we currently use defaults. Longer term I'm + // SURE this isn't right (e.g. we definitely want to customize the log + // function for the C lib at least). + break :options .{}; +}; + test { _ = terminal; _ = @import("lib/main.zig"); diff --git a/src/os/wasm.zig b/src/os/wasm.zig index 73a5922cf..3d0b90e9a 100644 --- a/src/os/wasm.zig +++ b/src/os/wasm.zig @@ -23,93 +23,3 @@ pub const alloc = if (builtin.is_test) std.testing.allocator else std.heap.wasm_allocator; - -/// For host-owned allocations: -/// We need to keep track of our own pointer lengths because Zig -/// allocators usually don't do this and we need to be able to send -/// a direct pointer back to the host system. A more appropriate thing -/// to do would be to probably make a custom allocator that keeps track -/// of size. -var allocs: std.AutoHashMapUnmanaged([*]u8, usize) = .{}; - -/// Allocate len bytes and return a pointer to the memory in the host. -/// The data is not zeroed. -pub export fn malloc(len: usize) ?[*]u8 { - return alloc_(len) catch return null; -} - -fn alloc_(len: usize) ![*]u8 { - // Create the allocation - const slice = try alloc.alloc(u8, len); - errdefer alloc.free(slice); - - // Store the size so we can deallocate later - try allocs.putNoClobber(alloc, slice.ptr, slice.len); - errdefer _ = allocs.remove(slice.ptr); - - return slice.ptr; -} - -/// Free an allocation from malloc. -pub export fn free(ptr: ?[*]u8) void { - if (ptr) |v| { - if (allocs.get(v)) |len| { - const slice = v[0..len]; - alloc.free(slice); - _ = allocs.remove(v); - } - } -} - -/// Convert an allocated pointer of any type to a host-owned pointer. -/// This pushes the responsibility to free it to the host. The returned -/// pointer will match the pointer but is typed correctly for returning -/// to the host. -pub fn toHostOwned(ptr: anytype) ![*]u8 { - // Convert our pointer to a byte array - const info = @typeInfo(@TypeOf(ptr)).pointer; - const T = info.child; - const size = @sizeOf(T); - const casted = @as([*]u8, @ptrFromInt(@intFromPtr(ptr))); - - // Store the information about it - try allocs.putNoClobber(alloc, casted, size); - errdefer _ = allocs.remove(casted); - - return casted; -} - -/// Returns true if the value is host owned. -pub fn isHostOwned(ptr: anytype) bool { - const casted = @as([*]u8, @ptrFromInt(@intFromPtr(ptr))); - return allocs.contains(casted); -} - -/// Convert a pointer back to a module-owned value. The caller is expected -/// to cast or have the valid pointer for alloc calls. -pub fn toModuleOwned(ptr: anytype) void { - const casted = @as([*]u8, @ptrFromInt(@intFromPtr(ptr))); - _ = allocs.remove(casted); -} - -test "basics" { - const testing = std.testing; - const buf = malloc(32).?; - try testing.expect(allocs.size == 1); - free(buf); - try testing.expect(allocs.size == 0); -} - -test "toHostOwned" { - const testing = std.testing; - - const Point = struct { x: u32 = 0, y: u32 = 0 }; - const p = try alloc.create(Point); - errdefer alloc.destroy(p); - const ptr = try toHostOwned(p); - try testing.expect(allocs.size == 1); - try testing.expect(isHostOwned(p)); - try testing.expect(isHostOwned(ptr)); - free(ptr); - try testing.expect(allocs.size == 0); -} diff --git a/src/os/wasm/log.zig b/src/os/wasm/log.zig index d81571229..1aac8c4e7 100644 --- a/src/os/wasm/log.zig +++ b/src/os/wasm/log.zig @@ -1,15 +1,12 @@ const std = @import("std"); const builtin = @import("builtin"); const wasm = @import("../wasm.zig"); -const wasm_target = @import("target.zig"); // Use the correct implementation -pub const log = if (wasm_target.target) |target| switch (target) { - .browser => Browser.log, -} else @compileError("wasm target required"); +pub const log = Freestanding.log; -/// Browser implementation calls an extern "log" function. -pub const Browser = struct { +/// Freestanding implementation calls an extern "log" function. +pub const Freestanding = struct { // The function std.log will call. pub fn log( comptime level: std.log.Level, diff --git a/src/os/wasm/target.zig b/src/os/wasm/target.zig index cd8b2dd33..a6a29e208 100644 --- a/src/os/wasm/target.zig +++ b/src/os/wasm/target.zig @@ -10,7 +10,7 @@ pub const Target = enum { }; /// Our specific target platform. -pub const target: ?Target = if (!builtin.target.isWasm()) null else target: { +pub const target: ?Target = if (!builtin.target.cpu.arch.isWasm()) null else target: { const result = @as(Target, @enumFromInt(@intFromEnum(options.wasm_target))); // This maybe isn't necessary but I don't know if enums without a specific // tag type and value are guaranteed to be the same between build.zig diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig index 96754d884..f5f6ff054 100644 --- a/src/terminal/c/key_encode.zig +++ b/src/terminal/c/key_encode.zig @@ -123,7 +123,9 @@ pub fn encode( encoder_.?.opts, ) catch unreachable; - out_written.* = discarding.count; + // Discarding always uses a u64. If we're on 32-bit systems + // we cast down. We should make this safer in the future. + out_written.* = @intCast(discarding.count); return .out_of_memory; }, }; From c133fac7e7f5d7306b151b2e6f8bc484f23b5508 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 22 Oct 2025 14:25:52 -0700 Subject: [PATCH 159/702] lib-vt: wasm convenience functions and a simple example (#9309) This adds a set of Wasm convenience functions to ease memory management. These are all prefixed with `ghostty_wasm` and are documented as part of the standard Doxygen docs. I also added a very simple single-page HTML example that demonstrates how to use the Wasm module for key encoding. This also adds a bunch of safety checks to the C API to verify that valid values are actually passed to the function. This is an easy to hit bug. **AI disclosure:** The example is AI-written with Amp. I read through all the code and understand it but I can't claim there isn't a better way, I'm far from a JS expert. It is simple and works currently though. Happy to see improvements if anyone wants to contribute. --- AGENTS.md | 1 + Doxyfile | 10 + example/wasm-key-encode/README.md | 76 ++++ example/wasm-key-encode/index.html | 687 +++++++++++++++++++++++++++++ include/ghostty/vt.h | 2 + include/ghostty/vt/wasm.h | 141 ++++++ src/input/key_encode.zig | 2 +- src/lib/allocator.zig | 3 + src/lib/allocator/convenience.zig | 50 +++ src/lib_vt.zig | 13 + src/terminal/c/key_encode.zig | 19 +- src/terminal/c/key_event.zig | 16 + src/terminal/c/osc.zig | 9 + 13 files changed, 1027 insertions(+), 2 deletions(-) create mode 100644 example/wasm-key-encode/README.md create mode 100644 example/wasm-key-encode/index.html create mode 100644 include/ghostty/vt/wasm.h create mode 100644 src/lib/allocator/convenience.zig diff --git a/AGENTS.md b/AGENTS.md index 5a885923e..a3e752816 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ A file for [guiding coding agents](https://agents.md/). ## libghostty-vt - Build: `zig build lib-vt` +- Build Wasm Module: `zig build lib-vt -Dtarget=wasm32-freestanding` - Test: `zig build test-lib-vt` - Test filter: `zig build test-lib-vt -Dtest-filter=` - When working on libghostty-vt, do not build the full app. diff --git a/Doxyfile b/Doxyfile index 63e73334d..1703e6fac 100644 --- a/Doxyfile +++ b/Doxyfile @@ -17,6 +17,16 @@ INLINE_SOURCES = NO REFERENCES_RELATION = YES REFERENCED_BY_RELATION = YES +#--------------------------------------------------------------------------- +# Preprocessor +#--------------------------------------------------------------------------- + +# Enable preprocessing to handle #ifdef guards +ENABLE_PREPROCESSING = YES +MACRO_EXPANSION = YES +EXPAND_ONLY_PREDEF = YES +PREDEFINED = __wasm__ + #--------------------------------------------------------------------------- # C API Optimization #--------------------------------------------------------------------------- diff --git a/example/wasm-key-encode/README.md b/example/wasm-key-encode/README.md new file mode 100644 index 000000000..ec8332e68 --- /dev/null +++ b/example/wasm-key-encode/README.md @@ -0,0 +1,76 @@ +# WebAssembly Key Encoder Example + +This example demonstrates how to use the Ghostty VT library from WebAssembly to encode key events into terminal escape sequences. + +## What It Does + +The example demonstrates using the Ghostty VT library from WebAssembly to encode key events: + +1. Loads the `ghostty-vt.wasm` module +2. Creates a key encoder with Kitty keyboard protocol support +3. Creates a key event for left ctrl release +4. Queries the required buffer size (optional) +5. Encodes the event into a terminal escape sequence +6. Displays the result in both hexadecimal and string format + +## Building + +First, build the WebAssembly module: + +```bash +zig build lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +``` + +This will create `zig-out/bin/ghostty-vt.wasm`. + +## Running + +**Important:** You must serve this via HTTP, not open it as a file directly. Browsers block loading WASM files from `file://` URLs. + +From the **root of the ghostty repository**, serve with a local HTTP server: + +```bash +# Using Python (recommended) +python3 -m http.server 8000 + +# Or using Node.js +npx serve . + +# Or using PHP +php -S localhost:8000 +``` + +Then open your browser to: + +``` +http://localhost:8000/example/wasm-key-encode/ +``` + +Click "Run Example" to see the key encoding in action. + +## Expected Output + +``` +Encoding event: left ctrl release with all Kitty flags enabled +Required buffer size: 12 bytes +Encoded 12 bytes +Hex: 1b 5b 35 37 3a 33 3b 32 3a 33 75 +String: \x1b[57:3;2:3u +``` + +## Notes + +- The example uses the convenience allocator functions exported by the wasm module +- Error handling is included to demonstrate proper usage patterns +- The encoded sequence `\x1b[57:3;2:3u` is a Kitty keyboard protocol sequence for left ctrl release with all features enabled +- The `env.log` function must be provided by the host environment for logging support + +## Current Limitations + +The current C API is verbose when called from WebAssembly because: + +- Functions use output pointers requiring manual memory allocation in JavaScript +- Options must be set via pointers to values +- Buffer sizes require pointer parameters + +See `WASM_API_PLAN.md` for proposed improvements to make the API more wasm-friendly. diff --git a/example/wasm-key-encode/index.html b/example/wasm-key-encode/index.html new file mode 100644 index 000000000..714988b4d --- /dev/null +++ b/example/wasm-key-encode/index.html @@ -0,0 +1,687 @@ + + + + + + Ghostty VT Key Encoder - WebAssembly Example + + + +

Ghostty VT Key Encoder - WebAssembly Example

+

This example demonstrates encoding key events into terminal escape sequences using the Ghostty VT WebAssembly module.

+ +
+ ⚠️ Warning: + This is an example of the libghostty-vt WebAssembly API. The JavaScript + keyboard event mapping to the libghostty-vt API may not be perfect + and may result in encoding inaccuracies for certain keys or layouts. + Do not use this as a key encoding reference. +
+ +
Loading WebAssembly module...
+ +
+

Key Action

+
+ + + +
+
+ +
+

Kitty Keyboard Protocol Flags

+
+ + + + + +
+
+ + + +
Waiting for key events...
+ +

Note: This example must be served via HTTP (not opened directly as a file). See the README for instructions.

+ + + + diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index cd357f0fa..e6d922009 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -32,6 +32,7 @@ * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref paste "Paste Utilities" - Validate paste data safety * - @ref allocator "Memory Management" - Memory management and custom allocators + * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions * * @section examples_sec Examples * @@ -69,6 +70,7 @@ extern "C" { #include #include #include +#include #ifdef __cplusplus } diff --git a/include/ghostty/vt/wasm.h b/include/ghostty/vt/wasm.h new file mode 100644 index 000000000..7edee529f --- /dev/null +++ b/include/ghostty/vt/wasm.h @@ -0,0 +1,141 @@ +/** + * @file wasm.h + * + * WebAssembly utility functions for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_WASM_H +#define GHOSTTY_VT_WASM_H + +#ifdef __wasm__ + +#include +#include + +/** @defgroup wasm WebAssembly Utilities + * + * Convenience functions for allocating various types in WebAssembly builds. + * **These are only available the libghostty-vt wasm module.** + * + * Ghostty relies on pointers to various types for ABI compatibility, and + * creating those pointers in Wasm can be tedious. These functions provide + * a purely additive set of utilities that simplify memory management in + * Wasm environments without changing the core C library API. + * + * @note These functions always use the default allocator. If you need + * custom allocation strategies, you should allocate types manually using + * your custom allocator. This is a very rare use case in the WebAssembly + * world so these are optimized for simplicity. + * + * ## Example Usage + * + * Here's a simple example of using the Wasm utilities with the key encoder: + * + * @code + * const { exports } = wasmInstance; + * const view = new DataView(wasmMemory.buffer); + * + * // Create key encoder + * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); + * exports.ghostty_key_encoder_new(null, encoderPtr); + * const encoder = view.getUint32(encoder, true); + * + * // Configure encoder with Kitty protocol flags + * const flagsPtr = exports.ghostty_wasm_alloc_u8(); + * view.setUint8(flagsPtr, 0x1F); + * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); + * + * // Allocate output buffer and size pointer + * const bufferSize = 32; + * const bufPtr = exports.ghostty_wasm_alloc_buffer(bufferSize); + * const writtenPtr = exports.ghostty_wasm_alloc_usize(); + * + * // Encode the key event + * exports.ghostty_key_encoder_encode( + * encoder, eventPtr, bufPtr, bufferSize, writtenPtr + * ); + * + * // Read encoded output + * const bytesWritten = view.getUint32(writtenPtr, true); + * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); + * @endcode + * + * @remark The code above is pretty ugly! This is the lowest level interface + * to the libghostty-vt Wasm module. In practice, this should be wrapped + * in a higher-level API that abstracts away all this. + * + * @{ + */ + +/** + * Allocate an opaque pointer. This can be used for any opaque pointer + * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. + * + * @return Pointer to allocated opaque pointer, or NULL if allocation failed + * @ingroup wasm + */ +void** ghostty_wasm_alloc_opaque(void); + +/** + * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +void ghostty_wasm_free_opaque(void **ptr); + +/** + * Allocate a buffer of the specified length. + * + * @param len Number of bytes to allocate + * @return Pointer to allocated buffer, or NULL if allocation failed + * @ingroup wasm + */ +uint8_t* ghostty_wasm_alloc_buffer(size_t len); + +/** + * Free a buffer allocated by ghostty_wasm_alloc_buffer(). + * + * @param ptr Pointer to the buffer to free, or NULL (NULL is safely ignored) + * @param len Length of the buffer (must match the length passed to alloc) + * @ingroup wasm + */ +void ghostty_wasm_free_buffer(uint8_t *ptr, size_t len); + +/** + * Allocate a single uint8_t value. + * + * @return Pointer to allocated uint8_t, or NULL if allocation failed + * @ingroup wasm + */ +uint8_t* ghostty_wasm_alloc_u8(void); + +/** + * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +void ghostty_wasm_free_u8(uint8_t *ptr); + +/** + * Allocate a single size_t value. + * + * @return Pointer to allocated size_t, or NULL if allocation failed + * @ingroup wasm + */ +size_t* ghostty_wasm_alloc_usize(void); + +/** + * Free a size_t allocated by ghostty_wasm_alloc_usize(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +void ghostty_wasm_free_usize(size_t *ptr); + +/** @} */ + +#endif /* __wasm__ */ + +#endif /* GHOSTTY_VT_WASM_H */ diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index f411deb19..4d6655600 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -77,7 +77,7 @@ pub fn encode( event: key.KeyEvent, opts: Options, ) std.Io.Writer.Error!void { - //std.log.warn("KEYENCODER event={} opts={}", .{ event, opts }); + std.log.warn("KEYENCODER event={} opts={}", .{ event, opts }); return if (opts.kitty_flags.int() != 0) try kitty( writer, event, diff --git a/src/lib/allocator.zig b/src/lib/allocator.zig index ccea7ae29..fc56033a2 100644 --- a/src/lib/allocator.zig +++ b/src/lib/allocator.zig @@ -2,6 +2,9 @@ const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; +/// Convenience functions +pub const convenience = @import("allocator/convenience.zig"); + /// Useful alias since they're required to create Zig allocators pub const ZigVTable = std.mem.Allocator.VTable; diff --git a/src/lib/allocator/convenience.zig b/src/lib/allocator/convenience.zig new file mode 100644 index 000000000..19543ad0e --- /dev/null +++ b/src/lib/allocator/convenience.zig @@ -0,0 +1,50 @@ +//! This contains convenience functions for allocating various types. +//! +//! The primary use case for this is Wasm builds. Ghostty relies a lot on +//! pointers to various types for ABI compatibility and creating those pointers +//! in Wasm is tedious. This file contains a purely additive set of functions +//! that can be exposed to the Wasm module without changing the API from the +//! C library. +//! +//! Given these are convenience methods, they always use the default allocator. +//! If a caller is using a custom allocator, they have the expertise to +//! allocate these types manually using their custom allocator. + +// Get our default allocator at comptime since it is known. +const default = @import("../allocator.zig").default; +const alloc = default(null); + +pub const Opaque = *anyopaque; + +pub fn allocOpaque() callconv(.c) ?*Opaque { + return alloc.create(*anyopaque) catch return null; +} + +pub fn freeOpaque(ptr: ?*Opaque) callconv(.c) void { + if (ptr) |p| alloc.destroy(p); +} + +pub fn allocBuffer(len: usize) callconv(.c) ?[*]u8 { + const slice = alloc.alloc(u8, len) catch return null; + return slice.ptr; +} + +pub fn freeBuffer(ptr: ?[*]u8, len: usize) callconv(.c) void { + if (ptr) |p| alloc.free(p[0..len]); +} + +pub fn allocU8() callconv(.c) ?*u8 { + return alloc.create(u8) catch return null; +} + +pub fn freeU8(ptr: ?*u8) callconv(.c) void { + if (ptr) |p| alloc.destroy(p); +} + +pub fn allocUsize() callconv(.c) ?*usize { + return alloc.create(usize) catch return null; +} + +pub fn freeUsize(ptr: ?*usize) callconv(.c) void { + if (ptr) |p| alloc.destroy(p); +} diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 322f391ab..c66e5ab39 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -126,6 +126,19 @@ comptime { @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); + + // On Wasm we need to export our allocator convenience functions. + if (builtin.target.cpu.arch.isWasm()) { + const alloc = @import("lib/allocator/convenience.zig"); + @export(&alloc.allocOpaque, .{ .name = "ghostty_wasm_alloc_opaque" }); + @export(&alloc.freeOpaque, .{ .name = "ghostty_wasm_free_opaque" }); + @export(&alloc.allocBuffer, .{ .name = "ghostty_wasm_alloc_buffer" }); + @export(&alloc.freeBuffer, .{ .name = "ghostty_wasm_free_buffer" }); + @export(&alloc.allocU8, .{ .name = "ghostty_wasm_alloc_u8" }); + @export(&alloc.freeU8, .{ .name = "ghostty_wasm_free_u8" }); + @export(&alloc.allocUsize, .{ .name = "ghostty_wasm_alloc_usize" }); + @export(&alloc.freeUsize, .{ .name = "ghostty_wasm_free_usize" }); + } } } diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig index f5f6ff054..47bd904e0 100644 --- a/src/terminal/c/key_encode.zig +++ b/src/terminal/c/key_encode.zig @@ -10,6 +10,8 @@ const OptionAsAlt = @import("../../input/config.zig").OptionAsAlt; const Result = @import("result.zig").Result; const KeyEvent = @import("key_event.zig").Event; +const log = std.log.scoped(.key_encode); + /// Wrapper around key encoding options that tracks the allocator for C API usage. const KeyEncoderWrapper = struct { opts: key_encode.Options, @@ -70,6 +72,13 @@ pub fn setopt( option: Option, value: ?*const anyopaque, ) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("setopt invalid option value={d}", .{@intFromEnum(option)}); + return; + }; + } + return switch (option) { inline else => |comptime_option| setoptTyped( encoder_, @@ -95,7 +104,15 @@ fn setoptTyped( const bits: u5 = @truncate(value.*); break :flags @bitCast(bits); }, - .macos_option_as_alt => opts.macos_option_as_alt = value.*, + .macos_option_as_alt => { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(OptionAsAlt, @intFromEnum(value.*)) catch { + log.warn("setopt invalid OptionAsAlt value={d}", .{@intFromEnum(value.*)}); + return; + }; + } + opts.macos_option_as_alt = value.*; + }, } } diff --git a/src/terminal/c/key_event.zig b/src/terminal/c/key_event.zig index 5befe4384..b52932fdd 100644 --- a/src/terminal/c/key_event.zig +++ b/src/terminal/c/key_event.zig @@ -6,6 +6,8 @@ const CAllocator = lib_alloc.Allocator; const key = @import("../../input/key.zig"); const Result = @import("result.zig").Result; +const log = std.log.scoped(.key_event); + /// Wrapper around KeyEvent that tracks the allocator for C API usage. /// The UTF-8 text is not owned by this wrapper - the caller is responsible /// for ensuring the lifetime of any UTF-8 text set via set_utf8. @@ -36,6 +38,13 @@ pub fn free(event_: Event) callconv(.c) void { } pub fn set_action(event_: Event, action: key.Action) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(key.Action, @intFromEnum(action)) catch { + log.warn("set_action invalid action value={d}", .{@intFromEnum(action)}); + return; + }; + } + const event: *key.KeyEvent = &event_.?.event; event.action = action; } @@ -46,6 +55,13 @@ pub fn get_action(event_: Event) callconv(.c) key.Action { } pub fn set_key(event_: Event, k: key.Key) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(key.Key, @intFromEnum(k)) catch { + log.warn("set_key invalid key value={d}", .{@intFromEnum(k)}); + return; + }; + } + const event: *key.KeyEvent = &event_.?.event; event.key = k; } diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 1311eaff8..124fc3b7c 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -6,6 +6,8 @@ const CAllocator = lib_alloc.Allocator; const osc = @import("../osc.zig"); const Result = @import("result.zig").Result; +const log = std.log.scoped(.osc); + /// C: GhosttyOscParser pub const Parser = ?*osc.Parser; @@ -68,6 +70,13 @@ pub fn commandData( data: CommandData, out: ?*anyopaque, ) callconv(.c) bool { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(CommandData, @intFromEnum(data)) catch { + log.warn("commandData invalid data value={d}", .{@intFromEnum(data)}); + return false; + }; + } + return switch (data) { inline else => |comptime_data| commandDataTyped( command_, From bdbda2fd8380b440508246bcda0bf1198f6666e4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 22 Oct 2025 15:36:18 -0700 Subject: [PATCH 160/702] input: accidentally merged a loud log line --- src/input/key_encode.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index 4d6655600..f411deb19 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -77,7 +77,7 @@ pub fn encode( event: key.KeyEvent, opts: Options, ) std.Io.Writer.Error!void { - std.log.warn("KEYENCODER event={} opts={}", .{ event, opts }); + //std.log.warn("KEYENCODER event={} opts={}", .{ event, opts }); return if (opts.kitty_flags.int() != 0) try kitty( writer, event, From b764055c3393d26f6c5f1ec373b53c438bcad939 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 22 Oct 2025 16:14:28 -0700 Subject: [PATCH 161/702] macos: window-position-x/y works with window-width/height (#9313) Fixes #9132 We were processing our window size defaults separate from our window position and the result was that you'd get some incorrect behavior. Unify the logic more to fix the positioning. Note there is room to improve this further, I think that all initial positioning could go into the controller completely. But I wanted to minimize the diff for a backport. --- .../Terminal/TerminalController.swift | 33 +++++++++++++++++-- .../Window Styles/TerminalWindow.swift | 24 +++++--------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 9790063d7..08bdac2ad 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -531,7 +531,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr 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 frame + return adjustForWindowPosition(frame: frame, on: screen) } guard let initialFrame else { return nil } @@ -549,7 +549,30 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr 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 frame + 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 } + guard let y = derivedConfig.windowPositionY else { return frame } + + // Convert top-left coordinates to bottom-left origin using our utility extension + let origin = screen.origin( + fromTopLeftOffsetX: CGFloat(x), + offsetY: CGFloat(y), + windowSize: frame.size) + + // Clamp the origin to ensure the window stays fully visible on screen + var safeOrigin = origin + let vf = screen.visibleFrame + safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width) + safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) + + // Return our new origin + var result = frame + result.origin = safeOrigin + return result } /// This is called anytime a node in the surface tree is being removed. @@ -1362,12 +1385,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let macosWindowButtons: Ghostty.MacOSWindowButtons let macosTitlebarStyle: String let maximize: Bool + let windowPositionX: Int16? + let windowPositionY: Int16? init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.macosWindowButtons = .visible self.macosTitlebarStyle = "system" self.maximize = false + self.windowPositionX = nil + self.windowPositionY = nil } init(_ config: Ghostty.Config) { @@ -1375,6 +1402,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr self.macosWindowButtons = config.macosWindowButtons self.macosTitlebarStyle = config.macosTitlebarStyle self.maximize = config.maximize + self.windowPositionX = config.windowPositionX + self.windowPositionY = config.windowPositionY } } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 16fcf227f..c33073a45 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -84,8 +84,7 @@ class TerminalWindow: NSWindow { // fallback to original centering behavior setInitialWindowPosition( x: config.windowPositionX, - y: config.windowPositionY, - windowDecorations: config.windowDecorations) + y: config.windowPositionY) // If our traffic buttons should be hidden, then hide them if config.macosWindowButtons == .hidden { @@ -463,7 +462,7 @@ class TerminalWindow: NSWindow { return derivedConfig.backgroundColor.withAlphaComponent(alpha) } - private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { + private func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. guard let x, let y else { if (!LastWindowPosition.shared.restore(self)) { @@ -479,19 +478,14 @@ class TerminalWindow: NSWindow { return } - // Convert top-left coordinates to bottom-left origin using our utility extension - let origin = screen.origin( - fromTopLeftOffsetX: CGFloat(x), - offsetY: CGFloat(y), - windowSize: frame.size) + // We have an X/Y, use our controller function to set it up. + guard let terminalController else { + center() + return + } - // Clamp the origin to ensure the window stays fully visible on screen - var safeOrigin = origin - let vf = screen.visibleFrame - safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width) - safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) - - setFrameOrigin(safeOrigin) + let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen) + setFrameOrigin(frame.origin) } private func hideWindowButtons() { From e2fe0cf53ab3fced976c794f823cff8e33282d1f Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Thu, 23 Oct 2025 08:33:39 -0700 Subject: [PATCH 162/702] macOS: remove scroll edge styling with hidden titlebar (#9317) With `macos-titlebar-style = hidden`, creating splits or cycling fullscreen sometimes produces a transparent overlay in the titlebar area, clipping the top of the surfaces: Screenshot 2025-10-22 at 21 27 28 This is actually SwiftUI styling for scroll views, and the fact that it pops up even though the titlebar is hidden is possibly a SwiftUI bug; at least it's causing frustration for others too, see https://developer.apple.com/forums/thread/798392 and https://stackoverflow.com/questions/79776037/strange-nsscrollpocket-height-on-my-nstableview-in-fullscreen-mode-on-macos-taho. I tried setting `.scrollEdgeEffectHidden()` on various nodes in the SwiftUI hierarchy, but couldn't get it to work, so I ended up resorting to an old-fashioned game of imperative whack-a-mole. Now: Screenshot 2025-10-22 at 21 28 47 AI disclosure (my first!): I consulted copilot trying to figure out of the whole SwiftUI/AppKit situation and whether there might be a declarative solution on the SwiftUI side. Just chatting in general terms without showing real-world code. No dice. --- macos/Sources/Ghostty/SurfaceScrollView.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index b1e1b9baf..08d249c4e 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -133,6 +133,20 @@ class SurfaceScrollView: NSView { override func layout() { super.layout() + + // The SwiftUI ScrollView host likes to add its own styling overlays to + // the titlebar area, which are incompatible with the hidden titlebar + // style. They won't be present when the app is first opened, but will + // appear when creating splits or cycling fullscreen. There's no public + // way to disable them in AppKit, so we just have to play whack-a-mole. + // See https://developer.apple.com/forums/thread/798392. + if window is HiddenTitlebarTerminalWindow { + for view in scrollView.subviews { + if view.className.contains("NSScrollPocket") { + view.removeFromSuperview() + } + } + } // Fill entire bounds with scroll view scrollView.frame = bounds From 5c574e7745c0c788afb1de36ac9db6914196d40e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 09:22:35 -0700 Subject: [PATCH 163/702] macos: use TextEditor instead of Text for clipboard confirmation (#9324) Fixes #9322 SwiftUI `Text` has huge performance issues. On my maxed out MBP it hangs for any text more than 100KB (it took ~8s to display it!). `TextEditor` with a constant value works much better and handles scrolling for us, too! --- .../ClipboardConfirmationView.swift | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 1a7272e16..086dab793 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -45,19 +45,15 @@ struct ClipboardConfirmationView: View { .font(.system(size: 42)) .padding() .frame(alignment: .center) - + Text(request.text()) .frame(maxWidth: .infinity, alignment: .leading) .padding() } - - ScrollView { - Text(contents) - .textSelection(.enabled) - .font(.system(.body, design: .monospaced)) - .padding(.all, 4) - } - + + TextEditor(text: .constant(contents)) + .font(.system(.body, design: .monospaced)) + HStack { Spacer() Button(Action.text(.cancel, request)) { onCancel() } From 66486901f525c275bc99649d97d0c1f94881d2a4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 12:49:37 -0700 Subject: [PATCH 164/702] examples/wasm-key-encode: update README --- example/wasm-key-encode/README.md | 46 ++++--------------------------- 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/example/wasm-key-encode/README.md b/example/wasm-key-encode/README.md index ec8332e68..ccd906cf7 100644 --- a/example/wasm-key-encode/README.md +++ b/example/wasm-key-encode/README.md @@ -1,17 +1,7 @@ # WebAssembly Key Encoder Example -This example demonstrates how to use the Ghostty VT library from WebAssembly to encode key events into terminal escape sequences. - -## What It Does - -The example demonstrates using the Ghostty VT library from WebAssembly to encode key events: - -1. Loads the `ghostty-vt.wasm` module -2. Creates a key encoder with Kitty keyboard protocol support -3. Creates a key event for left ctrl release -4. Queries the required buffer size (optional) -5. Encodes the event into a terminal escape sequence -6. Displays the result in both hexadecimal and string format +This example demonstrates how to use the Ghostty VT library from WebAssembly +to encode key events into terminal escape sequences. ## Building @@ -25,7 +15,8 @@ This will create `zig-out/bin/ghostty-vt.wasm`. ## Running -**Important:** You must serve this via HTTP, not open it as a file directly. Browsers block loading WASM files from `file://` URLs. +**Important:** You must serve this via HTTP, not open it as a file directly. +Browsers block loading WASM files from `file://` URLs. From the **root of the ghostty repository**, serve with a local HTTP server: @@ -46,31 +37,4 @@ Then open your browser to: http://localhost:8000/example/wasm-key-encode/ ``` -Click "Run Example" to see the key encoding in action. - -## Expected Output - -``` -Encoding event: left ctrl release with all Kitty flags enabled -Required buffer size: 12 bytes -Encoded 12 bytes -Hex: 1b 5b 35 37 3a 33 3b 32 3a 33 75 -String: \x1b[57:3;2:3u -``` - -## Notes - -- The example uses the convenience allocator functions exported by the wasm module -- Error handling is included to demonstrate proper usage patterns -- The encoded sequence `\x1b[57:3;2:3u` is a Kitty keyboard protocol sequence for left ctrl release with all features enabled -- The `env.log` function must be provided by the host environment for logging support - -## Current Limitations - -The current C API is verbose when called from WebAssembly because: - -- Functions use output pointers requiring manual memory allocation in JavaScript -- Options must be set via pointers to values -- Buffer sizes require pointer parameters - -See `WASM_API_PLAN.md` for proposed improvements to make the API more wasm-friendly. +Focus the text input field and press any key combination to see the encoded output. From fb5b8d7968e6d760b53785ba169c751de75ac08d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 12:49:55 -0700 Subject: [PATCH 165/702] input: command palette actions must use formatter, not tag (#9325) Regression from our Zig 0.15 migration. --- src/input/command.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/command.zig b/src/input/command.zig index 0904ef2bb..8216d107a 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -50,7 +50,7 @@ pub const Command = struct { return .{ .action_key = @tagName(self.action), - .action = std.fmt.comptimePrint("{t}", .{self.action}), + .action = std.fmt.comptimePrint("{f}", .{self.action}), .title = self.title, .description = self.description, }; From 099dcbe04dfeee7bbd3808877d6e1cb8d9b0e765 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 14:03:34 -0700 Subject: [PATCH 166/702] lib: add a `TaggedUnion` helper to create C ABI compatible tagged unions --- src/lib/enum.zig | 6 +- src/lib/main.zig | 5 +- src/lib/struct.zig | 31 ++++++++ src/lib/target.zig | 6 ++ src/lib/union.zig | 171 +++++++++++++++++++++++++++++++++++++++++++ src/main_ghostty.zig | 1 + 6 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 src/lib/struct.zig create mode 100644 src/lib/target.zig create mode 100644 src/lib/union.zig diff --git a/src/lib/enum.zig b/src/lib/enum.zig index c3971ebde..6fc759846 100644 --- a/src/lib/enum.zig +++ b/src/lib/enum.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Target = @import("target.zig").Target; /// Create an enum type with the given keys that is C ABI compatible /// if we're targeting C, otherwise a Zig enum with smallest possible @@ -58,11 +59,6 @@ pub fn Enum( return Result; } -pub const Target = union(enum) { - c, - zig, -}; - test "zig" { const testing = std.testing; const T = Enum(.zig, &.{ "a", "b", "c", "d" }); diff --git a/src/lib/main.zig b/src/lib/main.zig index 4ef8dcb2d..cdddade09 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -1,9 +1,12 @@ const std = @import("std"); const enumpkg = @import("enum.zig"); +const unionpkg = @import("union.zig"); pub const allocator = @import("allocator.zig"); pub const Enum = enumpkg.Enum; -pub const EnumTarget = enumpkg.Target; +pub const Struct = @import("struct.zig").Struct; +pub const Target = @import("target.zig").Target; +pub const TaggedUnion = unionpkg.TaggedUnion; test { std.testing.refAllDecls(@This()); diff --git a/src/lib/struct.zig b/src/lib/struct.zig new file mode 100644 index 000000000..d494da2e6 --- /dev/null +++ b/src/lib/struct.zig @@ -0,0 +1,31 @@ +const std = @import("std"); +const Target = @import("target.zig").Target; + +pub fn Struct( + comptime target: Target, + comptime Zig: type, +) type { + return switch (target) { + .zig => Zig, + .c => c: { + const info = @typeInfo(Zig).@"struct"; + var fields: [info.fields.len]std.builtin.Type.StructField = undefined; + for (info.fields, 0..) |field, i| { + fields[i] = .{ + .name = field.name, + .type = field.type, + .default_value_ptr = field.default_value_ptr, + .is_comptime = field.is_comptime, + .alignment = field.alignment, + }; + } + + break :c @Type(.{ .@"struct" = .{ + .layout = .@"extern", + .fields = &fields, + .decls = &.{}, + .is_tuple = info.is_tuple, + } }); + }, + }; +} diff --git a/src/lib/target.zig b/src/lib/target.zig new file mode 100644 index 000000000..8d7a7fb89 --- /dev/null +++ b/src/lib/target.zig @@ -0,0 +1,6 @@ +/// The target for ABI generation. The detection of this is left to the +/// caller since there are multiple ways to do that. +pub const Target = union(enum) { + c, + zig, +}; diff --git a/src/lib/union.zig b/src/lib/union.zig new file mode 100644 index 000000000..f19cd3c7f --- /dev/null +++ b/src/lib/union.zig @@ -0,0 +1,171 @@ +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const Target = @import("target.zig").Target; + +/// Create a tagged union type that supports a C ABI and maintains +/// C ABI compatibility when adding new tags. This returns a set of types +/// and functions to augment the given Union type, not create a wholly new +/// union type. +/// +/// The C ABI compatible types and functions are only available when the +/// target produces C values. +/// +/// The `Union` type should be a standard Zig tagged union. The tag type +/// should be explicit (i.e. not `union(enum)`) and the tag type should +/// be an enum created with the `Enum` function in this library, so that +/// automatic C ABI compatibility is ensured. +/// +/// The `Padding` type is a type that is always added to the C union +/// with the key `_padding`. This should be set to a type that has the size +/// and alignment needed to pad the C union to the expected size. This +/// should never change to ensure ABI compatibility. +pub fn TaggedUnion( + comptime target: Target, + comptime Union: type, + comptime Padding: type, +) type { + return struct { + comptime { + switch (target) { + .zig => {}, + + // For ABI compatibility, we expect that this is our union size. + .c => if (@sizeOf(CValue) != @sizeOf(Padding)) { + @compileLog(@sizeOf(CValue)); + @compileError("TaggedUnion CValue size does not match expected fixed size"); + }, + } + } + + /// The tag type. + pub const Tag = @typeInfo(Union).@"union".tag_type.?; + + /// The Zig union. + pub const Zig = Union; + + /// The C ABI compatible tagged union type. + pub const C = switch (target) { + .zig => struct {}, + .c => extern struct { + tag: Tag, + value: CValue, + }, + }; + + /// The C ABI compatible union value type. + pub const CValue = cvalue: { + switch (target) { + .zig => break :cvalue extern struct {}, + .c => {}, + } + + const tag_fields = @typeInfo(Tag).@"enum".fields; + var union_fields: [tag_fields.len + 1]std.builtin.Type.UnionField = undefined; + for (tag_fields, 0..) |field, i| { + const action = @unionInit(Union, field.name, undefined); + const Type = t: { + const Type = @TypeOf(@field(action, field.name)); + // Types can provide custom types for their CValue. + switch (@typeInfo(Type)) { + .@"enum", .@"struct", .@"union" => if (@hasDecl(Type, "C")) break :t Type.C, + else => {}, + } + + break :t Type; + }; + + union_fields[i] = .{ + .name = field.name, + .type = Type, + .alignment = @alignOf(Type), + }; + } + + union_fields[tag_fields.len] = .{ + .name = "_padding", + .type = Padding, + .alignment = @alignOf(Padding), + }; + + break :cvalue @Type(.{ .@"union" = .{ + .layout = .@"extern", + .tag_type = null, + .fields = &union_fields, + .decls = &.{}, + } }); + }; + + /// Convert to C union. + pub fn cval(self: Union) C { + const value: CValue = switch (self) { + inline else => |v, tag| @unionInit( + CValue, + @tagName(tag), + value: { + switch (@typeInfo(@TypeOf(v))) { + .@"enum", .@"struct", .@"union" => if (@hasDecl(@TypeOf(v), "cval")) v.cval(), + else => {}, + } + + break :value v; + }, + ), + }; + + return .{ + .tag = @as(Tag, self), + .value = value, + }; + } + + /// Returns the value type for the given tag. + pub fn Value(comptime tag: Tag) type { + inline for (@typeInfo(Union).@"union".fields) |field| { + const field_tag = @field(Tag, field.name); + if (field_tag == tag) return field.type; + } + + unreachable; + } + }; +} + +test "TaggedUnion: matching size" { + const Tag = enum(c_int) { a, b }; + const U = TaggedUnion( + .c, + union(Tag) { + a: u32, + b: u64, + }, + u64, + ); + + try testing.expectEqual(8, @sizeOf(U.CValue)); +} + +test "TaggedUnion: padded size" { + const Tag = enum(c_int) { a }; + const U = TaggedUnion( + .c, + union(Tag) { + a: u32, + }, + u64, + ); + + try testing.expectEqual(8, @sizeOf(U.CValue)); +} + +test "TaggedUnion: c conversion" { + const Tag = enum(c_int) { a, b }; + const U = TaggedUnion(.c, union(Tag) { + a: u32, + b: u64, + }, u64); + + const c = U.cval(.{ .a = 42 }); + try testing.expectEqual(Tag.a, c.tag); + try testing.expectEqual(42, c.value.a); +} diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index decfc609c..77b7f3ef4 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -193,6 +193,7 @@ test { _ = @import("crash/main.zig"); _ = @import("datastruct/main.zig"); _ = @import("inspector/main.zig"); + _ = @import("lib/main.zig"); _ = @import("terminal/main.zig"); _ = @import("terminfo/main.zig"); _ = @import("simd/main.zig"); From f7189d14b974ce68fd213cca67897ee6dae2cf8c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 14:03:34 -0700 Subject: [PATCH 167/702] terminal: convert Stream to use Action tagged union --- src/benchmark/TerminalStream.zig | 10 +- src/inspector/termio.zig | 9 + src/terminal/stream.zig | 398 ++++++++++++++++++++++++++++++- src/termio/Termio.zig | 2 +- src/termio/stream_handler.zig | 16 +- 5 files changed, 418 insertions(+), 17 deletions(-) diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index ecce509f3..23356ba22 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -138,8 +138,14 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { const Handler = struct { t: *Terminal, - pub fn print(self: *Handler, cp: u21) !void { - try self.t.print(cp); + pub fn vt( + self: *Handler, + comptime action: Stream.Action.Tag, + value: Stream.Action.Value(action), + ) !void { + switch (action) { + .print => try self.t.print(value.cp), + } } }; diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 212f0ea4a..840a587bf 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -333,6 +333,15 @@ pub const VTHandler = struct { cimgui.c.ImGuiTextFilter_destroy(self.filter_text); } + pub fn vt( + self: *VTHandler, + comptime action: Stream.Action.Tag, + value: Stream.Action.Value(action), + ) !void { + _ = self; + _ = value; + } + /// This is called with every single terminal action. pub fn handleManually(self: *VTHandler, action: terminal.Parser.Action) !bool { const insp = self.surface.inspector orelse return false; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index c85e72f0f..a26118f3f 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1,8 +1,11 @@ +const streampkg = @This(); const std = @import("std"); const build_options = @import("terminal_options"); const assert = std.debug.assert; const testing = std.testing; const simd = @import("../simd/main.zig"); +const LibEnum = @import("../lib/enum.zig").Enum; +const LibUnion = @import("../lib/union.zig").TaggedUnion; const Parser = @import("Parser.zig"); const ansi = @import("ansi.zig"); const charsets = @import("charsets.zig"); @@ -25,6 +28,40 @@ const log = std.log.scoped(.stream); /// do something else. const debug = false; +pub const Action = union(Key) { + print: Print, + + pub const Key = LibEnum( + if (build_options.c_abi) .c else .zig, + &.{ + "print", + }, + ); + + /// C ABI functions. + const c_union = LibUnion(@This(), extern struct { + x: u64, + }); + pub const Tag = c_union.Tag; + pub const Value = c_union.Value; + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; + + /// Field types + pub const Print = struct { + cp: u21, + + pub const C = extern struct { + cp: u32, + }; + + pub fn cval(self: Print) Print.C { + return .{ .cp = @intCast(self.cp) }; + } + }; +}; + /// Returns a type that can process a stream of tty control characters. /// This will call various callback functions on type T. Type T only has to /// implement the callbacks it cares about; any unimplemented callbacks will @@ -40,6 +77,8 @@ pub fn Stream(comptime Handler: type) type { return struct { const Self = @This(); + pub const Action = streampkg.Action; + // We use T with @hasDecl so it needs to be a struct. Unwrap the // pointer if we were given one. const T = switch (@typeInfo(Handler)) { @@ -306,7 +345,7 @@ pub fn Stream(comptime Handler: type) type { } switch (action) { - .print => |p| if (@hasDecl(T, "print")) try self.handler.print(p), + .print => |p| try self.print(p), .execute => |code| try self.execute(code), .csi_dispatch => |csi_action| try self.csiDispatch(csi_action), .esc_dispatch => |esc| try self.escDispatch(esc), @@ -334,9 +373,7 @@ pub fn Stream(comptime Handler: type) type { } pub inline fn print(self: *Self, c: u21) !void { - if (@hasDecl(T, "print")) { - try self.handler.print(c); - } + try self.handler.vt(.print, .{ .cp = c }); } pub inline fn execute(self: *Self, c: u8) !void { @@ -1869,8 +1906,15 @@ test "stream: print" { const H = struct { c: ?u21 = 0, - pub fn print(self: *@This(), c: u21) !void { - self.c = c; + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + switch (action) { + .print => self.c = value.cp, + else => {}, + } } }; @@ -1883,8 +1927,15 @@ test "simd: print invalid utf-8" { const H = struct { c: ?u21 = 0, - pub fn print(self: *@This(), c: u21) !void { - self.c = c; + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + switch (action) { + .print => self.c = value.cp, + else => {}, + } } }; @@ -1897,8 +1948,15 @@ test "simd: complete incomplete utf-8" { const H = struct { c: ?u21 = null, - pub fn print(self: *@This(), c: u21) !void { - self.c = c; + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + switch (action) { + .print => self.c = value.cp, + else => {}, + } } }; @@ -1918,6 +1976,16 @@ test "stream: cursor right (CUF)" { pub fn setCursorRight(self: *@This(), v: u16) !void { self.amount = v; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -1943,6 +2011,16 @@ test "stream: dec set mode (SM) and reset mode (RM)" { self.mode = @as(modes.Mode, @enumFromInt(1)); if (v) self.mode = mode; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -1965,6 +2043,16 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { self.mode = null; if (v) self.mode = mode; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -1987,6 +2075,16 @@ test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { self.mode = null; if (v) self.mode = mode; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2007,6 +2105,16 @@ test "stream: restore mode" { _ = b; self.called = true; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2022,6 +2130,16 @@ test "stream: pop kitty keyboard with no params defaults to 1" { pub fn popKittyKeyboard(self: *Self, n: u16) !void { self.n = n; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2037,6 +2155,16 @@ test "stream: DECSCA" { pub fn setProtectedMode(self: *Self, v: ansi.ProtectedMode) !void { self.v = v; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2072,6 +2200,16 @@ test "stream: DECED, DECSED" { self.mode = mode; self.protected = protected; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2148,6 +2286,16 @@ test "stream: DECEL, DECSEL" { self.mode = mode; self.protected = protected; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2207,6 +2355,16 @@ test "stream: DECSCUSR" { pub fn setCursorStyle(self: *@This(), style: ansi.CursorStyle) !void { self.style = style; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2228,6 +2386,16 @@ test "stream: DECSCUSR without space" { pub fn setCursorStyle(self: *@This(), style: ansi.CursorStyle) !void { self.style = style; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2245,6 +2413,16 @@ test "stream: XTSHIFTESCAPE" { pub fn setMouseShiftCapture(self: *@This(), v: bool) !void { self.escape = v; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2274,6 +2452,16 @@ test "stream: change window title with invalid utf-8" { self.seen = true; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; { @@ -2298,6 +2486,16 @@ test "stream: insert characters" { _ = v; self.called = true; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2324,6 +2522,16 @@ test "stream: SCOSC" { pub fn setLeftAndRightMarginAmbiguous(self: *Self) !void { self.called = true; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2339,6 +2547,16 @@ test "stream: SCORC" { pub fn restoreCursor(self: *Self) !void { self.called = true; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2353,6 +2571,16 @@ test "stream: too many csi params" { _ = self; unreachable; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2365,6 +2593,16 @@ test "stream: csi param too long" { _ = v; _ = self; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2378,6 +2616,16 @@ test "stream: send report with CSI t" { pub fn sendSizeReport(self: *@This(), style: csi.SizeReportStyle) void { self.style = style; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2402,6 +2650,16 @@ test "stream: invalid CSI t" { pub fn sendSizeReport(self: *@This(), style: csi.SizeReportStyle) void { self.style = style; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2417,6 +2675,16 @@ test "stream: CSI t push title" { pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { self.op = op; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2435,6 +2703,16 @@ test "stream: CSI t push title with explicit window" { pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { self.op = op; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2453,6 +2731,16 @@ test "stream: CSI t push title with explicit icon" { pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { self.op = op; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2468,6 +2756,16 @@ test "stream: CSI t push title with index" { pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { self.op = op; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2486,6 +2784,16 @@ test "stream: CSI t pop title" { pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { self.op = op; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2504,6 +2812,16 @@ test "stream: CSI t pop title with explicit window" { pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { self.op = op; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2522,6 +2840,16 @@ test "stream: CSI t pop title with explicit icon" { pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { self.op = op; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2537,6 +2865,16 @@ test "stream: CSI t pop title with index" { pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { self.op = op; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2555,6 +2893,16 @@ test "stream CSI W clear tab stops" { pub fn tabClear(self: *@This(), op: csi.TabClear) !void { self.op = op; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2573,6 +2921,16 @@ test "stream CSI W tab set" { pub fn tabSet(self: *@This()) !void { self.called = true; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2600,6 +2958,16 @@ test "stream CSI ? W reset tab stops" { pub fn tabReset(self: *@This()) !void { self.reset = true; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); @@ -2624,6 +2992,16 @@ test "stream: SGR with 17+ parameters for underline color" { self.attrs = attr; self.called = true; } + + pub fn vt( + self: *@This(), + comptime action: anytype, + value: anytype, + ) !void { + _ = self; + _ = action; + _ = value; + } }; var s: Stream(H) = .init(.{}); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index e41fe33a9..2f1bf227d 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -64,7 +64,7 @@ mailbox: termio.Mailbox, /// The stream parser. This parses the stream of escape codes and so on /// from the child process and calls callbacks in the stream handler. -terminal_stream: terminalpkg.Stream(StreamHandler), +terminal_stream: StreamHandler.Stream, /// Last time the cursor was reset. This is used to prevent message /// flooding with cursor resets. diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index dd8669d90..b9ab7a2b4 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -95,6 +95,8 @@ pub const StreamHandler = struct { /// this to determine if we need to default the window title. seen_title: bool = false, + pub const Stream = terminal.Stream(StreamHandler); + pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); @@ -186,6 +188,16 @@ pub const StreamHandler = struct { _ = self.renderer_mailbox.push(msg, .{ .forever = {} }); } + pub fn vt( + self: *StreamHandler, + comptime action: Stream.Action.Tag, + value: Stream.Action.Value(action), + ) !void { + switch (action) { + .print => try self.terminal.print(value.cp), + } + } + pub inline fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { var cmd = self.dcs.hook(self.alloc, dcs) orelse return; defer cmd.deinit(); @@ -322,10 +334,6 @@ pub const StreamHandler = struct { } } - pub inline fn print(self: *StreamHandler, ch: u21) !void { - try self.terminal.print(ch); - } - pub inline fn printRepeat(self: *StreamHandler, count: usize) !void { try self.terminal.printRepeat(count); } From 2ef89c153abe0e5115472907c96301dbd422a695 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 15:09:30 -0700 Subject: [PATCH 168/702] terminal: convert C0 --- src/benchmark/TerminalStream.zig | 1 + src/terminal/charsets.zig | 17 +++--- src/terminal/stream.zig | 96 +++++++++++++++++--------------- src/termio/stream_handler.zig | 30 ++++------ 4 files changed, 71 insertions(+), 73 deletions(-) diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index 23356ba22..0a993c42b 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -145,6 +145,7 @@ const Handler = struct { ) !void { switch (action) { .print => try self.t.print(value.cp), + else => {}, } } }; diff --git a/src/terminal/charsets.zig b/src/terminal/charsets.zig index 66d6566e3..9d49832df 100644 --- a/src/terminal/charsets.zig +++ b/src/terminal/charsets.zig @@ -1,16 +1,19 @@ const std = @import("std"); +const build_options = @import("terminal_options"); const assert = std.debug.assert; +const LibEnum = @import("../lib/enum.zig").Enum; /// The available charset slots for a terminal. -pub const Slots = enum(u3) { - G0 = 0, - G1 = 1, - G2 = 2, - G3 = 3, -}; +pub const Slots = LibEnum( + if (build_options.c_abi) .c else .zig, + &.{ "G0", "G1", "G2", "G3" }, +); /// The name of the active slots. -pub const ActiveSlot = enum { GL, GR }; +pub const ActiveSlot = LibEnum( + if (build_options.c_abi) .c else .zig, + &.{ "GL", "GR" }, +); /// The list of supported character sets and their associated tables. pub const Charset = enum { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index a26118f3f..555326607 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -4,8 +4,7 @@ const build_options = @import("terminal_options"); const assert = std.debug.assert; const testing = std.testing; const simd = @import("../simd/main.zig"); -const LibEnum = @import("../lib/enum.zig").Enum; -const LibUnion = @import("../lib/union.zig").TaggedUnion; +const lib = @import("../lib/main.zig"); const Parser = @import("Parser.zig"); const ansi = @import("ansi.zig"); const charsets = @import("charsets.zig"); @@ -28,20 +27,40 @@ const log = std.log.scoped(.stream); /// do something else. const debug = false; +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + pub const Action = union(Key) { print: Print, + bell, + backspace, + horizontal_tab: HorizontalTab, + linefeed, + carriage_return, + enquiry, + invoke_charset: InvokeCharset, - pub const Key = LibEnum( - if (build_options.c_abi) .c else .zig, + pub const Key = lib.Enum( + lib_target, &.{ "print", + "bell", + "backspace", + "horizontal_tab", + "linefeed", + "carriage_return", + "enquiry", + "invoke_charset", }, ); /// C ABI functions. - const c_union = LibUnion(@This(), extern struct { - x: u64, - }); + const c_union = lib.TaggedUnion( + lib_target, + @This(), + // TODO: Before shipping an ABI-compatible libghostty, verify this. + // This was just arbitrarily chosen for now. + [8]u64, + ); pub const Tag = c_union.Tag; pub const Value = c_union.Value; pub const C = c_union.C; @@ -60,6 +79,16 @@ pub const Action = union(Key) { return .{ .cp = @intCast(self.cp) }; } }; + + pub const HorizontalTab = lib.Struct(lib_target, struct { + count: u16, + }); + + pub const InvokeCharset = lib.Struct(lib_target, struct { + bank: charsets.ActiveSlot, + charset: charsets.Slots, + locking: bool, + }); }; /// Returns a type that can process a stream of tty control characters. @@ -383,45 +412,14 @@ pub fn Stream(comptime Handler: type) type { // We ignore SOH/STX: https://github.com/microsoft/terminal/issues/10786 .NUL, .SOH, .STX => {}, - .ENQ => if (@hasDecl(T, "enquiry")) - try self.handler.enquiry() - else - log.warn("unimplemented execute: {x}", .{c}), - - .BEL => if (@hasDecl(T, "bell")) - try self.handler.bell() - else - log.warn("unimplemented execute: {x}", .{c}), - - .BS => if (@hasDecl(T, "backspace")) - try self.handler.backspace() - else - log.warn("unimplemented execute: {x}", .{c}), - - .HT => if (@hasDecl(T, "horizontalTab")) - try self.handler.horizontalTab(1) - else - log.warn("unimplemented execute: {x}", .{c}), - - .LF, .VT, .FF => if (@hasDecl(T, "linefeed")) - try self.handler.linefeed() - else - log.warn("unimplemented execute: {x}", .{c}), - - .CR => if (@hasDecl(T, "carriageReturn")) - try self.handler.carriageReturn() - else - log.warn("unimplemented execute: {x}", .{c}), - - .SO => if (@hasDecl(T, "invokeCharset")) - try self.handler.invokeCharset(.GL, .G1, false) - else - log.warn("unimplemented invokeCharset: {x}", .{c}), - - .SI => if (@hasDecl(T, "invokeCharset")) - try self.handler.invokeCharset(.GL, .G0, false) - else - log.warn("unimplemented invokeCharset: {x}", .{c}), + .ENQ => try self.handler.vt(.enquiry, {}), + .BEL => try self.handler.vt(.bell, {}), + .BS => try self.handler.vt(.backspace, {}), + .HT => try self.handler.vt(.horizontal_tab, .{ .count = 1 }), + .LF, .VT, .FF => try self.handler.vt(.linefeed, {}), + .CR => try self.handler.vt(.carriage_return, {}), + .SO => try self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }), + .SI => try self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }), else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}), } @@ -1902,6 +1900,12 @@ pub fn Stream(comptime Handler: type) type { }; } +test Action { + // Forces the C type to be reified when the target is C, ensuring + // all our types are C ABI compatible. + _ = Action.C; +} + test "stream: print" { const H = struct { c: ?u21 = 0, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index b9ab7a2b4..d8c05f2f2 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -195,6 +195,13 @@ pub const StreamHandler = struct { ) !void { switch (action) { .print => try self.terminal.print(value.cp), + .bell => self.bell(), + .backspace => self.terminal.backspace(), + .horizontal_tab => try self.horizontalTab(value.count), + .linefeed => try self.linefeed(), + .carriage_return => self.terminal.carriageReturn(), + .enquiry => try self.enquiry(), + .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), } } @@ -338,15 +345,11 @@ pub const StreamHandler = struct { try self.terminal.printRepeat(count); } - pub inline fn bell(self: *StreamHandler) !void { + inline fn bell(self: *StreamHandler) void { self.surfaceMessageWriter(.ring_bell); } - pub inline fn backspace(self: *StreamHandler) !void { - self.terminal.backspace(); - } - - pub inline fn horizontalTab(self: *StreamHandler, count: u16) !void { + inline fn horizontalTab(self: *StreamHandler, count: u16) !void { for (0..count) |_| { const x = self.terminal.screen.cursor.x; try self.terminal.horizontalTab(); @@ -362,16 +365,12 @@ pub const StreamHandler = struct { } } - pub inline fn linefeed(self: *StreamHandler) !void { + inline fn linefeed(self: *StreamHandler) !void { // Small optimization: call index instead of linefeed because they're // identical and this avoids one layer of function call overhead. try self.terminal.index(); } - pub inline fn carriageReturn(self: *StreamHandler) !void { - self.terminal.carriageReturn(); - } - pub inline fn setCursorLeft(self: *StreamHandler, amount: u16) !void { self.terminal.cursorLeft(amount); } @@ -896,15 +895,6 @@ pub const StreamHandler = struct { self.terminal.configureCharset(slot, set); } - pub fn invokeCharset( - self: *StreamHandler, - active: terminal.CharsetActiveSlot, - slot: terminal.CharsetSlot, - single: bool, - ) !void { - self.terminal.invokeCharset(active, slot, single); - } - pub fn fullReset( self: *StreamHandler, ) !void { From ccd821a0ff942b30dc415f14bbc6c8988103aa9e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 16:02:17 -0700 Subject: [PATCH 169/702] terminal: convert cursor movements --- src/terminal/stream.zig | 179 ++++++++++++++++++++-------------- src/termio/stream_handler.zig | 57 +++-------- 2 files changed, 121 insertions(+), 115 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 555326607..c48eca973 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -38,6 +38,15 @@ pub const Action = union(Key) { carriage_return, enquiry, invoke_charset: InvokeCharset, + cursor_up: CursorMovement, + cursor_down: CursorMovement, + cursor_left: CursorMovement, + cursor_right: CursorMovement, + cursor_col: CursorMovement, + cursor_row: CursorMovement, + cursor_col_relative: CursorMovement, + cursor_row_relative: CursorMovement, + cursor_pos: CursorPos, pub const Key = lib.Enum( lib_target, @@ -50,6 +59,15 @@ pub const Action = union(Key) { "carriage_return", "enquiry", "invoke_charset", + "cursor_up", + "cursor_down", + "cursor_left", + "cursor_right", + "cursor_col", + "cursor_row", + "cursor_col_relative", + "cursor_row_relative", + "cursor_pos", }, ); @@ -89,6 +107,19 @@ pub const Action = union(Key) { charset: charsets.Slots, locking: bool, }); + + pub const CursorMovement = extern struct { + /// The value of the cursor movement. Depending on the tag of this + /// union this may be an absolute value or it may be a relative + /// value. For example, `cursor_up` is relative, but `cursor_row` + /// is absolute. + value: u16, + }; + + pub const CursorPos = extern struct { + row: u16, + col: u16, + }; }; /// Returns a type that can process a stream of tty control characters. @@ -429,8 +460,8 @@ pub fn Stream(comptime Handler: type) type { switch (input.final) { // CUU - Cursor Up 'A', 'k' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_up, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -438,8 +469,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }, - false, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI A with intermediates: {s}", @@ -449,8 +479,8 @@ pub fn Stream(comptime Handler: type) type { // CUD - Cursor Down 'B' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_down, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -458,8 +488,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }, - false, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI B with intermediates: {s}", @@ -469,8 +498,8 @@ pub fn Stream(comptime Handler: type) type { // CUF - Cursor Right 'C' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_right, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -478,7 +507,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI C with intermediates: {s}", @@ -488,8 +517,8 @@ pub fn Stream(comptime Handler: type) type { // CUB - Cursor Left 'D', 'j' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_left, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -497,7 +526,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI D with intermediates: {s}", @@ -507,17 +536,19 @@ pub fn Stream(comptime Handler: type) type { // CNL - Cursor Next Line 'E' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor up command: {f}", .{input}); - return; + 0 => { + try self.handler.vt(.cursor_down, .{ + .value = switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor up command: {f}", .{input}); + return; + }, }, - }, - true, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }); + try self.handler.vt(.carriage_return, {}); + }, else => log.warn( "ignoring unimplemented CSI E with intermediates: {s}", @@ -527,17 +558,19 @@ pub fn Stream(comptime Handler: type) type { // CPL - Cursor Previous Line 'F' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor down command: {f}", .{input}); - return; + 0 => { + try self.handler.vt(.cursor_up, .{ + .value = switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor down command: {f}", .{input}); + return; + }, }, - }, - true, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }); + try self.handler.vt(.carriage_return, {}); + }, else => log.warn( "ignoring unimplemented CSI F with intermediates: {s}", @@ -548,11 +581,16 @@ pub fn Stream(comptime Handler: type) type { // HPA - Cursor Horizontal Position Absolute // TODO: test 'G', '`' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) { - 0 => try self.handler.setCursorCol(1), - 1 => try self.handler.setCursorCol(input.params[0]), - else => log.warn("invalid HPA command: {f}", .{input}), - } else log.warn("unimplemented CSI callback: {f}", .{input}), + 0 => try self.handler.vt(.cursor_col, .{ + .value = switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid HPA command: {f}", .{input}); + return; + }, + }, + }), else => log.warn( "ignoring unimplemented CSI G with intermediates: {s}", @@ -563,12 +601,18 @@ pub fn Stream(comptime Handler: type) type { // CUP - Set Cursor Position. // TODO: test 'H', 'f' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) { - 0 => try self.handler.setCursorPos(1, 1), - 1 => try self.handler.setCursorPos(input.params[0], 1), - 2 => try self.handler.setCursorPos(input.params[0], input.params[1]), - else => log.warn("invalid CUP command: {f}", .{input}), - } else log.warn("unimplemented CSI callback: {f}", .{input}), + 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); + }, else => log.warn( "ignoring unimplemented CSI H with intermediates: {s}", @@ -830,8 +874,8 @@ pub fn Stream(comptime Handler: type) type { // HPR - Cursor Horizontal Position Relative 'a' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_col_relative, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -839,7 +883,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI a with intermediates: {s}", @@ -886,8 +930,8 @@ pub fn Stream(comptime Handler: type) type { // VPA - Cursor Vertical Position Absolute 'd' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_row, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -895,7 +939,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI d with intermediates: {s}", @@ -905,8 +949,8 @@ pub fn Stream(comptime Handler: type) type { // VPR - Cursor Vertical Position Relative 'e' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( - switch (input.params.len) { + 0 => try self.handler.vt(.cursor_row_relative, .{ + .value = switch (input.params.len) { 0 => 1, 1 => input.params[0], else => { @@ -914,7 +958,7 @@ pub fn Stream(comptime Handler: type) type { return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI e with intermediates: {s}", @@ -1977,18 +2021,15 @@ test "stream: cursor right (CUF)" { const H = struct { amount: u16 = 0, - pub fn setCursorRight(self: *@This(), v: u16) !void { - self.amount = v; - } - pub fn vt( self: *@This(), comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .cursor_right => self.amount = value.value, + else => {}, + } } }; @@ -2570,20 +2611,17 @@ test "stream: SCORC" { test "stream: too many csi params" { const H = struct { - pub fn setCursorRight(self: *@This(), v: u16) !void { - _ = v; - _ = self; - unreachable; - } - pub fn vt( self: *@This(), comptime action: anytype, value: anytype, ) !void { _ = self; - _ = action; _ = value; + switch (action) { + .cursor_right => unreachable, + else => {}, + } } }; @@ -2593,11 +2631,6 @@ test "stream: too many csi params" { test "stream: csi param too long" { const H = struct { - pub fn setCursorRight(self: *@This(), v: u16) !void { - _ = v; - _ = self; - } - pub fn vt( self: *@This(), comptime action: anytype, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index d8c05f2f2..a18e669f6 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -202,6 +202,21 @@ pub const StreamHandler = struct { .carriage_return => 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_col => self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, value.value), + .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screen.cursor.x + 1), + .cursor_col_relative => self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1, + self.terminal.screen.cursor.x + 1 +| value.value, + ), + .cursor_row_relative => self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1 +| value.value, + self.terminal.screen.cursor.x + 1, + ), } } @@ -371,49 +386,7 @@ pub const StreamHandler = struct { try self.terminal.index(); } - pub inline fn setCursorLeft(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorLeft(amount); - } - pub inline fn setCursorRight(self: *StreamHandler, amount: u16) !void { - self.terminal.cursorRight(amount); - } - - pub inline fn setCursorDown(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorDown(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub inline fn setCursorUp(self: *StreamHandler, amount: u16, carriage: bool) !void { - self.terminal.cursorUp(amount); - if (carriage) self.terminal.carriageReturn(); - } - - pub inline fn setCursorCol(self: *StreamHandler, col: u16) !void { - self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, col); - } - - pub inline fn setCursorColRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1, - self.terminal.screen.cursor.x + 1 +| offset, - ); - } - - pub inline fn setCursorRow(self: *StreamHandler, row: u16) !void { - self.terminal.setCursorPos(row, self.terminal.screen.cursor.x + 1); - } - - pub inline fn setCursorRowRelative(self: *StreamHandler, offset: u16) !void { - self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1 +| offset, - self.terminal.screen.cursor.x + 1, - ); - } - - pub inline fn setCursorPos(self: *StreamHandler, row: u16, col: u16) !void { - self.terminal.setCursorPos(row, col); - } pub inline fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { if (mode == .complete) { From b5da54d92538f9d58ec1e8a45d9110291f831295 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 16:27:02 -0700 Subject: [PATCH 170/702] terminal: horizontal tab --- src/terminal/stream.zig | 42 +++++++++++++++-------------------- src/termio/stream_handler.zig | 7 +++--- 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index c48eca973..c6621df00 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -33,7 +33,8 @@ pub const Action = union(Key) { print: Print, bell, backspace, - horizontal_tab: HorizontalTab, + horizontal_tab: u16, + horizontal_tab_back: u16, linefeed, carriage_return, enquiry, @@ -55,6 +56,7 @@ pub const Action = union(Key) { "bell", "backspace", "horizontal_tab", + "horizontal_tab_back", "linefeed", "carriage_return", "enquiry", @@ -98,10 +100,6 @@ pub const Action = union(Key) { } }; - pub const HorizontalTab = lib.Struct(lib_target, struct { - count: u16, - }); - pub const InvokeCharset = lib.Struct(lib_target, struct { bank: charsets.ActiveSlot, charset: charsets.Slots, @@ -446,7 +444,7 @@ pub fn Stream(comptime Handler: type) type { .ENQ => try self.handler.vt(.enquiry, {}), .BEL => try self.handler.vt(.bell, {}), .BS => try self.handler.vt(.backspace, {}), - .HT => try self.handler.vt(.horizontal_tab, .{ .count = 1 }), + .HT => try self.handler.vt(.horizontal_tab, 1), .LF, .VT, .FF => try self.handler.vt(.linefeed, {}), .CR => try self.handler.vt(.carriage_return, {}), .SO => try self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }), @@ -622,16 +620,14 @@ pub fn Stream(comptime Handler: type) type { // CHT - Cursor Horizontal Tabulation 'I' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid horizontal tab command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.horizontal_tab, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid horizontal tab command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI I with intermediates: {s}", @@ -855,16 +851,14 @@ pub fn Stream(comptime Handler: type) type { // CHT - Cursor Horizontal Tabulation Back 'Z' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid horizontal tab back command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.horizontal_tab_back, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid horizontal tab back command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI Z with intermediates: {s}", diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index a18e669f6..cbd4ef281 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -197,7 +197,8 @@ pub const StreamHandler = struct { .print => try self.terminal.print(value.cp), .bell => self.bell(), .backspace => self.terminal.backspace(), - .horizontal_tab => try self.horizontalTab(value.count), + .horizontal_tab => try self.horizontalTab(value), + .horizontal_tab_back => try self.horizontalTabBack(value), .linefeed => try self.linefeed(), .carriage_return => self.terminal.carriageReturn(), .enquiry => try self.enquiry(), @@ -372,7 +373,7 @@ pub const StreamHandler = struct { } } - pub inline fn horizontalTabBack(self: *StreamHandler, count: u16) !void { + inline fn horizontalTabBack(self: *StreamHandler, count: u16) !void { for (0..count) |_| { const x = self.terminal.screen.cursor.x; try self.terminal.horizontalTabBack(); @@ -386,8 +387,6 @@ pub const StreamHandler = struct { try self.terminal.index(); } - - pub inline fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { if (mode == .complete) { // Whenever we erase the full display, scroll to bottom. From b0fb3ef9a90a1ad15051f0669841aed6eadb3c6d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 16:31:42 -0700 Subject: [PATCH 171/702] terminal: erase display conversion --- src/terminal/stream.zig | 112 ++++++++++++++++++++++++---------- src/termio/stream_handler.zig | 27 ++++---- 2 files changed, 93 insertions(+), 46 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index c6621df00..25ce76f4b 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -48,6 +48,15 @@ pub const Action = union(Key) { cursor_col_relative: CursorMovement, cursor_row_relative: CursorMovement, cursor_pos: CursorPos, + erase_display_below: bool, + erase_display_above: bool, + erase_display_complete: bool, + erase_display_scrollback: bool, + erase_display_scroll_complete: bool, + erase_line_right: bool, + erase_line_left: bool, + erase_line_complete: bool, + erase_line_right_unless_pending_wrap: bool, pub const Key = lib.Enum( lib_target, @@ -70,6 +79,15 @@ pub const Action = union(Key) { "cursor_col_relative", "cursor_row_relative", "cursor_pos", + "erase_display_below", + "erase_display_above", + "erase_display_complete", + "erase_display_scrollback", + "erase_display_scroll_complete", + "erase_line_right", + "erase_line_left", + "erase_line_complete", + "erase_line_right_unless_pending_wrap", }, ); @@ -636,7 +654,7 @@ pub fn Stream(comptime Handler: type) type { }, // Erase Display - 'J' => if (@hasDecl(T, "eraseDisplay")) { + 'J' => { const protected_: ?bool = switch (input.intermediates.len) { 0 => false, 1 => if (input.intermediates[0] == '?') true else null, @@ -659,11 +677,17 @@ pub fn Stream(comptime Handler: type) type { return; }; - try self.handler.eraseDisplay(mode, protected); - } else log.warn("unimplemented CSI callback: {f}", .{input}), + switch (mode) { + .below => try self.handler.vt(.erase_display_below, protected), + .above => try self.handler.vt(.erase_display_above, protected), + .complete => try self.handler.vt(.erase_display_complete, protected), + .scrollback => try self.handler.vt(.erase_display_scrollback, protected), + .scroll_complete => try self.handler.vt(.erase_display_scroll_complete, protected), + } + }, // Erase Line - 'K' => if (@hasDecl(T, "eraseLine")) { + 'K' => { const protected_: ?bool = switch (input.intermediates.len) { 0 => false, 1 => if (input.intermediates[0] == '?') true else null, @@ -686,8 +710,14 @@ pub fn Stream(comptime Handler: type) type { return; }; - try self.handler.eraseLine(mode, protected); - } else log.warn("unimplemented CSI callback: {f}", .{input}), + switch (mode) { + .right => try self.handler.vt(.erase_line_right, protected), + .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}), + } + }, // IL - Insert Lines // TODO: test @@ -2231,23 +2261,34 @@ test "stream: DECED, DECSED" { mode: ?csi.EraseDisplay = null, protected: ?bool = null, - pub fn eraseDisplay( - self: *Self, - mode: csi.EraseDisplay, - protected: bool, - ) !void { - self.mode = mode; - self.protected = protected; - } - pub fn vt( - self: *@This(), + self: *Self, comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .erase_display_below => { + self.mode = .below; + self.protected = value; + }, + .erase_display_above => { + self.mode = .above; + self.protected = value; + }, + .erase_display_complete => { + self.mode = .complete; + self.protected = value; + }, + .erase_display_scrollback => { + self.mode = .scrollback; + self.protected = value; + }, + .erase_display_scroll_complete => { + self.mode = .scroll_complete; + self.protected = value; + }, + else => {}, + } } }; @@ -2317,23 +2358,30 @@ test "stream: DECEL, DECSEL" { mode: ?csi.EraseLine = null, protected: ?bool = null, - pub fn eraseLine( - self: *Self, - mode: csi.EraseLine, - protected: bool, - ) !void { - self.mode = mode; - self.protected = protected; - } - pub fn vt( - self: *@This(), + self: *Self, comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .erase_line_right => { + self.mode = .right; + self.protected = value; + }, + .erase_line_left => { + self.mode = .left; + self.protected = value; + }, + .erase_line_complete => { + self.mode = .complete; + self.protected = value; + }, + .erase_line_right_unless_pending_wrap => { + self.mode = .right_unless_pending_wrap; + self.protected = value; + }, + else => {}, + } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index cbd4ef281..4cee8c1b3 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -218,6 +218,19 @@ pub const StreamHandler = struct { self.terminal.screen.cursor.y + 1 +| value.value, self.terminal.screen.cursor.x + 1, ), + .erase_display_below => self.terminal.eraseDisplay(.below, value), + .erase_display_above => self.terminal.eraseDisplay(.above, value), + .erase_display_complete => { + try self.terminal.scrollViewport(.{ .bottom = {} }); + try self.queueRender(); + self.terminal.eraseDisplay(.complete, value); + }, + .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), + .erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value), + .erase_line_right => self.terminal.eraseLine(.right, value), + .erase_line_left => self.terminal.eraseLine(.left, value), + .erase_line_complete => self.terminal.eraseLine(.complete, value), + .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value), } } @@ -387,20 +400,6 @@ pub const StreamHandler = struct { try self.terminal.index(); } - pub inline fn eraseDisplay(self: *StreamHandler, mode: terminal.EraseDisplay, protected: bool) !void { - if (mode == .complete) { - // Whenever we erase the full display, scroll to bottom. - try self.terminal.scrollViewport(.{ .bottom = {} }); - try self.queueRender(); - } - - self.terminal.eraseDisplay(mode, protected); - } - - pub inline fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine, protected: bool) !void { - self.terminal.eraseLine(mode, protected); - } - pub inline fn deleteChars(self: *StreamHandler, count: usize) !void { self.terminal.deleteChars(count); } From 37016d8b89aa3dc25da33fab42b9f850aac8c155 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 16:44:46 -0700 Subject: [PATCH 172/702] terminal: erase/insert lines, characters, etc. --- src/terminal/stream.zig | 130 +++++++++++++++++++--------------- src/termio/stream_handler.zig | 35 ++------- 2 files changed, 78 insertions(+), 87 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 25ce76f4b..9a3551491 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -57,6 +57,13 @@ pub const Action = union(Key) { erase_line_left: bool, erase_line_complete: bool, erase_line_right_unless_pending_wrap: bool, + delete_chars: usize, + erase_chars: usize, + insert_lines: usize, + insert_blanks: usize, + delete_lines: usize, + scroll_up: usize, + scroll_down: usize, pub const Key = lib.Enum( lib_target, @@ -88,6 +95,13 @@ pub const Action = union(Key) { "erase_line_left", "erase_line_complete", "erase_line_right_unless_pending_wrap", + "delete_chars", + "erase_chars", + "insert_lines", + "insert_blanks", + "delete_lines", + "scroll_up", + "scroll_down", }, ); @@ -722,11 +736,14 @@ pub fn Stream(comptime Handler: type) type { // IL - Insert Lines // TODO: test 'L' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "insertLines")) switch (input.params.len) { - 0 => try self.handler.insertLines(1), - 1 => try self.handler.insertLines(input.params[0]), - else => log.warn("invalid IL command: {f}", .{input}), - } else log.warn("unimplemented CSI callback: {f}", .{input}), + 0 => try self.handler.vt(.insert_lines, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid IL command: {f}", .{input}); + return; + }, + }), else => log.warn( "ignoring unimplemented CSI L with intermediates: {s}", @@ -737,11 +754,14 @@ pub fn Stream(comptime Handler: type) type { // DL - Delete Lines // TODO: test 'M' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "deleteLines")) switch (input.params.len) { - 0 => try self.handler.deleteLines(1), - 1 => try self.handler.deleteLines(input.params[0]), - else => log.warn("invalid DL command: {f}", .{input}), - } else log.warn("unimplemented CSI callback: {f}", .{input}), + 0 => try self.handler.vt(.delete_lines, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid DL command: {f}", .{input}); + return; + }, + }), else => log.warn( "ignoring unimplemented CSI M with intermediates: {s}", @@ -751,16 +771,14 @@ pub fn Stream(comptime Handler: type) type { // Delete Character (DCH) 'P' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid delete characters command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.delete_chars, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid delete characters command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI P with intermediates: {s}", @@ -771,16 +789,14 @@ pub fn Stream(comptime Handler: type) type { // Scroll Up (SD) 'S' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "scrollUp")) try self.handler.scrollUp( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid scroll up command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.scroll_up, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid scroll up command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI S with intermediates: {s}", @@ -790,16 +806,14 @@ pub fn Stream(comptime Handler: type) type { // Scroll Down (SD) 'T' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid scroll down command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.scroll_down, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid scroll down command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI T with intermediates: {s}", @@ -862,16 +876,14 @@ pub fn Stream(comptime Handler: type) type { // Erase Characters (ECH) 'X' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid erase characters command: {f}", .{input}); - return; - }, + 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; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI X with intermediates: {s}", @@ -1558,11 +1570,14 @@ pub fn Stream(comptime Handler: type) type { // ICH - Insert Blanks '@' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "insertBlanks")) switch (input.params.len) { - 0 => try self.handler.insertBlanks(1), - 1 => try self.handler.insertBlanks(input.params[0]), - else => log.warn("invalid ICH command: {f}", .{input}), - } else log.warn("unimplemented CSI callback: {f}", .{input}), + 0 => try self.handler.vt(.insert_blanks, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid ICH command: {f}", .{input}); + return; + }, + }), else => log.warn( "ignoring unimplemented CSI @: {f}", @@ -2569,19 +2584,16 @@ test "stream: insert characters" { const Self = @This(); called: bool = false, - pub fn insertBlanks(self: *Self, v: u16) !void { - _ = v; - self.called = true; - } - pub fn vt( - self: *@This(), + self: *Self, comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; _ = value; + switch (action) { + .insert_blanks => self.called = true, + else => {}, + } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 4cee8c1b3..a05949424 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -231,6 +231,13 @@ pub const StreamHandler = struct { .erase_line_left => self.terminal.eraseLine(.left, value), .erase_line_complete => self.terminal.eraseLine(.complete, value), .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value), + .delete_chars => self.terminal.deleteChars(value), + .erase_chars => self.terminal.eraseChars(value), + .insert_lines => self.terminal.insertLines(value), + .insert_blanks => self.terminal.insertBlanks(value), + .delete_lines => self.terminal.deleteLines(value), + .scroll_up => self.terminal.scrollUp(value), + .scroll_down => self.terminal.scrollDown(value), } } @@ -400,26 +407,6 @@ pub const StreamHandler = struct { try self.terminal.index(); } - pub inline fn deleteChars(self: *StreamHandler, count: usize) !void { - self.terminal.deleteChars(count); - } - - pub inline fn eraseChars(self: *StreamHandler, count: usize) !void { - self.terminal.eraseChars(count); - } - - pub inline fn insertLines(self: *StreamHandler, count: usize) !void { - self.terminal.insertLines(count); - } - - pub inline fn insertBlanks(self: *StreamHandler, count: usize) !void { - self.terminal.insertBlanks(count); - } - - pub inline fn deleteLines(self: *StreamHandler, count: usize) !void { - self.terminal.deleteLines(count); - } - pub inline fn reverseIndex(self: *StreamHandler) !void { self.terminal.reverseIndex(); } @@ -843,14 +830,6 @@ pub const StreamHandler = struct { self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); } - pub inline fn scrollDown(self: *StreamHandler, count: usize) !void { - self.terminal.scrollDown(count); - } - - pub inline fn scrollUp(self: *StreamHandler, count: usize) !void { - self.terminal.scrollUp(count); - } - pub fn setActiveStatusDisplay( self: *StreamHandler, req: terminal.StatusDisplay, From dc5406781f19819c616de0e96bc08c109476c802 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 19:53:35 -0700 Subject: [PATCH 173/702] terminal: many more conversions --- src/lib/union.zig | 1 + src/terminal/stream.zig | 118 +++++++++++++++------------------- src/termio/stream_handler.zig | 21 ++---- 3 files changed, 58 insertions(+), 82 deletions(-) diff --git a/src/lib/union.zig b/src/lib/union.zig index f19cd3c7f..8e9f09049 100644 --- a/src/lib/union.zig +++ b/src/lib/union.zig @@ -121,6 +121,7 @@ pub fn TaggedUnion( /// Returns the value type for the given tag. pub fn Value(comptime tag: Tag) type { + @setEvalBranchQuota(2000); inline for (@typeInfo(Union).@"union".fields) |field| { const field_tag = @field(Tag, field.name); if (field_tag == tag) return field.type; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 9a3551491..71d54e4f3 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -31,6 +31,7 @@ const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; pub const Action = union(Key) { print: Print, + print_repeat: usize, bell, backspace, horizontal_tab: u16, @@ -64,11 +65,16 @@ pub const Action = union(Key) { delete_lines: usize, scroll_up: usize, scroll_down: usize, + tab_clear_current, + tab_clear_all, + tab_set, + tab_reset, pub const Key = lib.Enum( lib_target, &.{ "print", + "print_repeat", "bell", "backspace", "horizontal_tab", @@ -102,6 +108,10 @@ pub const Action = union(Key) { "delete_lines", "scroll_up", "scroll_down", + "tab_clear_current", + "tab_clear_all", + "tab_set", + "tab_reset", }, ); @@ -827,11 +837,7 @@ pub fn Stream(comptime Handler: type) type { if (input.params.len == 0 or (input.params.len == 1 and input.params[0] == 0)) { - if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {f}", .{input}); - + try self.handler.vt(.tab_set, {}); return; } @@ -841,15 +847,9 @@ pub fn Stream(comptime Handler: type) type { 1 => switch (input.params[0]) { 0 => unreachable, - 2 => if (@hasDecl(T, "tabClear")) - try self.handler.tabClear(.current) - else - log.warn("unimplemented tab clear callback: {f}", .{input}), + 2 => try self.handler.vt(.tab_clear_current, {}), - 5 => if (@hasDecl(T, "tabClear")) - try self.handler.tabClear(.all) - else - log.warn("unimplemented tab clear callback: {f}", .{input}), + 5 => try self.handler.vt(.tab_clear_all, {}), else => {}, }, @@ -862,10 +862,7 @@ pub fn Stream(comptime Handler: type) type { }, 1 => if (input.intermediates[0] == '?' and input.params[0] == 5) { - if (@hasDecl(T, "tabReset")) - try self.handler.tabReset() - else - log.warn("unimplemented tab reset callback: {f}", .{input}); + try self.handler.vt(.tab_reset, {}); } else log.warn("invalid cursor tabulation control: {f}", .{input}), else => log.warn( @@ -929,16 +926,14 @@ pub fn Stream(comptime Handler: type) type { // Repeat Previous Char (REP) 'b' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid print repeat command: {f}", .{input}); - return; - }, + 0 => try self.handler.vt(.print_repeat, switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid print repeat command: {f}", .{input}); + return; }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }), else => log.warn( "ignoring unimplemented CSI b with intermediates: {s}", @@ -1005,15 +1000,20 @@ pub fn Stream(comptime Handler: type) type { // TBC - Tab Clear // TODO: test 'g' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( - switch (input.params.len) { + 0 => { + const mode: csi.TabClear = switch (input.params.len) { 1 => @enumFromInt(input.params[0]), else => { log.warn("invalid tab clear command: {f}", .{input}); return; }, - }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}), + }; + switch (mode) { + .current => try self.handler.vt(.tab_clear_current, {}), + .all => try self.handler.vt(.tab_clear_all, {}), + _ => log.warn("unknown tab clear mode: {}", .{mode}), + } + }, else => log.warn( "ignoring unimplemented CSI g with intermediates: {s}", @@ -1856,13 +1856,13 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented ESC callback: {f}", .{action}), // HTS - Horizontal Tab Set - 'H' => if (@hasDecl(T, "tabSet")) switch (action.intermediates.len) { - 0 => try self.handler.tabSet(), + 'H' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.tab_set, {}), else => { log.warn("invalid tab set command: {f}", .{action}); return; }, - } else log.warn("unimplemented tab set callback: {f}", .{action}), + }, // RI - Reverse Index 'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { @@ -2979,99 +2979,85 @@ test "stream: CSI t pop title with index" { test "stream CSI W clear tab stops" { const H = struct { - op: ?csi.TabClear = null, - - pub fn tabClear(self: *@This(), op: csi.TabClear) !void { - self.op = op; - } + action: ?Action.Key = null, pub fn vt( self: *@This(), comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; _ = value; + self.action = action; } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[2W"); - try testing.expectEqual(csi.TabClear.current, s.handler.op.?); + try testing.expectEqual(Action.Key.tab_clear_current, s.handler.action.?); try s.nextSlice("\x1b[5W"); - try testing.expectEqual(csi.TabClear.all, s.handler.op.?); + try testing.expectEqual(Action.Key.tab_clear_all, s.handler.action.?); } test "stream CSI W tab set" { const H = struct { - called: bool = false, - - pub fn tabSet(self: *@This()) !void { - self.called = true; - } + action: ?Action.Key = null, pub fn vt( self: *@This(), comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; _ = value; + self.action = action; } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[W"); - try testing.expect(s.handler.called); + try testing.expectEqual(Action.Key.tab_set, s.handler.action.?); - s.handler.called = false; + s.handler.action = null; try s.nextSlice("\x1b[0W"); - try testing.expect(s.handler.called); + try testing.expectEqual(Action.Key.tab_set, s.handler.action.?); - s.handler.called = false; + s.handler.action = null; try s.nextSlice("\x1b[>W"); - try testing.expect(!s.handler.called); + try testing.expect(s.handler.action == null); - s.handler.called = false; + s.handler.action = null; try s.nextSlice("\x1b[99W"); - try testing.expect(!s.handler.called); + try testing.expect(s.handler.action == null); } test "stream CSI ? W reset tab stops" { const H = struct { - reset: bool = false, - - pub fn tabReset(self: *@This()) !void { - self.reset = true; - } + action: ?Action.Key = null, pub fn vt( self: *@This(), comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; _ = value; + self.action = action; } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[?2W"); - try testing.expect(!s.handler.reset); + try testing.expect(s.handler.action == null); try s.nextSlice("\x1b[?5W"); - try testing.expect(s.handler.reset); + try testing.expectEqual(Action.Key.tab_reset, s.handler.action.?); // Invalid and ignored by the handler + s.handler.action = null; try s.nextSlice("\x1b[?1;2;3W"); - try testing.expect(s.handler.reset); + try testing.expect(s.handler.action == null); } test "stream: SGR with 17+ parameters for underline color" { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index a05949424..53da93f82 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -195,6 +195,7 @@ pub const StreamHandler = struct { ) !void { switch (action) { .print => 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), @@ -238,6 +239,10 @@ pub const StreamHandler = struct { .delete_lines => self.terminal.deleteLines(value), .scroll_up => self.terminal.scrollUp(value), .scroll_down => self.terminal.scrollDown(value), + .tab_clear_current => self.terminal.tabClear(.current), + .tab_clear_all => self.terminal.tabClear(.all), + .tab_set => self.terminal.tabSet(), + .tab_reset => self.terminal.tabReset(), } } @@ -377,10 +382,6 @@ pub const StreamHandler = struct { } } - pub inline fn printRepeat(self: *StreamHandler, count: usize) !void { - try self.terminal.printRepeat(count); - } - inline fn bell(self: *StreamHandler) void { self.surfaceMessageWriter(.ring_bell); } @@ -805,18 +806,6 @@ pub const StreamHandler = struct { try self.terminal.decaln(); } - pub inline fn tabClear(self: *StreamHandler, cmd: terminal.TabClear) !void { - self.terminal.tabClear(cmd); - } - - pub inline fn tabSet(self: *StreamHandler) !void { - self.terminal.tabSet(); - } - - pub inline fn tabReset(self: *StreamHandler) !void { - self.terminal.tabReset(); - } - pub inline fn saveCursor(self: *StreamHandler) !void { self.terminal.saveCursor(); } From 94a8fa05cbaf6b234d612385a5670eae758a50d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 20:07:39 -0700 Subject: [PATCH 174/702] terminal: convert modes --- src/terminal/stream.zig | 63 +++++++++++++++++++++-------------- src/termio/stream_handler.zig | 24 ++++++------- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 71d54e4f3..7ecd67750 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -69,6 +69,10 @@ pub const Action = union(Key) { tab_clear_all, tab_set, tab_reset, + set_mode: Mode, + reset_mode: Mode, + save_mode: Mode, + restore_mode: Mode, pub const Key = lib.Enum( lib_target, @@ -112,6 +116,10 @@ pub const Action = union(Key) { "tab_clear_all", "tab_set", "tab_reset", + "set_mode", + "reset_mode", + "save_mode", + "restore_mode", }, ); @@ -160,6 +168,16 @@ pub const Action = union(Key) { row: u16, col: u16, }; + + pub const Mode = struct { + mode: modes.Mode, + + pub const C = u16; + + pub fn cval(self: Mode) Mode.C { + return @bitCast(self.mode); + } + }; }; /// Returns a type that can process a stream of tty control characters. @@ -1022,7 +1040,7 @@ pub fn Stream(comptime Handler: type) type { }, // SM - Set Mode - 'h' => if (@hasDecl(T, "setMode")) mode: { + 'h' => mode: { const ansi_mode = ansi: { if (input.intermediates.len == 0) break :ansi true; if (input.intermediates.len == 1 and @@ -1034,15 +1052,15 @@ pub fn Stream(comptime Handler: type) type { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { - try self.handler.setMode(mode, true); + try self.handler.vt(.set_mode, .{ .mode = mode }); } else { log.warn("unimplemented mode: {}", .{mode_int}); } } - } else log.warn("unimplemented CSI callback: {f}", .{input}), + }, // RM - Reset Mode - 'l' => if (@hasDecl(T, "setMode")) mode: { + 'l' => mode: { const ansi_mode = ansi: { if (input.intermediates.len == 0) break :ansi true; if (input.intermediates.len == 1 and @@ -1054,12 +1072,12 @@ pub fn Stream(comptime Handler: type) type { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { - try self.handler.setMode(mode, false); + try self.handler.vt(.reset_mode, .{ .mode = mode }); } else { log.warn("unimplemented mode: {}", .{mode_int}); } } - } else log.warn("unimplemented CSI callback: {f}", .{input}), + }, // SGR - Select Graphic Rendition 'm' => switch (input.intermediates.len) { @@ -1304,10 +1322,10 @@ pub fn Stream(comptime Handler: type) type { 1 => switch (input.intermediates[0]) { // Restore Mode - '?' => if (@hasDecl(T, "restoreMode")) { + '?' => { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.restoreMode(mode); + try self.handler.vt(.restore_mode, .{ .mode = mode }); } else { log.warn( "unimplemented restore mode: {}", @@ -1348,10 +1366,10 @@ pub fn Stream(comptime Handler: type) type { ), 1 => switch (input.intermediates[0]) { - '?' => if (@hasDecl(T, "saveMode")) { + '?' => { for (input.params) |mode_int| { if (modes.modeFromInt(mode_int, false)) |mode| { - try self.handler.saveMode(mode); + try self.handler.vt(.save_mode, .{ .mode = mode }); } else { log.warn( "unimplemented save mode: {}", @@ -2091,19 +2109,17 @@ test "stream: cursor right (CUF)" { test "stream: dec set mode (SM) and reset mode (RM)" { const H = struct { mode: modes.Mode = @as(modes.Mode, @enumFromInt(1)), - pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { - self.mode = @as(modes.Mode, @enumFromInt(1)); - if (v) self.mode = mode; - } pub fn vt( self: *@This(), comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .set_mode => self.mode = value.mode, + .reset_mode => self.mode = @as(modes.Mode, @enumFromInt(1)), + else => {}, + } } }; @@ -2123,19 +2139,16 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { const H = struct { mode: ?modes.Mode = null, - pub fn setMode(self: *@This(), mode: modes.Mode, v: bool) !void { - self.mode = null; - if (v) self.mode = mode; - } - pub fn vt( self: *@This(), comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .set_mode => self.mode = value.mode, + .reset_mode => self.mode = null, + else => {}, + } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 53da93f82..226a6fde9 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -243,6 +243,16 @@ pub const StreamHandler = struct { .tab_clear_all => self.terminal.tabClear(.all), .tab_set => self.terminal.tabSet(), .tab_reset => self.terminal.tabReset(), + .set_mode => try self.setMode(value.mode, true), + .reset_mode => try self.setMode(value.mode, false), + .save_mode => self.terminal.modes.save(value.mode), + .restore_mode => { + // For restore mode we have to restore but if we set it, we + // always have to call setMode because setting some modes have + // side effects and we want to make sure we process those. + const v = self.terminal.modes.restore(value.mode); + try self.setMode(value.mode, v); + }, } } @@ -470,20 +480,6 @@ pub const StreamHandler = struct { self.messageWriter(msg); } - pub inline fn saveMode(self: *StreamHandler, mode: terminal.Mode) !void { - // log.debug("save mode={}", .{mode}); - self.terminal.modes.save(mode); - } - - pub inline fn restoreMode(self: *StreamHandler, mode: terminal.Mode) !void { - // For restore mode we have to restore but if we set it, we - // always have to call setMode because setting some modes have - // side effects and we want to make sure we process those. - const v = self.terminal.modes.restore(mode); - // log.debug("restore mode={} v={}", .{ mode, v }); - try self.setMode(mode, v); - } - pub fn setMode(self: *StreamHandler, mode: terminal.Mode, enabled: bool) !void { // Note: this function doesn't need to grab the render state or // terminal locks because it is only called from process() which From b6ac4c764f41db8dfcca4ac8a40053148c30b790 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 20:15:05 -0700 Subject: [PATCH 175/702] terminal: modify_other_keys --- src/terminal/ansi.zig | 21 +++++++++++++++------ src/terminal/stream.zig | 32 ++++++++++++++++++-------------- src/termio/stream_handler.zig | 6 ++---- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/terminal/ansi.zig b/src/terminal/ansi.zig index 590e9885a..7c18d933e 100644 --- a/src/terminal/ansi.zig +++ b/src/terminal/ansi.zig @@ -1,3 +1,7 @@ +const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + /// C0 (7-bit) control characters from ANSI. /// /// This is not complete, control characters are only added to this @@ -95,12 +99,17 @@ pub const StatusDisplay = enum(u16) { /// The possible modify key formats to ESC[>{a};{b}m /// Note: this is not complete, we should add more as we support more -pub const ModifyKeyFormat = union(enum) { - legacy: void, - cursor_keys: void, - function_keys: void, - other_keys: enum { none, numeric_except, numeric }, -}; +pub const ModifyKeyFormat = lib.Enum( + lib_target, + &.{ + "legacy", + "cursor_keys", + "function_keys", + "other_keys_none", + "other_keys_numeric_except", + "other_keys_numeric", + }, +); /// The protection modes that can be set for the terminal. See DECSCA and /// ESC V, W. diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 7ecd67750..2c237fbb0 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -73,6 +73,7 @@ pub const Action = union(Key) { reset_mode: Mode, save_mode: Mode, restore_mode: Mode, + modify_key_format: ansi.ModifyKeyFormat, pub const Key = lib.Enum( lib_target, @@ -120,6 +121,7 @@ pub const Action = union(Key) { "reset_mode", "save_mode", "restore_mode", + "modify_key_format", }, ); @@ -1094,18 +1096,18 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented CSI callback: {f}", .{input}), 1 => switch (input.intermediates[0]) { - '>' => if (@hasDecl(T, "setModifyKeyFormat")) blk: { + '>' => blk: { if (input.params.len == 0) { // Reset - try self.handler.setModifyKeyFormat(.{ .legacy = {} }); + 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 }, + 0 => .legacy, + 1 => .cursor_keys, + 2 => .function_keys, + 4 => .other_keys_none, else => { log.warn("invalid setModifyKeyFormat: {f}", .{input}); break :blk; @@ -1125,15 +1127,17 @@ pub fn Stream(comptime Handler: type) type { .function_keys => {}, // We only support the numeric form. - .other_keys => |*v| switch (input.params[1]) { - 2 => v.* = .numeric, - else => v.* = .none, + .other_keys_none => switch (input.params[1]) { + 2 => format = .other_keys_numeric, + else => {}, }, + .other_keys_numeric_except => {}, + .other_keys_numeric => {}, } } - try self.handler.setModifyKeyFormat(format); - } else log.warn("unimplemented setModifyKeyFormat: {f}", .{input}), + try self.handler.vt(.modify_key_format, format); + }, else => log.warn( "unknown CSI m with intermediate: {}", @@ -1194,13 +1198,13 @@ pub fn Stream(comptime Handler: type) type { 0 => unreachable, // handled above 1 => switch (input.intermediates[0]) { - '>' => if (@hasDecl(T, "setModifyKeyFormat")) { + '>' => { // This isn't strictly correct. CSI > n has parameters that // control what exactly is being disabled. However, we // only support reverting back to modify other keys in // numeric except format. - try self.handler.setModifyKeyFormat(.{ .other_keys = .numeric_except }); - } else log.warn("unimplemented setModifyKeyFormat: {f}", .{input}), + try self.handler.vt(.modify_key_format, .other_keys_numeric_except); + }, else => log.warn( "unknown CSI n with intermediate: {}", diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 226a6fde9..cb78ff264 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -253,6 +253,7 @@ pub const StreamHandler = struct { const v = self.terminal.modes.restore(value.mode); try self.setMode(value.mode, v); }, + .modify_key_format => try self.setModifyKeyFormat(value), } } @@ -450,10 +451,7 @@ pub const StreamHandler = struct { pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { self.terminal.flags.modify_other_keys_2 = false; switch (format) { - .other_keys => |v| switch (v) { - .numeric => self.terminal.flags.modify_other_keys_2 = true, - else => {}, - }, + .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true, else => {}, } } From 25eee9379db02e8eae33c0551dbb0a4eb7d8eb7c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 20:17:23 -0700 Subject: [PATCH 176/702] terminal: request mode --- src/terminal/stream.zig | 22 +++++++++++++++++++--- src/termio/stream_handler.zig | 28 ++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 2c237fbb0..d9ec4c59b 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -73,6 +73,8 @@ pub const Action = union(Key) { reset_mode: Mode, save_mode: Mode, restore_mode: Mode, + request_mode: Mode, + request_mode_unknown: RawMode, modify_key_format: ansi.ModifyKeyFormat, pub const Key = lib.Enum( @@ -121,6 +123,8 @@ pub const Action = union(Key) { "reset_mode", "save_mode", "restore_mode", + "request_mode", + "request_mode_unknown", "modify_key_format", }, ); @@ -180,6 +184,11 @@ pub const Action = union(Key) { return @bitCast(self.mode); } }; + + pub const RawMode = extern struct { + mode: u16, + ansi: bool, + }; }; /// Returns a type that can process a stream of tty control characters. @@ -1242,9 +1251,16 @@ pub fn Stream(comptime Handler: type) type { break :decrqm; } - if (@hasDecl(T, "requestMode")) { - try self.handler.requestMode(input.params[0], ansi_mode); - } else log.warn("unimplemented DECRQM callback: {f}", .{input}); + const mode_raw = input.params[0]; + const mode = modes.modeFromInt(mode_raw, ansi_mode); + if (mode) |m| { + try self.handler.vt(.request_mode, .{ .mode = m }); + } else { + try self.handler.vt(.request_mode_unknown, .{ + .mode = mode_raw, + .ansi = ansi_mode, + }); + } }, else => log.warn( diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index cb78ff264..644613626 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -253,6 +253,8 @@ pub const StreamHandler = struct { const v = self.terminal.modes.restore(value.mode); try self.setMode(value.mode, v); }, + .request_mode => try self.requestMode(value.mode), + .request_mode_unknown => try self.requestModeUnknown(value.mode, value.ansi), .modify_key_format => try self.setModifyKeyFormat(value), } } @@ -456,22 +458,32 @@ pub const StreamHandler = struct { } } - pub fn requestMode(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { - // Get the mode value and respond. - const code: u8 = code: { - const mode = terminal.modes.modeFromInt(mode_raw, ansi) orelse break :code 0; - if (self.terminal.modes.get(mode)) break :code 1; - break :code 2; - }; + fn requestMode(self: *StreamHandler, mode: terminal.Mode) !void { + const tag: terminal.modes.ModeTag = @bitCast(@intFromEnum(mode)); + const code: u8 = if (self.terminal.modes.get(mode)) 1 else 2; var msg: termio.Message = .{ .write_small = .{} }; const resp = try std.fmt.bufPrint( &msg.write_small.data, "\x1B[{s}{};{}$y", + .{ + if (tag.ansi) "" else "?", + tag.value, + code, + }, + ); + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } + + fn requestModeUnknown(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { + var msg: termio.Message = .{ .write_small = .{} }; + const resp = try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B[{s}{};0$y", .{ if (ansi) "" else "?", mode_raw, - code, }, ); msg.write_small.len = @intCast(resp.len); From c1e57dd3304bf635c32e2d9069cbafd7cfcd70cd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 20:27:52 -0700 Subject: [PATCH 177/702] terminal: setprotectedmode --- src/terminal/stream.zig | 70 ++++++++++++++++++++--------------- src/termio/stream_handler.zig | 7 ++-- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index d9ec4c59b..21e7d1f51 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -76,6 +76,9 @@ pub const Action = union(Key) { request_mode: Mode, request_mode_unknown: RawMode, modify_key_format: ansi.ModifyKeyFormat, + protected_mode_off, + protected_mode_iso, + protected_mode_dec, pub const Key = lib.Enum( lib_target, @@ -126,6 +129,9 @@ pub const Action = union(Key) { "request_mode", "request_mode_unknown", "modify_key_format", + "protected_mode_off", + "protected_mode_iso", + "protected_mode_dec", }, ); @@ -1288,24 +1294,26 @@ pub fn Stream(comptime Handler: type) type { // DECSCA '"' => { - if (@hasDecl(T, "setProtectedMode")) { - const mode_: ?ansi.ProtectedMode = switch (input.params.len) { + const mode_: ?ansi.ProtectedMode = switch (input.params.len) { + else => null, + 0 => .off, + 1 => switch (input.params[0]) { + 0, 2 => .off, + 1 => .dec, else => null, - 0 => .off, - 1 => switch (input.params[0]) { - 0, 2 => .off, - 1 => .dec, - else => null, - }, - }; + }, + }; - const mode = mode_ orelse { - log.warn("invalid set protected mode command: {f}", .{input}); - return; - }; + const mode = mode_ orelse { + log.warn("invalid set protected mode command: {f}", .{input}); + return; + }; - try self.handler.setProtectedMode(mode); - } else log.warn("unimplemented CSI callback: {f}", .{input}); + switch (mode) { + .off => try self.handler.vt(.protected_mode_off, {}), + .iso => try self.handler.vt(.protected_mode_iso, {}), + .dec => try self.handler.vt(.protected_mode_dec, {}), + } }, // XTVERSION @@ -1930,14 +1938,16 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented invokeCharset: {f}", .{action}), // SPA - Start of Guarded Area - 'V' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) { - try self.handler.setProtectedMode(ansi.ProtectedMode.iso); - } else log.warn("unimplemented ESC callback: {f}", .{action}), + 'V' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.protected_mode_iso, {}), + else => log.warn("unimplemented ESC callback: {f}", .{action}), + }, // EPA - End of Guarded Area - 'W' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) { - try self.handler.setProtectedMode(ansi.ProtectedMode.off); - } else log.warn("unimplemented ESC callback: {f}", .{action}), + 'W' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.protected_mode_off, {}), + else => log.warn("unimplemented ESC callback: {f}", .{action}), + }, // DECID 'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) { @@ -2269,18 +2279,18 @@ test "stream: DECSCA" { const Self = @This(); v: ?ansi.ProtectedMode = null, - pub fn setProtectedMode(self: *Self, v: ansi.ProtectedMode) !void { - self.v = v; - } - pub fn vt( - self: *@This(), - comptime action: anytype, - value: anytype, + self: *Self, + comptime action: Stream(Self).Action.Tag, + value: Stream(Self).Action.Value(action), ) !void { - _ = self; - _ = action; _ = value; + switch (action) { + .protected_mode_off => self.v = .off, + .protected_mode_iso => self.v = .iso, + .protected_mode_dec => self.v = .dec, + else => {}, + } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 644613626..d66223081 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -256,6 +256,9 @@ pub const StreamHandler = struct { .request_mode => try self.requestMode(value.mode), .request_mode_unknown => try self.requestModeUnknown(value.mode, value.ansi), .modify_key_format => try self.setModifyKeyFormat(value), + .protected_mode_off => self.terminal.setProtectedMode(.off), + .protected_mode_iso => self.terminal.setProtectedMode(.iso), + .protected_mode_dec => self.terminal.setProtectedMode(.dec), } } @@ -804,10 +807,6 @@ pub const StreamHandler = struct { } } - pub inline fn setProtectedMode(self: *StreamHandler, mode: terminal.ProtectedMode) !void { - self.terminal.setProtectedMode(mode); - } - pub inline fn decaln(self: *StreamHandler) !void { try self.terminal.decaln(); } From 0df4d5c5a45846845468fb1ea7c75ed72e311027 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 20:37:32 -0700 Subject: [PATCH 178/702] terminal: margins --- src/lib/union.zig | 2 +- src/terminal/stream.zig | 95 ++++++++++++++++------------------- src/termio/stream_handler.zig | 25 ++++----- 3 files changed, 52 insertions(+), 70 deletions(-) diff --git a/src/lib/union.zig b/src/lib/union.zig index 8e9f09049..7e15aa84d 100644 --- a/src/lib/union.zig +++ b/src/lib/union.zig @@ -121,7 +121,7 @@ pub fn TaggedUnion( /// Returns the value type for the given tag. pub fn Value(comptime tag: Tag) type { - @setEvalBranchQuota(2000); + @setEvalBranchQuota(10000); inline for (@typeInfo(Union).@"union".fields) |field| { const field_tag = @field(Tag, field.name); if (field_tag == tag) return field.type; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 21e7d1f51..4b92e38f9 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -75,6 +75,9 @@ pub const Action = union(Key) { restore_mode: Mode, request_mode: Mode, request_mode_unknown: RawMode, + top_and_bottom_margin: Margin, + left_and_right_margin: Margin, + left_and_right_margin_ambiguous, modify_key_format: ansi.ModifyKeyFormat, protected_mode_off, protected_mode_iso, @@ -128,6 +131,9 @@ pub const Action = union(Key) { "restore_mode", "request_mode", "request_mode_unknown", + "top_and_bottom_margin", + "left_and_right_margin", + "left_and_right_margin_ambiguous", "modify_key_format", "protected_mode_off", "protected_mode_iso", @@ -195,6 +201,11 @@ pub const Action = union(Key) { mode: u16, ansi: bool, }; + + pub const Margin = extern struct { + top_left: u16, + bottom_right: u16, + }; }; /// Returns a type that can process a stream of tty control characters. @@ -1336,17 +1347,12 @@ pub fn Stream(comptime Handler: type) type { 'r' => switch (input.intermediates.len) { // DECSTBM - Set Top and Bottom Margins - 0 => if (@hasDecl(T, "setTopAndBottomMargin")) { - switch (input.params.len) { - 0 => try self.handler.setTopAndBottomMargin(0, 0), - 1 => try self.handler.setTopAndBottomMargin(input.params[0], 0), - 2 => try self.handler.setTopAndBottomMargin(input.params[0], input.params[1]), - else => log.warn("invalid DECSTBM command: {f}", .{input}), - } - } else log.warn( - "unimplemented CSI callback: {f}", - .{input}, - ), + 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}), + }, 1 => switch (input.intermediates[0]) { // Restore Mode @@ -1377,21 +1383,16 @@ pub fn Stream(comptime Handler: type) type { 's' => switch (input.intermediates.len) { // DECSLRM - 0 => if (@hasDecl(T, "setLeftAndRightMargin")) { - switch (input.params.len) { - // CSI S is ambiguous with zero params so we defer - // to our handler to do the proper logic. If mode 69 - // is set, then we should invoke DECSLRM, otherwise - // we should invoke SC. - 0 => try self.handler.setLeftAndRightMarginAmbiguous(), - 1 => try self.handler.setLeftAndRightMargin(input.params[0], 0), - 2 => try self.handler.setLeftAndRightMargin(input.params[0], input.params[1]), - else => log.warn("invalid DECSLRM command: {f}", .{input}), - } - } else log.warn( - "unimplemented CSI callback: {f}", - .{input}, - ), + 0 => switch (input.params.len) { + // CSI S is ambiguous with zero params so we defer + // to our handler to do the proper logic. If mode 69 + // is set, then we should invoke DECSLRM, otherwise + // we should invoke SC. + 0 => try self.handler.vt(.left_and_right_margin_ambiguous, {}), + 1 => try self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = 0 }), + 2 => try self.handler.vt(.left_and_right_margin, .{ .top_left = input.params[0], .bottom_right = input.params[1] }), + else => log.warn("invalid DECSLRM command: {f}", .{input}), + }, 1 => switch (input.intermediates[0]) { '?' => { @@ -2227,20 +2228,16 @@ test "stream: restore mode" { const Self = @This(); called: bool = false, - pub fn setTopAndBottomMargin(self: *Self, t: u16, b: u16) !void { - _ = t; - _ = b; - self.called = true; - } - pub fn vt( - self: *@This(), - comptime action: anytype, - value: anytype, + self: *Self, + comptime action: Stream(Self).Action.Tag, + value: Stream(Self).Action.Value(action), ) !void { - _ = self; - _ = action; _ = value; + switch (action) { + .top_and_bottom_margin => self.called = true, + else => {}, + } } }; @@ -2654,25 +2651,17 @@ test "stream: SCOSC" { const Self = @This(); called: bool = false, - pub fn setLeftAndRightMargin(self: *Self, left: u16, right: u16) !void { - _ = self; - _ = left; - _ = right; - @panic("bad"); - } - - pub fn setLeftAndRightMarginAmbiguous(self: *Self) !void { - self.called = true; - } - pub fn vt( - self: *@This(), - comptime action: anytype, - value: anytype, + self: *Self, + comptime action: Stream(Self).Action.Tag, + value: Stream(Self).Action.Value(action), ) !void { - _ = self; - _ = action; _ = value; + switch (action) { + .left_and_right_margin => @panic("bad"), + .left_and_right_margin_ambiguous => self.called = true, + else => {}, + } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index d66223081..e7e965e25 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -255,6 +255,15 @@ pub const StreamHandler = struct { }, .request_mode => try self.requestMode(value.mode), .request_mode_unknown => try self.requestModeUnknown(value.mode, value.ansi), + .top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right), + .left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right), + .left_and_right_margin_ambiguous => { + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + self.terminal.setLeftAndRightMargin(0, 0); + } else { + self.terminal.saveCursor(); + } + }, .modify_key_format => try self.setModifyKeyFormat(value), .protected_mode_off => self.terminal.setProtectedMode(.off), .protected_mode_iso => self.terminal.setProtectedMode(.iso), @@ -437,22 +446,6 @@ pub const StreamHandler = struct { self.terminal.carriageReturn(); } - pub inline fn setTopAndBottomMargin(self: *StreamHandler, top: u16, bot: u16) !void { - self.terminal.setTopAndBottomMargin(top, bot); - } - - pub inline fn setLeftAndRightMarginAmbiguous(self: *StreamHandler) !void { - if (self.terminal.modes.get(.enable_left_and_right_margin)) { - try self.setLeftAndRightMargin(0, 0); - } else { - try self.saveCursor(); - } - } - - pub inline fn setLeftAndRightMargin(self: *StreamHandler, left: u16, right: u16) !void { - self.terminal.setLeftAndRightMargin(left, right); - } - pub fn setModifyKeyFormat(self: *StreamHandler, format: terminal.ModifyKeyFormat) !void { self.terminal.flags.modify_other_keys_2 = false; switch (format) { From b7ea979f38e7255fcdbe77430bdc6071ba6cd778 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 20:41:24 -0700 Subject: [PATCH 179/702] terminal: zero-arg actions --- src/terminal/stream.zig | 57 ++++++++++++++++++----------------- src/termio/stream_handler.zig | 8 +++++ 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 4b92e38f9..e85176dbd 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -78,10 +78,18 @@ pub const Action = union(Key) { top_and_bottom_margin: Margin, left_and_right_margin: Margin, left_and_right_margin_ambiguous, + save_cursor, + restore_cursor, modify_key_format: ansi.ModifyKeyFormat, protected_mode_off, protected_mode_iso, protected_mode_dec, + xtversion, + kitty_keyboard_query, + prompt_end, + end_of_input, + end_hyperlink, + decaln, pub const Key = lib.Enum( lib_target, @@ -134,10 +142,18 @@ pub const Action = union(Key) { "top_and_bottom_margin", "left_and_right_margin", "left_and_right_margin_ambiguous", + "save_cursor", + "restore_cursor", "modify_key_format", "protected_mode_off", "protected_mode_iso", "protected_mode_dec", + "xtversion", + "kitty_keyboard_query", + "prompt_end", + "end_of_input", + "end_hyperlink", + "decaln", }, ); @@ -1328,9 +1344,7 @@ pub fn Stream(comptime Handler: type) type { }, // XTVERSION - '>' => { - if (@hasDecl(T, "reportXtversion")) try self.handler.reportXtversion(); - }, + '>' => try self.handler.vt(.xtversion, {}), else => { log.warn( "ignoring unimplemented CSI q with intermediates: {s}", @@ -1548,9 +1562,7 @@ pub fn Stream(comptime Handler: type) type { // Kitty keyboard protocol 1 => switch (input.intermediates[0]) { - '?' => if (@hasDecl(T, "queryKittyKeyboard")) { - try self.handler.queryKittyKeyboard(); - }, + '?' => try self.handler.vt(.kitty_keyboard_query, {}), '>' => if (@hasDecl(T, "pushKittyKeyboard")) push: { const flags: u5 = if (input.params.len == 1) @@ -1698,17 +1710,11 @@ pub fn Stream(comptime Handler: type) type { }, .prompt_end => { - if (@hasDecl(T, "promptEnd")) { - try self.handler.promptEnd(); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.prompt_end, {}); }, .end_of_input => { - if (@hasDecl(T, "endOfInput")) { - try self.handler.endOfInput(); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.end_of_input, {}); }, .end_of_command => |end| { @@ -1770,10 +1776,7 @@ pub fn Stream(comptime Handler: type) type { }, .hyperlink_end => { - if (@hasDecl(T, "endHyperlink")) { - try self.handler.endHyperlink(); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.end_hyperlink, {}); }, .conemu_progress_report => |v| { @@ -1852,28 +1855,28 @@ pub fn Stream(comptime Handler: type) type { '0' => try self.configureCharset(action.intermediates, .dec_special), // DECSC - Save Cursor - '7' => if (@hasDecl(T, "saveCursor")) switch (action.intermediates.len) { - 0 => try self.handler.saveCursor(), + '7' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.save_cursor, {}), else => { log.warn("invalid command: {f}", .{action}); return; }, - } else log.warn("unimplemented ESC callback: {f}", .{action}), + }, '8' => blk: { switch (action.intermediates.len) { // DECRC - Restore Cursor - 0 => if (@hasDecl(T, "restoreCursor")) { - try self.handler.restoreCursor(); + 0 => { + try self.handler.vt(.restore_cursor, {}); break :blk {}; - } else log.warn("unimplemented restore cursor callback: {f}", .{action}), + }, 1 => switch (action.intermediates[0]) { // DECALN - Fill Screen with E - '#' => if (@hasDecl(T, "decaln")) { - try self.handler.decaln(); + '#' => { + try self.handler.vt(.decaln, {}); break :blk {}; - } else log.warn("unimplemented ESC callback: {f}", .{action}), + }, else => {}, }, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index e7e965e25..68c7d00b7 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -264,10 +264,18 @@ pub const StreamHandler = struct { self.terminal.saveCursor(); } }, + .save_cursor => try self.saveCursor(), + .restore_cursor => try self.restoreCursor(), .modify_key_format => try self.setModifyKeyFormat(value), .protected_mode_off => self.terminal.setProtectedMode(.off), .protected_mode_iso => self.terminal.setProtectedMode(.iso), .protected_mode_dec => self.terminal.setProtectedMode(.dec), + .xtversion => try self.reportXtversion(), + .kitty_keyboard_query => try self.queryKittyKeyboard(), + .prompt_end => try self.promptEnd(), + .end_of_input => try self.endOfInput(), + .end_hyperlink => try self.endHyperlink(), + .decaln => try self.decaln(), } } From 2520e27aefddfb681c7e306ffac26c470c494fa6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 20:49:50 -0700 Subject: [PATCH 180/702] terminal: kitty keyboard actions --- src/terminal/stream.zig | 66 +++++++++++++++++++++++------------ src/termio/stream_handler.zig | 42 +++++++++++----------- 2 files changed, 63 insertions(+), 45 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index e85176dbd..b74764fb4 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -86,6 +86,11 @@ pub const Action = union(Key) { protected_mode_dec, xtversion, kitty_keyboard_query, + kitty_keyboard_push: KittyKeyboardFlags, + kitty_keyboard_pop: u16, + kitty_keyboard_set: KittyKeyboardFlags, + kitty_keyboard_set_or: KittyKeyboardFlags, + kitty_keyboard_set_not: KittyKeyboardFlags, prompt_end, end_of_input, end_hyperlink, @@ -150,6 +155,11 @@ pub const Action = union(Key) { "protected_mode_dec", "xtversion", "kitty_keyboard_query", + "kitty_keyboard_push", + "kitty_keyboard_pop", + "kitty_keyboard_set", + "kitty_keyboard_set_or", + "kitty_keyboard_set_not", "prompt_end", "end_of_input", "end_hyperlink", @@ -222,6 +232,16 @@ pub const Action = union(Key) { top_left: u16, bottom_right: u16, }; + + pub const KittyKeyboardFlags = struct { + flags: kitty.KeyFlags, + + pub const C = u8; + + pub fn cval(self: KittyKeyboardFlags) KittyKeyboardFlags.C { + return @intCast(self.flags.int()); + } + }; }; /// Returns a type that can process a stream of tty control characters. @@ -1564,7 +1584,7 @@ pub fn Stream(comptime Handler: type) type { 1 => switch (input.intermediates[0]) { '?' => try self.handler.vt(.kitty_keyboard_query, {}), - '>' => if (@hasDecl(T, "pushKittyKeyboard")) push: { + '>' => push: { const flags: u5 = if (input.params.len == 1) std.math.cast(u5, input.params[0]) orelse { log.warn("invalid pushKittyKeyboard command: {f}", .{input}); @@ -1573,19 +1593,19 @@ pub fn Stream(comptime Handler: type) type { else 0; - try self.handler.pushKittyKeyboard(@bitCast(flags)); + try self.handler.vt(.kitty_keyboard_push, .{ .flags = @as(kitty.KeyFlags, @bitCast(flags)) }); }, - '<' => if (@hasDecl(T, "popKittyKeyboard")) { + '<' => { const number: u16 = if (input.params.len == 1) input.params[0] else 1; - try self.handler.popKittyKeyboard(number); + try self.handler.vt(.kitty_keyboard_pop, number); }, - '=' => if (@hasDecl(T, "setKittyKeyboard")) set: { + '=' => set: { const flags: u5 = if (input.params.len >= 1) std.math.cast(u5, input.params[0]) orelse { log.warn("invalid setKittyKeyboard command: {f}", .{input}); @@ -1599,20 +1619,23 @@ pub fn Stream(comptime Handler: type) type { else 1; - const mode: kitty.KeySetMode = switch (number) { - 1 => .set, - 2 => .@"or", - 3 => .not, + const action_tag: streampkg.Action.Tag = switch (number) { + 1 => .kitty_keyboard_set, + 2 => .kitty_keyboard_set_or, + 3 => .kitty_keyboard_set_not, else => { log.warn("invalid setKittyKeyboard command: {f}", .{input}); break :set; }, }; - try self.handler.setKittyKeyboard( - mode, - @bitCast(flags), - ); + const kitty_flags: streampkg.Action.KittyKeyboardFlags = .{ .flags = @as(kitty.KeyFlags, @bitCast(flags)) }; + switch (action_tag) { + .kitty_keyboard_set => try self.handler.vt(.kitty_keyboard_set, kitty_flags), + .kitty_keyboard_set_or => try self.handler.vt(.kitty_keyboard_set_or, kitty_flags), + .kitty_keyboard_set_not => try self.handler.vt(.kitty_keyboard_set_not, kitty_flags), + else => unreachable, + } }, else => log.warn( @@ -2254,18 +2277,15 @@ test "stream: pop kitty keyboard with no params defaults to 1" { const Self = @This(); n: u16 = 0, - pub fn popKittyKeyboard(self: *Self, n: u16) !void { - self.n = n; - } - pub fn vt( - self: *@This(), - comptime action: anytype, - value: anytype, + self: *Self, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .kitty_keyboard_pop => self.n = value, + else => {}, + } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 68c7d00b7..ba9746ca2 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -272,6 +272,26 @@ pub const StreamHandler = struct { .protected_mode_dec => self.terminal.setProtectedMode(.dec), .xtversion => try self.reportXtversion(), .kitty_keyboard_query => try self.queryKittyKeyboard(), + .kitty_keyboard_push => { + log.debug("pushing kitty keyboard mode: {}", .{value.flags}); + self.terminal.screen.kitty_keyboard.push(value.flags); + }, + .kitty_keyboard_pop => { + log.debug("popping kitty keyboard mode n={}", .{value}); + self.terminal.screen.kitty_keyboard.pop(@intCast(value)); + }, + .kitty_keyboard_set => { + log.debug("setting kitty keyboard mode: set {}", .{value.flags}); + self.terminal.screen.kitty_keyboard.set(.set, value.flags); + }, + .kitty_keyboard_set_or => { + log.debug("setting kitty keyboard mode: or {}", .{value.flags}); + self.terminal.screen.kitty_keyboard.set(.@"or", value.flags); + }, + .kitty_keyboard_set_not => { + log.debug("setting kitty keyboard mode: not {}", .{value.flags}); + self.terminal.screen.kitty_keyboard.set(.not, value.flags); + }, .prompt_end => try self.promptEnd(), .end_of_input => try self.endOfInput(), .end_hyperlink => try self.endHyperlink(), @@ -865,28 +885,6 @@ pub const StreamHandler = struct { }); } - pub fn pushKittyKeyboard( - self: *StreamHandler, - flags: terminal.kitty.KeyFlags, - ) !void { - log.debug("pushing kitty keyboard mode: {}", .{flags}); - self.terminal.screen.kitty_keyboard.push(flags); - } - - pub fn popKittyKeyboard(self: *StreamHandler, n: u16) !void { - log.debug("popping kitty keyboard mode n={}", .{n}); - self.terminal.screen.kitty_keyboard.pop(@intCast(n)); - } - - pub fn setKittyKeyboard( - self: *StreamHandler, - mode: terminal.kitty.KeySetMode, - flags: terminal.kitty.KeyFlags, - ) !void { - log.debug("setting kitty keyboard mode: {} {}", .{ mode, flags }); - self.terminal.screen.kitty_keyboard.set(mode, flags); - } - pub fn reportXtversion( self: *StreamHandler, ) !void { From f68ea7c907d93cb22d2c3c457bb79b60da1bc8a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 20:59:57 -0700 Subject: [PATCH 181/702] terminal: many more conversions --- src/terminal/csi.zig | 21 ++- src/terminal/stream.zig | 291 +++++++++++++--------------------- src/termio/stream_handler.zig | 11 +- 3 files changed, 129 insertions(+), 194 deletions(-) diff --git a/src/terminal/csi.zig b/src/terminal/csi.zig index 0cab9ed52..d2f4bd6f8 100644 --- a/src/terminal/csi.zig +++ b/src/terminal/csi.zig @@ -1,3 +1,7 @@ +const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + /// Modes for the ED CSI command. pub const EraseDisplay = enum(u8) { below = 0, @@ -33,13 +37,16 @@ pub const TabClear = enum(u8) { }; /// Style formats for terminal size reports. -pub const SizeReportStyle = enum { - // XTWINOPS - csi_14_t, - csi_16_t, - csi_18_t, - csi_21_t, -}; +pub const SizeReportStyle = lib.Enum( + lib_target, + &.{ + // XTWINOPS + "csi_14_t", + "csi_16_t", + "csi_18_t", + "csi_21_t", + }, +); /// XTWINOPS CSI 22/23 pub const TitlePushPop = struct { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index b74764fb4..7ccb597b8 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -81,9 +81,13 @@ pub const Action = union(Key) { save_cursor, restore_cursor, modify_key_format: ansi.ModifyKeyFormat, + mouse_shift_capture: bool, protected_mode_off, protected_mode_iso, protected_mode_dec, + size_report: csi.SizeReportStyle, + title_push: u16, + title_pop: u16, xtversion, kitty_keyboard_query, kitty_keyboard_push: KittyKeyboardFlags, @@ -150,9 +154,13 @@ pub const Action = union(Key) { "save_cursor", "restore_cursor", "modify_key_format", + "mouse_shift_capture", "protected_mode_off", "protected_mode_iso", "protected_mode_dec", + "size_report", + "title_push", + "title_pop", "xtversion", "kitty_keyboard_query", "kitty_keyboard_push", @@ -1443,7 +1451,7 @@ pub fn Stream(comptime Handler: type) type { }, // XTSHIFTESCAPE - '>' => if (@hasDecl(T, "setMouseShiftCapture")) capture: { + '>' => capture: { const capture = switch (input.params.len) { 0 => false, 1 => switch (input.params[0]) { @@ -1460,11 +1468,8 @@ pub fn Stream(comptime Handler: type) type { }, }; - try self.handler.setMouseShiftCapture(capture); - } else log.warn( - "unimplemented CSI callback: {f}", - .{input}, - ), + try self.handler.vt(.mouse_shift_capture, capture); + }, else => log.warn( "unknown CSI s with intermediate: {f}", @@ -1485,48 +1490,28 @@ pub fn Stream(comptime Handler: type) type { switch (input.params[0]) { 14 => if (input.params.len == 1) { // report the text area size in pixels - if (@hasDecl(T, "sendSizeReport")) { - self.handler.sendSizeReport(.csi_14_t); - } else log.warn( - "ignoring unimplemented CSI 14 t", - .{}, - ); + try self.handler.vt(.size_report, .csi_14_t); } else log.warn( "ignoring CSI 14 t with extra parameters: {f}", .{input}, ), 16 => if (input.params.len == 1) { // report cell size in pixels - if (@hasDecl(T, "sendSizeReport")) { - self.handler.sendSizeReport(.csi_16_t); - } else log.warn( - "ignoring unimplemented CSI 16 t", - .{}, - ); + try self.handler.vt(.size_report, .csi_16_t); } else log.warn( "ignoring CSI 16 t with extra parameters: {f}", .{input}, ), 18 => if (input.params.len == 1) { // report screen size in characters - if (@hasDecl(T, "sendSizeReport")) { - self.handler.sendSizeReport(.csi_18_t); - } else log.warn( - "ignoring unimplemented CSI 18 t", - .{}, - ); + try self.handler.vt(.size_report, .csi_18_t); } else log.warn( "ignoring CSI 18 t with extra parameters: {f}", .{input}, ), 21 => if (input.params.len == 1) { // report window title - if (@hasDecl(T, "sendSizeReport")) { - self.handler.sendSizeReport(.csi_21_t); - } else log.warn( - "ignoring unimplemented CSI 21 t", - .{}, - ); + try self.handler.vt(.size_report, .csi_21_t); } else log.warn( "ignoring CSI 21 t with extra parameters: {f}", .{input}, @@ -1538,22 +1523,15 @@ pub fn Stream(comptime Handler: type) type { input.params[1] == 2)) { // push/pop title - if (@hasDecl(T, "pushPopTitle")) { - self.handler.pushPopTitle(.{ - .op = switch (number) { - 22 => .push, - 23 => .pop, - else => @compileError("unreachable"), - }, - .index = if (input.params.len == 3) - input.params[2] - else - 0, - }); - } else log.warn( - "ignoring unimplemented CSI 22/23 t", - .{}, - ); + const index: u16 = if (input.params.len == 3) + input.params[2] + else + 0; + switch (number) { + 22 => try self.handler.vt(.title_push, index), + 23 => try self.handler.vt(.title_pop, index), + else => @compileError("unreachable"), + } } else log.warn( "ignoring CSI 22/23 t with extra parameters: {f}", .{input}, @@ -1575,10 +1553,7 @@ pub fn Stream(comptime Handler: type) type { }, 'u' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "restoreCursor")) - try self.handler.restoreCursor() - else - log.warn("unimplemented CSI callback: {f}", .{input}), + 0 => try self.handler.vt(.restore_cursor, {}), // Kitty keyboard protocol 1 => switch (input.intermediates[0]) { @@ -2575,18 +2550,15 @@ test "stream: XTSHIFTESCAPE" { const H = struct { escape: ?bool = null, - pub fn setMouseShiftCapture(self: *@This(), v: bool) !void { - self.escape = v; - } - pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .mouse_shift_capture => self.escape = value, + else => {}, + } } }; @@ -2698,18 +2670,16 @@ test "stream: SCORC" { const Self = @This(); called: bool = false, - pub fn restoreCursor(self: *Self) !void { - self.called = true; - } - pub fn vt( - self: *@This(), - comptime action: anytype, - value: anytype, + self: *Self, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; _ = value; + switch (action) { + .restore_cursor => self.called = true, + else => {}, + } } }; @@ -2759,18 +2729,15 @@ test "stream: send report with CSI t" { const H = struct { style: ?csi.SizeReportStyle = null, - pub fn sendSizeReport(self: *@This(), style: csi.SizeReportStyle) void { - self.style = style; - } - pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .size_report => self.style = value, + else => {}, + } } }; @@ -2816,220 +2783,178 @@ test "stream: invalid CSI t" { test "stream: CSI t push title" { const H = struct { - op: ?csi.TitlePushPop = null, - - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; - } + index: ?u16 = null, pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .title_push => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[22;0t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .push, - .index = 0, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 0), s.handler.index.?); } test "stream: CSI t push title with explicit window" { const H = struct { - op: ?csi.TitlePushPop = null, - - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; - } + index: ?u16 = null, pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .title_push => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[22;2t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .push, - .index = 0, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 0), s.handler.index.?); } test "stream: CSI t push title with explicit icon" { const H = struct { - op: ?csi.TitlePushPop = null, - - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; - } + index: ?u16 = null, pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .title_push => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[22;1t"); - try testing.expectEqual(null, s.handler.op); + try testing.expectEqual(null, s.handler.index); } test "stream: CSI t push title with index" { const H = struct { - op: ?csi.TitlePushPop = null, - - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; - } + index: ?u16 = null, pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .title_push => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[22;0;5t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .push, - .index = 5, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 5), s.handler.index.?); } test "stream: CSI t pop title" { const H = struct { - op: ?csi.TitlePushPop = null, - - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; - } + index: ?u16 = null, pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .title_pop => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[23;0t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .pop, - .index = 0, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 0), s.handler.index.?); } test "stream: CSI t pop title with explicit window" { const H = struct { - op: ?csi.TitlePushPop = null, - - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; - } + index: ?u16 = null, pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .title_pop => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[23;2t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .pop, - .index = 0, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 0), s.handler.index.?); } test "stream: CSI t pop title with explicit icon" { const H = struct { - op: ?csi.TitlePushPop = null, - - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; - } + index: ?u16 = null, pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .title_pop => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[23;1t"); - try testing.expectEqual(null, s.handler.op); + try testing.expectEqual(null, s.handler.index); } test "stream: CSI t pop title with index" { const H = struct { - op: ?csi.TitlePushPop = null, - - pub fn pushPopTitle(self: *@This(), op: csi.TitlePushPop) void { - self.op = op; - } + index: ?u16 = null, pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: streampkg.Action.Tag, + value: streampkg.Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .title_pop => self.index = value, + else => {}, + } } }; var s: Stream(H) = .init(.{}); try s.nextSlice("\x1b[23;0;5t"); - try testing.expectEqual(csi.TitlePushPop{ - .op = .pop, - .index = 5, - }, s.handler.op.?); + try testing.expectEqual(@as(u16, 5), s.handler.index.?); } test "stream CSI W clear tab stops" { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ba9746ca2..b7fcd5834 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -270,6 +270,8 @@ pub const StreamHandler = struct { .protected_mode_off => self.terminal.setProtectedMode(.off), .protected_mode_iso => self.terminal.setProtectedMode(.iso), .protected_mode_dec => self.terminal.setProtectedMode(.dec), + .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, + .size_report => self.sendSizeReport(value), .xtversion => try self.reportXtversion(), .kitty_keyboard_query => try self.queryKittyKeyboard(), .kitty_keyboard_push => { @@ -296,6 +298,11 @@ pub const StreamHandler = struct { .end_of_input => try self.endOfInput(), .end_hyperlink => try self.endHyperlink(), .decaln => try self.decaln(), + + // Unimplemented + .title_push, + .title_pop, + => {}, } } @@ -692,10 +699,6 @@ pub const StreamHandler = struct { } } - pub inline fn setMouseShiftCapture(self: *StreamHandler, v: bool) !void { - self.terminal.flags.mouse_shift_capture = if (v) .true else .false; - } - pub inline fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { switch (attr) { .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), From 9cd45943568f102ff8db9ea7866e7ac103215d8e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 21:11:21 -0700 Subject: [PATCH 182/702] terminal: active status display --- src/terminal/ansi.zig | 11 +++++++---- src/terminal/stream.zig | 37 +++++++++++++++++++---------------- src/termio/stream_handler.zig | 8 +------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/terminal/ansi.zig b/src/terminal/ansi.zig index 7c18d933e..e4b2613d8 100644 --- a/src/terminal/ansi.zig +++ b/src/terminal/ansi.zig @@ -92,10 +92,13 @@ pub const StatusLineType = enum(u16) { }; /// The display to target for status updates (DECSASD). -pub const StatusDisplay = enum(u16) { - main = 0, - status_line = 1, -}; +pub const StatusDisplay = lib.Enum( + lib_target, + &.{ + "main", + "status_line", + }, +); /// The possible modify key formats to ESC[>{a};{b}m /// Note: this is not complete, we should add more as we support more diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 7ccb597b8..45bf1e25d 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -98,6 +98,7 @@ pub const Action = union(Key) { prompt_end, end_of_input, end_hyperlink, + active_status_display: ansi.StatusDisplay, decaln, pub const Key = lib.Enum( @@ -171,6 +172,7 @@ pub const Action = union(Key) { "prompt_end", "end_of_input", "end_hyperlink", + "active_status_display", "decaln", }, ); @@ -1643,26 +1645,27 @@ pub fn Stream(comptime Handler: type) type { }, // DECSASD - Select Active Status Display - '}' => { - const success = decsasd: { - // Verify we're getting a DECSASD command - if (input.intermediates.len != 1 or input.intermediates[0] != '$') - break :decsasd false; - if (input.params.len != 1) - break :decsasd false; - if (!@hasDecl(T, "setActiveStatusDisplay")) - break :decsasd false; + '}' => decsasd: { + // Verify we're getting a DECSASD command + if (input.intermediates.len != 1 or input.intermediates[0] != '$') { + log.warn("unimplemented CSI callback: {f}", .{input}); + break :decsasd; + } + if (input.params.len != 1) { + log.warn("unimplemented CSI callback: {f}", .{input}); + break :decsasd; + } - const display = std.meta.intToEnum( - ansi.StatusDisplay, - input.params[0], - ) catch break :decsasd false; - - try self.handler.setActiveStatusDisplay(display); - break :decsasd true; + const display: ansi.StatusDisplay = switch (input.params[0]) { + 0 => .main, + 1 => .status_line, + else => { + log.warn("unimplemented CSI callback: {f}", .{input}); + break :decsasd; + }, }; - if (!success) log.warn("unimplemented CSI callback: {f}", .{input}); + try self.handler.vt(.active_status_display, display); }, else => if (@hasDecl(T, "csiUnimplemented")) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index b7fcd5834..3f08610b7 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -297,6 +297,7 @@ pub const StreamHandler = struct { .prompt_end => try self.promptEnd(), .end_of_input => try self.endOfInput(), .end_hyperlink => try self.endHyperlink(), + .active_status_display => self.terminal.status_display = value, .decaln => try self.decaln(), // Unimplemented @@ -848,13 +849,6 @@ pub const StreamHandler = struct { self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); } - pub fn setActiveStatusDisplay( - self: *StreamHandler, - req: terminal.StatusDisplay, - ) !void { - self.terminal.status_display = req; - } - pub fn configureCharset( self: *StreamHandler, slot: terminal.CharsetSlot, From b91149f0fe6fd826ba3bfb4a330a72f18a48fcff Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 21:17:49 -0700 Subject: [PATCH 183/702] terminal: simple esc dispatch --- src/terminal/stream.zig | 46 +++++++++++++++++++++-------------- src/termio/stream_handler.zig | 4 +++ 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 45bf1e25d..fa58cb69d 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -69,6 +69,10 @@ pub const Action = union(Key) { tab_clear_all, tab_set, tab_reset, + index, + next_line, + reverse_index, + full_reset, set_mode: Mode, reset_mode: Mode, save_mode: Mode, @@ -143,6 +147,10 @@ pub const Action = union(Key) { "tab_clear_all", "tab_set", "tab_reset", + "index", + "next_line", + "reverse_index", + "full_reset", "set_mode", "reset_mode", "save_mode", @@ -1889,22 +1897,22 @@ pub fn Stream(comptime Handler: type) type { }, // IND - Index - 'D' => if (@hasDecl(T, "index")) switch (action.intermediates.len) { - 0 => try self.handler.index(), + 'D' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.index, {}), else => { log.warn("invalid index command: {f}", .{action}); return; }, - } else log.warn("unimplemented ESC callback: {f}", .{action}), + }, // NEL - Next Line - 'E' => if (@hasDecl(T, "nextLine")) switch (action.intermediates.len) { - 0 => try self.handler.nextLine(), + 'E' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.next_line, {}), else => { log.warn("invalid next line command: {f}", .{action}); return; }, - } else log.warn("unimplemented ESC callback: {f}", .{action}), + }, // HTS - Horizontal Tab Set 'H' => switch (action.intermediates.len) { @@ -1916,13 +1924,13 @@ pub fn Stream(comptime Handler: type) type { }, // RI - Reverse Index - 'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { - 0 => try self.handler.reverseIndex(), + 'M' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.reverse_index, {}), else => { log.warn("invalid reverse index command: {f}", .{action}); return; }, - } else log.warn("unimplemented ESC callback: {f}", .{action}), + }, // SS2 - Single Shift 2 'N' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { @@ -1960,13 +1968,13 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented ESC callback: {f}", .{action}), // RIS - Full Reset - 'c' => if (@hasDecl(T, "fullReset")) switch (action.intermediates.len) { - 0 => try self.handler.fullReset(), + 'c' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.full_reset, {}), else => { log.warn("invalid full reset command: {f}", .{action}); return; }, - } else log.warn("unimplemented ESC callback: {f}", .{action}), + }, // LS2 - Locking Shift 2 'n' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { @@ -2014,14 +2022,16 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented invokeCharset: {f}", .{action}), // Set application keypad mode - '=' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) { - try self.handler.setMode(.keypad_keys, true); - } else log.warn("unimplemented setMode: {f}", .{action}), + '=' => 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 - '>' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) { - try self.handler.setMode(.keypad_keys, false); - } else log.warn("unimplemented setMode: {f}", .{action}), + '>' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.reset_mode, .{ .mode = .keypad_keys }), + else => log.warn("unimplemented setMode: {f}", .{action}), + }, else => if (@hasDecl(T, "escUnimplemented")) try self.handler.escUnimplemented(action) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 3f08610b7..8e3722649 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -243,6 +243,10 @@ pub const StreamHandler = struct { .tab_clear_all => self.terminal.tabClear(.all), .tab_set => self.terminal.tabSet(), .tab_reset => self.terminal.tabReset(), + .index => try self.index(), + .next_line => try self.nextLine(), + .reverse_index => try self.reverseIndex(), + .full_reset => try self.fullReset(), .set_mode => try self.setMode(value.mode, true), .reset_mode => try self.setMode(value.mode, false), .save_mode => self.terminal.modes.save(value.mode), From 6902d89d9123484762f8610c27221ca64e778525 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 21:21:39 -0700 Subject: [PATCH 184/702] terminal: convert APC --- src/terminal/stream.zig | 18 +++++++++--------- src/termio/stream_handler.zig | 11 +++-------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index fa58cb69d..1a8e983b5 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -99,6 +99,9 @@ pub const Action = union(Key) { kitty_keyboard_set: KittyKeyboardFlags, kitty_keyboard_set_or: KittyKeyboardFlags, kitty_keyboard_set_not: KittyKeyboardFlags, + apc_start, + apc_end, + apc_put: u8, prompt_end, end_of_input, end_hyperlink, @@ -177,6 +180,9 @@ pub const Action = union(Key) { "kitty_keyboard_set", "kitty_keyboard_set_or", "kitty_keyboard_set_not", + "apc_start", + "apc_end", + "apc_put", "prompt_end", "end_of_input", "end_hyperlink", @@ -559,15 +565,9 @@ pub fn Stream(comptime Handler: type) type { .dcs_unhook => if (@hasDecl(T, "dcsUnhook")) { try self.handler.dcsUnhook(); } else log.warn("unimplemented DCS unhook", .{}), - .apc_start => if (@hasDecl(T, "apcStart")) { - try self.handler.apcStart(); - } else log.warn("unimplemented APC start", .{}), - .apc_put => |code| if (@hasDecl(T, "apcPut")) { - try self.handler.apcPut(code); - } else log.warn("unimplemented APC put: {x}", .{code}), - .apc_end => if (@hasDecl(T, "apcEnd")) { - try self.handler.apcEnd(); - } else log.warn("unimplemented APC end", .{}), + .apc_start => try self.handler.vt(.apc_start, {}), + .apc_put => |code| try self.handler.vt(.apc_put, code), + .apc_end => try self.handler.vt(.apc_end, {}), } } } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8e3722649..dbfa9ddb2 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -303,6 +303,9 @@ pub const StreamHandler = struct { .end_hyperlink => try self.endHyperlink(), .active_status_display => self.terminal.status_display = value, .decaln => try self.decaln(), + .apc_start => self.apc.start(), + .apc_end => try self.apcEnd(), + .apc_put => self.apc.feed(self.alloc, value), // Unimplemented .title_push, @@ -418,14 +421,6 @@ pub const StreamHandler = struct { } } - pub inline fn apcStart(self: *StreamHandler) !void { - self.apc.start(); - } - - pub inline fn apcPut(self: *StreamHandler, byte: u8) !void { - self.apc.feed(self.alloc, byte); - } - pub fn apcEnd(self: *StreamHandler) !void { var cmd = self.apc.end() orelse return; defer cmd.deinit(self.alloc); From 109376115bb2ff70517cca79019437501d5e0fe8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Oct 2025 21:29:16 -0700 Subject: [PATCH 185/702] terminal: convert dcs --- src/terminal/Parser.zig | 8 ++++++++ src/terminal/stream.zig | 18 +++++++++--------- src/termio/stream_handler.zig | 3 +++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 625591d3f..4a02e2b13 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -127,6 +127,14 @@ pub const Action = union(enum) { intermediates: []const u8 = "", params: []const u16 = &.{}, final: u8, + + pub const C = extern struct { + intermediates: [*]const u8, + intermediates_len: usize, + params: [*]const u16, + params_len: usize, + final: u8, + }; }; // Implement formatter for logging. This is mostly copied from the diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 1a8e983b5..2ef7c256b 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -99,6 +99,9 @@ pub const Action = union(Key) { kitty_keyboard_set: KittyKeyboardFlags, kitty_keyboard_set_or: KittyKeyboardFlags, kitty_keyboard_set_not: KittyKeyboardFlags, + dcs_hook: Parser.Action.DCS, + dcs_put: u8, + dcs_unhook, apc_start, apc_end, apc_put: u8, @@ -180,6 +183,9 @@ pub const Action = union(Key) { "kitty_keyboard_set", "kitty_keyboard_set_or", "kitty_keyboard_set_not", + "dcs_hook", + "dcs_put", + "dcs_unhook", "apc_start", "apc_end", "apc_put", @@ -556,15 +562,9 @@ pub fn Stream(comptime Handler: type) type { .csi_dispatch => |csi_action| try self.csiDispatch(csi_action), .esc_dispatch => |esc| try self.escDispatch(esc), .osc_dispatch => |cmd| try self.oscDispatch(cmd), - .dcs_hook => |dcs| if (@hasDecl(T, "dcsHook")) { - try self.handler.dcsHook(dcs); - } else log.warn("unimplemented DCS hook", .{}), - .dcs_put => |code| if (@hasDecl(T, "dcsPut")) { - try self.handler.dcsPut(code); - } else log.warn("unimplemented DCS put: {x}", .{code}), - .dcs_unhook => if (@hasDecl(T, "dcsUnhook")) { - try self.handler.dcsUnhook(); - } else log.warn("unimplemented DCS unhook", .{}), + .dcs_hook => |dcs| try self.handler.vt(.dcs_hook, dcs), + .dcs_put => |code| try self.handler.vt(.dcs_put, code), + .dcs_unhook => try self.handler.vt(.dcs_unhook, {}), .apc_start => try self.handler.vt(.apc_start, {}), .apc_put => |code| try self.handler.vt(.apc_put, code), .apc_end => try self.handler.vt(.apc_end, {}), diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index dbfa9ddb2..a44522573 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -303,6 +303,9 @@ pub const StreamHandler = struct { .end_hyperlink => try self.endHyperlink(), .active_status_display => self.terminal.status_display = value, .decaln => try self.decaln(), + .dcs_hook => try self.dcsHook(value), + .dcs_put => try self.dcsPut(value), + .dcs_unhook => try self.dcsUnhook(), .apc_start => self.apc.start(), .apc_end => try self.apcEnd(), .apc_put => self.apc.feed(self.alloc, value), From e347ab6915e7c3e0a3629a27dd672bce9613559b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Oct 2025 07:08:09 -0700 Subject: [PATCH 186/702] terminal: device attributes --- src/terminal/ansi.zig | 13 ++++++++----- src/terminal/stream.zig | 30 +++++++++++++++++------------- src/termio/stream_handler.zig | 4 +--- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/terminal/ansi.zig b/src/terminal/ansi.zig index e4b2613d8..357910d54 100644 --- a/src/terminal/ansi.zig +++ b/src/terminal/ansi.zig @@ -53,11 +53,14 @@ pub const RenditionAspect = enum(u16) { }; /// The device attribute request type (ESC [ c). -pub const DeviceAttributeReq = enum { - primary, // Blank - secondary, // > - tertiary, // = -}; +pub const DeviceAttributeReq = lib.Enum( + lib_target, + &.{ + "primary", // Blank + "secondary", // > + "tertiary", // = + }, +); /// Possible cursor styles (ESC [ q) pub const CursorStyle = enum(u16) { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 2ef7c256b..a63665a92 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -93,6 +93,7 @@ pub const Action = union(Key) { title_push: u16, title_pop: u16, xtversion, + device_attributes: ansi.DeviceAttributeReq, kitty_keyboard_query, kitty_keyboard_push: KittyKeyboardFlags, kitty_keyboard_pop: u16, @@ -177,6 +178,7 @@ pub const Action = union(Key) { "title_push", "title_pop", "xtversion", + "device_attributes", "kitty_keyboard_query", "kitty_keyboard_push", "kitty_keyboard_pop", @@ -1042,22 +1044,24 @@ pub fn Stream(comptime Handler: type) type { }, // c - Device Attributes (DA1) - 'c' => if (@hasDecl(T, "deviceAttributes")) { - const req: ansi.DeviceAttributeReq = switch (input.intermediates.len) { - 0 => ansi.DeviceAttributeReq.primary, + 'c' => { + const req: ?ansi.DeviceAttributeReq = switch (input.intermediates.len) { + 0 => .primary, 1 => switch (input.intermediates[0]) { - '>' => ansi.DeviceAttributeReq.secondary, - '=' => ansi.DeviceAttributeReq.tertiary, + '>' => .secondary, + '=' => .tertiary, else => null, }, - else => @as(?ansi.DeviceAttributeReq, null), - } orelse { + else => null, + }; + + if (req) |r| { + try self.handler.vt(.device_attributes, r); + } else { log.warn("invalid device attributes command: {f}", .{input}); return; - }; - - try self.handler.deviceAttributes(req, input.params); - } else log.warn("unimplemented CSI callback: {f}", .{input}), + } + }, // VPA - Cursor Vertical Position Absolute 'd' => switch (input.intermediates.len) { @@ -1963,8 +1967,8 @@ pub fn Stream(comptime Handler: type) type { }, // DECID - 'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) { - try self.handler.deviceAttributes(.primary, &.{}); + 'Z' => if (action.intermediates.len == 0) { + try self.handler.vt(.device_attributes, .primary); } else log.warn("unimplemented ESC callback: {f}", .{action}), // RIS - Full Reset diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index a44522573..11ca15ea9 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -277,6 +277,7 @@ pub const StreamHandler = struct { .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, .size_report => self.sendSizeReport(value), .xtversion => try self.reportXtversion(), + .device_attributes => try self.deviceAttributes(value), .kitty_keyboard_query => try self.queryKittyKeyboard(), .kitty_keyboard_push => { log.debug("pushing kitty keyboard mode: {}", .{value.flags}); @@ -722,10 +723,7 @@ pub const StreamHandler = struct { pub fn deviceAttributes( self: *StreamHandler, req: terminal.DeviceAttributeReq, - params: []const u16, ) !void { - _ = params; - // For the below, we quack as a VT220. We don't quack as // a 420 because we don't support DCS sequences. switch (req) { From a4a37534d7a1a7bbe70fbadc8d42f49abf305ab0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Oct 2025 07:11:06 -0700 Subject: [PATCH 187/702] terminal: missed some invoke charsets --- src/terminal/stream.zig | 70 ++++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index a63665a92..252c50178 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1937,22 +1937,30 @@ pub fn Stream(comptime Handler: type) type { }, // SS2 - Single Shift 2 - 'N' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G2, true), + 'N' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GL, + .charset = .G2, + .locking = true, + }), else => { log.warn("invalid single shift 2 command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // SS3 - Single Shift 3 - 'O' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G3, true), + 'O' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GL, + .charset = .G3, + .locking = true, + }), else => { log.warn("invalid single shift 3 command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // SPA - Start of Guarded Area 'V' => switch (action.intermediates.len) { @@ -1981,49 +1989,69 @@ pub fn Stream(comptime Handler: type) type { }, // LS2 - Locking Shift 2 - 'n' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G2, false), + 'n' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GL, + .charset = .G2, + .locking = false, + }), else => { log.warn("invalid single shift 2 command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // LS3 - Locking Shift 3 - 'o' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GL, .G3, false), + 'o' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GL, + .charset = .G3, + .locking = false, + }), else => { log.warn("invalid single shift 3 command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // LS1R - Locking Shift 1 Right - '~' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GR, .G1, false), + '~' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GR, + .charset = .G1, + .locking = false, + }), else => { log.warn("invalid locking shift 1 right command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // LS2R - Locking Shift 2 Right - '}' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GR, .G2, false), + '}' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GR, + .charset = .G2, + .locking = false, + }), else => { log.warn("invalid locking shift 2 right command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // LS3R - Locking Shift 3 Right - '|' => if (@hasDecl(T, "invokeCharset")) switch (action.intermediates.len) { - 0 => try self.handler.invokeCharset(.GR, .G3, false), + '|' => switch (action.intermediates.len) { + 0 => try self.handler.vt(.invoke_charset, .{ + .bank = .GR, + .charset = .G3, + .locking = false, + }), else => { log.warn("invalid locking shift 3 right command: {f}", .{action}); return; }, - } else log.warn("unimplemented invokeCharset: {f}", .{action}), + }, // Set application keypad mode '=' => switch (action.intermediates.len) { From fd0f9bb84307452d096f8fd1a4d370a85fabcf6e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Oct 2025 07:17:43 -0700 Subject: [PATCH 188/702] terminal: device attributes --- src/terminal/stream.zig | 21 ++++++++++++++------- src/termio/stream_handler.zig | 1 + 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 252c50178..72c2c8532 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -94,6 +94,7 @@ pub const Action = union(Key) { title_pop: u16, xtversion, device_attributes: ansi.DeviceAttributeReq, + device_status: DeviceStatus, kitty_keyboard_query, kitty_keyboard_push: KittyKeyboardFlags, kitty_keyboard_pop: u16, @@ -179,6 +180,7 @@ pub const Action = union(Key) { "title_pop", "xtversion", "device_attributes", + "device_status", "kitty_keyboard_query", "kitty_keyboard_push", "kitty_keyboard_pop", @@ -245,6 +247,16 @@ pub const Action = union(Key) { col: u16, }; + pub const DeviceStatus = struct { + request: device_status.Request, + + pub const C = u16; + + pub fn cval(self: DeviceStatus) DeviceStatus.C { + return @bitCast(self.request); + } + }; + pub const Mode = struct { mode: modes.Mode, @@ -1054,7 +1066,7 @@ pub fn Stream(comptime Handler: type) type { }, else => null, }; - + if (req) |r| { try self.handler.vt(.device_attributes, r); } else { @@ -1249,11 +1261,6 @@ pub fn Stream(comptime Handler: type) type { if (input.intermediates.len == 0 or input.intermediates[0] == '?') { - if (!@hasDecl(T, "deviceStatusReport")) { - log.warn("unimplemented CSI callback: {f}", .{input}); - return; - } - if (input.params.len != 1) { log.warn("invalid device status report command: {f}", .{input}); return; @@ -1273,7 +1280,7 @@ pub fn Stream(comptime Handler: type) type { return; }; - try self.handler.deviceStatusReport(req); + try self.handler.vt(.device_status, .{ .request = req }); return; } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 11ca15ea9..9d754158a 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -278,6 +278,7 @@ pub const StreamHandler = struct { .size_report => self.sendSizeReport(value), .xtversion => try self.reportXtversion(), .device_attributes => try self.deviceAttributes(value), + .device_status => try self.deviceStatusReport(value.request), .kitty_keyboard_query => try self.queryKittyKeyboard(), .kitty_keyboard_push => { log.debug("pushing kitty keyboard mode: {}", .{value.flags}); From bce1164ae6ce77a990999123160027e8ff190b20 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Oct 2025 07:25:14 -0700 Subject: [PATCH 189/702] terminal: cursor style --- src/terminal/ansi.zig | 32 ++++++++------------ src/terminal/stream.zig | 55 ++++++++++++++++++++--------------- src/termio/stream_handler.zig | 3 +- 3 files changed, 44 insertions(+), 46 deletions(-) diff --git a/src/terminal/ansi.zig b/src/terminal/ansi.zig index 357910d54..c9cd53666 100644 --- a/src/terminal/ansi.zig +++ b/src/terminal/ansi.zig @@ -63,26 +63,18 @@ pub const DeviceAttributeReq = lib.Enum( ); /// Possible cursor styles (ESC [ q) -pub const CursorStyle = enum(u16) { - default = 0, - blinking_block = 1, - steady_block = 2, - blinking_underline = 3, - steady_underline = 4, - blinking_bar = 5, - steady_bar = 6, - - // Non-exhaustive so that @intToEnum never fails for unsupported modes. - _, - - /// True if the cursor should blink. - pub fn blinking(self: CursorStyle) bool { - return switch (self) { - .blinking_block, .blinking_underline, .blinking_bar => true, - else => false, - }; - } -}; +pub const CursorStyle = lib.Enum( + lib_target, + &.{ + "default", + "blinking_block", + "steady_block", + "blinking_underline", + "steady_underline", + "blinking_bar", + "steady_bar", + }, +); /// The status line type for DECSSDT. pub const StatusLineType = enum(u16) { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 72c2c8532..7442fb21c 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -49,6 +49,7 @@ pub const Action = union(Key) { cursor_col_relative: CursorMovement, cursor_row_relative: CursorMovement, cursor_pos: CursorPos, + cursor_style: ansi.CursorStyle, erase_display_below: bool, erase_display_above: bool, erase_display_complete: bool, @@ -135,6 +136,7 @@ pub const Action = union(Key) { "cursor_col_relative", "cursor_row_relative", "cursor_pos", + "cursor_style", "erase_display_below", "erase_display_above", "erase_display_complete", @@ -1356,16 +1358,27 @@ pub fn Stream(comptime Handler: type) type { // DECSCUSR - Select Cursor Style // TODO: test ' ' => { - if (@hasDecl(T, "setCursorStyle")) try self.handler.setCursorStyle( - switch (input.params.len) { - 0 => ansi.CursorStyle.default, - 1 => @enumFromInt(input.params[0]), + const style: ansi.CursorStyle = switch (input.params.len) { + 0 => .default, + 1 => switch (input.params[0]) { + 0 => .default, + 1 => .blinking_block, + 2 => .steady_block, + 3 => .blinking_underline, + 4 => .steady_underline, + 5 => .blinking_bar, + 6 => .steady_bar, else => { - log.warn("invalid set curor style command: {f}", .{input}); + log.warn("invalid cursor style value: {}", .{input.params[0]}); return; }, }, - ) else log.warn("unimplemented CSI callback: {f}", .{input}); + else => { + log.warn("invalid set curor style command: {f}", .{input}); + return; + }, + }; + try self.handler.vt(.cursor_style, style); }, // DECSCA @@ -2544,18 +2557,15 @@ test "stream: DECSCUSR" { const H = struct { style: ?ansi.CursorStyle = null, - pub fn setCursorStyle(self: *@This(), style: ansi.CursorStyle) !void { - self.style = style; - } - pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Stream(@This()).Action.Tag, + value: Stream(@This()).Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .cursor_style => self.style = value, + else => {}, + } } }; @@ -2575,18 +2585,15 @@ test "stream: DECSCUSR without space" { const H = struct { style: ?ansi.CursorStyle = null, - pub fn setCursorStyle(self: *@This(), style: ansi.CursorStyle) !void { - self.style = style; - } - pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Stream(@This()).Action.Tag, + value: Stream(@This()).Action.Value(action), ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .cursor_style => self.style = value, + else => {}, + } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 9d754158a..32696c096 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -219,6 +219,7 @@ pub const StreamHandler = struct { self.terminal.screen.cursor.y + 1 +| value.value, self.terminal.screen.cursor.x + 1, ), + .cursor_style => try self.setCursorStyle(value), .erase_display_below => self.terminal.eraseDisplay(.below, value), .erase_display_above => self.terminal.eraseDisplay(.above, value), .erase_display_complete => { @@ -828,8 +829,6 @@ pub const StreamHandler = struct { self.terminal.screen.cursor.cursor_style = .bar; self.terminal.modes.set(.cursor_blinking, false); }, - - else => log.warn("unimplemented cursor style: {}", .{style}), } } From 3f75c66e8395d7389f05d360d89af567dcd22cba Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Fri, 24 Oct 2025 10:28:50 -0400 Subject: [PATCH 190/702] cli: simplify +ssh-cache cache key validation (#9331) Remove the semi-magic upper bound on the total cache key length. The hostname and username validation routines will perform their own length checks. Also consolidate this function's tests. We previously had a few redundant test cases. --- src/cli/ssh-cache/DiskCache.zig | 60 +++++++++++---------------------- 1 file changed, 20 insertions(+), 40 deletions(-) diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index 25d2cd42e..a3c5b13de 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -328,11 +328,10 @@ fn readEntries( // Supports both standalone hostnames and user@hostname format fn isValidCacheKey(key: []const u8) bool { - // 253 + 1 + 64 for user@hostname - if (key.len == 0 or key.len > 320) return false; + if (key.len == 0) return false; // Check for user@hostname format - if (std.mem.indexOf(u8, key, "@")) |at_pos| { + if (std.mem.indexOfScalar(u8, key, '@')) |at_pos| { const user = key[0..at_pos]; const hostname = key[at_pos + 1 ..]; return isValidUser(user) and isValidHost(hostname); @@ -455,8 +454,6 @@ test "disk cache operations" { ); } -// Tests - test isValidHost { const testing = std.testing; @@ -488,59 +485,42 @@ test isValidHost { try testing.expect(!isValidHost("host:port")); } -test "user validation - valid cases" { +test isValidUser { const testing = std.testing; + + // Valid try testing.expect(isValidUser("user")); - try testing.expect(isValidUser("deploy")); - try testing.expect(isValidUser("test-user")); + try testing.expect(isValidUser("user-user")); try testing.expect(isValidUser("user_name")); try testing.expect(isValidUser("user.name")); try testing.expect(isValidUser("user123")); - try testing.expect(isValidUser("a")); -} -test "user validation - complex realistic cases" { - const testing = std.testing; - try testing.expect(isValidUser("git")); - try testing.expect(isValidUser("ubuntu")); - try testing.expect(isValidUser("root")); - try testing.expect(isValidUser("service.account")); - try testing.expect(isValidUser("user-with-dashes")); -} - -test "user validation - invalid cases" { - const testing = std.testing; + // Invalid try testing.expect(!isValidUser("")); try testing.expect(!isValidUser("user name")); - try testing.expect(!isValidUser("user@domain")); + try testing.expect(!isValidUser("user@example")); try testing.expect(!isValidUser("user:group")); try testing.expect(!isValidUser("user\nname")); - - // Too long - const long_user = "a" ** 65; - try testing.expect(!isValidUser(long_user)); + try testing.expect(!isValidUser("a" ** 65)); // too long } -test "cache key validation - hostname format" { +test isValidCacheKey { const testing = std.testing; + + // Valid try testing.expect(isValidCacheKey("example.com")); try testing.expect(isValidCacheKey("sub.example.com")); try testing.expect(isValidCacheKey("192.168.1.1")); try testing.expect(isValidCacheKey("::1")); - try testing.expect(!isValidCacheKey("")); - try testing.expect(!isValidCacheKey(".invalid.com")); -} - -test "cache key validation - user@hostname format" { - const testing = std.testing; try testing.expect(isValidCacheKey("user@example.com")); - try testing.expect(isValidCacheKey("deploy@prod.server.com")); - try testing.expect(isValidCacheKey("test-user@192.168.1.1")); - try testing.expect(isValidCacheKey("user_name@host.domain.org")); - try testing.expect(isValidCacheKey("git@github.com")); - try testing.expect(isValidCacheKey("ubuntu@::1")); + try testing.expect(isValidCacheKey("user@192.168.1.1")); + try testing.expect(isValidCacheKey("user@::1")); + + // Invalid + try testing.expect(!isValidCacheKey("")); + try testing.expect(!isValidCacheKey(".example.com")); try testing.expect(!isValidCacheKey("@example.com")); try testing.expect(!isValidCacheKey("user@")); - try testing.expect(!isValidCacheKey("user@@host")); - try testing.expect(!isValidCacheKey("user@.invalid.com")); + try testing.expect(!isValidCacheKey("user@@example")); + try testing.expect(!isValidCacheKey("user@.example.com")); } From 4d028dac1f8f8e2a8d886ec9b37b0d71892ff051 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Oct 2025 10:53:57 -0700 Subject: [PATCH 191/702] terminal: some osc types --- src/lib/main.zig | 2 + src/lib/types.zig | 13 ++ src/terminal/stream.zig | 233 +++++++++++++++++++++++++--------- src/termio/stream_handler.zig | 34 +++-- 4 files changed, 209 insertions(+), 73 deletions(-) create mode 100644 src/lib/types.zig diff --git a/src/lib/main.zig b/src/lib/main.zig index cdddade09..5a626b1e8 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -1,9 +1,11 @@ const std = @import("std"); const enumpkg = @import("enum.zig"); +const types = @import("types.zig"); const unionpkg = @import("union.zig"); pub const allocator = @import("allocator.zig"); pub const Enum = enumpkg.Enum; +pub const String = types.String; pub const Struct = @import("struct.zig").Struct; pub const Target = @import("target.zig").Target; pub const TaggedUnion = unionpkg.TaggedUnion; diff --git a/src/lib/types.zig b/src/lib/types.zig new file mode 100644 index 000000000..758540d12 --- /dev/null +++ b/src/lib/types.zig @@ -0,0 +1,13 @@ +pub const String = extern struct { + ptr: [*]const u8, + len: usize, + + pub fn init(zig: anytype) String { + return switch (@TypeOf(zig)) { + []u8, []const u8 => .{ + .ptr = zig.ptr, + .len = zig.len, + }, + }; + } +}; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 7442fb21c..025e995c1 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -113,6 +113,16 @@ pub const Action = union(Key) { end_hyperlink, active_status_display: ansi.StatusDisplay, decaln, + window_title: WindowTitle, + report_pwd: ReportPwd, + show_desktop_notification: ShowDesktopNotification, + progress_report: osc.Command.ProgressReport, + start_hyperlink: StartHyperlink, + clipboard_contents: ClipboardContents, + prompt_start: PromptStart, + prompt_continuation: PromptContinuation, + end_of_command: EndOfCommand, + mouse_shape: MouseShape, pub const Key = lib.Enum( lib_target, @@ -200,6 +210,16 @@ pub const Action = union(Key) { "end_hyperlink", "active_status_display", "decaln", + "window_title", + "report_pwd", + "show_desktop_notification", + "progress_report", + "start_hyperlink", + "clipboard_contents", + "prompt_start", + "prompt_continuation", + "end_of_command", + "mouse_shape", }, ); @@ -288,6 +308,118 @@ pub const Action = union(Key) { return @intCast(self.flags.int()); } }; + + pub const WindowTitle = struct { + title: []const u8, + + pub const C = lib.String; + + pub fn cval(self: WindowTitle) WindowTitle.C { + return .init(self.title); + } + }; + + pub const ReportPwd = struct { + url: []const u8, + + pub const C = lib.String; + + pub fn cval(self: ReportPwd) ReportPwd.C { + return .init(self.url); + } + }; + + pub const ShowDesktopNotification = struct { + title: []const u8, + body: []const u8, + + pub const C = extern struct { + title: lib.String, + body: lib.String, + }; + + pub fn cval(self: ShowDesktopNotification) ShowDesktopNotification.C { + return .{ + .title = .init(self.title), + .body = .init(self.body), + }; + } + }; + + pub const StartHyperlink = struct { + uri: []const u8, + id: ?[]const u8, + + pub const C = extern struct { + uri: lib.String, + id: lib.String, + }; + + pub fn cval(self: StartHyperlink) StartHyperlink.C { + return .{ + .uri = .init(self.uri), + .id = .init(self.id orelse ""), + }; + } + }; + + pub const ClipboardContents = struct { + kind: u8, + data: []const u8, + + pub const C = extern struct { + kind: u8, + data: lib.String, + }; + + pub fn cval(self: ClipboardContents) ClipboardContents.C { + return .{ + .kind = self.kind, + .data = .init(self.data), + }; + } + }; + + pub const PromptStart = struct { + aid: ?[]const u8, + redraw: bool, + + pub const C = extern struct { + aid: lib.String, + redraw: bool, + }; + + pub fn cval(self: PromptStart) PromptStart.C { + return .{ + .aid = .init(self.aid orelse ""), + .redraw = self.redraw, + }; + } + }; + + pub const PromptContinuation = struct { + aid: ?[]const u8, + + pub const C = lib.String; + + pub fn cval(self: PromptContinuation) PromptContinuation.C { + return .init(self.aid orelse ""); + } + }; + + pub const EndOfCommand = struct { + exit_code: ?u8, + + pub const C = extern struct { + exit_code: i16, + }; + + pub fn cval(self: EndOfCommand) EndOfCommand.C { + return .{ + .exit_code = if (self.exit_code) |code| @intCast(code) else -1, + }; + } + }; }; /// Returns a type that can process a stream of tty control characters. @@ -1710,15 +1842,12 @@ pub fn Stream(comptime Handler: type) type { inline fn oscDispatch(self: *Self, cmd: osc.Command) !void { switch (cmd) { .change_window_title => |title| { - if (@hasDecl(T, "changeWindowTitle")) { - if (!std.unicode.utf8ValidateSlice(title)) { - log.warn("change title request: invalid utf-8, ignoring request", .{}); - return; - } - - try self.handler.changeWindowTitle(title); + if (!std.unicode.utf8ValidateSlice(title)) { + log.warn("change title request: invalid utf-8, ignoring request", .{}); return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + } + + try self.handler.vt(.window_title, .{ .title = title }); }, .change_window_icon => |icon| { @@ -1726,54 +1855,43 @@ pub fn Stream(comptime Handler: type) type { }, .clipboard_contents => |clip| { - if (@hasDecl(T, "clipboardContents")) { - try self.handler.clipboardContents(clip.kind, clip.data); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.clipboard_contents, .{ + .kind = clip.kind, + .data = clip.data, + }); }, .prompt_start => |v| { - if (@hasDecl(T, "promptStart")) { - switch (v.kind) { - .primary, .right => try self.handler.promptStart(v.aid, v.redraw), - .continuation, .secondary => try self.handler.promptContinuation(v.aid), - } - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + switch (v.kind) { + .primary, .right => try self.handler.vt(.prompt_start, .{ + .aid = v.aid, + .redraw = v.redraw, + }), + .continuation, .secondary => try self.handler.vt(.prompt_continuation, .{ + .aid = v.aid, + }), + } }, - .prompt_end => { - try self.handler.vt(.prompt_end, {}); - }, + .prompt_end => try self.handler.vt(.prompt_end, {}), - .end_of_input => { - try self.handler.vt(.end_of_input, {}); - }, + .end_of_input => try self.handler.vt(.end_of_input, {}), .end_of_command => |end| { - if (@hasDecl(T, "endOfCommand")) { - try self.handler.endOfCommand(end.exit_code); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.end_of_command, .{ .exit_code = end.exit_code }); }, .report_pwd => |v| { - if (@hasDecl(T, "reportPwd")) { - try self.handler.reportPwd(v.value); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.report_pwd, .{ .url = v.value }); }, .mouse_shape => |v| { - if (@hasDecl(T, "setMouseShape")) { - const shape = MouseShape.fromString(v.value) orelse { - log.warn("unknown cursor shape: {s}", .{v.value}); - return; - }; - - try self.handler.setMouseShape(shape); + const shape = MouseShape.fromString(v.value) orelse { + log.warn("unknown cursor shape: {s}", .{v.value}); return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }; + + try self.handler.vt(.mouse_shape, shape); }, .color_operation => |v| { @@ -1795,17 +1913,17 @@ pub fn Stream(comptime Handler: type) type { }, .show_desktop_notification => |v| { - if (@hasDecl(T, "showDesktopNotification")) { - try self.handler.showDesktopNotification(v.title, v.body); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.show_desktop_notification, .{ + .title = v.title, + .body = v.body, + }); }, .hyperlink_start => |v| { - if (@hasDecl(T, "startHyperlink")) { - try self.handler.startHyperlink(v.uri, v.id); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.start_hyperlink, .{ + .uri = v.uri, + .id = v.id, + }); }, .hyperlink_end => { @@ -1813,10 +1931,7 @@ pub fn Stream(comptime Handler: type) type { }, .conemu_progress_report => |v| { - if (@hasDecl(T, "handleProgressReport")) { - try self.handler.handleProgressReport(v); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.progress_report, v); }, .conemu_sleep, @@ -2643,20 +2758,16 @@ test "stream: change window title with invalid utf-8" { const H = struct { seen: bool = false, - pub fn changeWindowTitle(self: *@This(), title: []const u8) !void { - _ = title; - - self.seen = true; - } - pub fn vt( self: *@This(), comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; _ = value; + switch (action) { + .window_title => self.seen = true, + else => {}, + } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 32696c096..d23e7606e 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -306,6 +306,16 @@ pub const StreamHandler = struct { .end_hyperlink => try self.endHyperlink(), .active_status_display => self.terminal.status_display = value, .decaln => try self.decaln(), + .window_title => try self.windowTitle(value.title), + .report_pwd => try self.reportPwd(value.url), + .show_desktop_notification => try self.showDesktopNotification(value.title, value.body), + .progress_report => self.progressReport(value), + .start_hyperlink => try self.startHyperlink(value.uri, value.id), + .clipboard_contents => try self.clipboardContents(value.kind, value.data), + .prompt_start => self.promptStart(value.aid, value.redraw), + .prompt_continuation => self.promptContinuation(value.aid), + .end_of_command => self.endOfCommand(value.exit_code), + .mouse_shape => try self.setMouseShape(value), .dcs_hook => try self.dcsHook(value), .dcs_put => try self.dcsPut(value), .dcs_unhook => try self.dcsUnhook(), @@ -714,7 +724,7 @@ pub const StreamHandler = struct { } } - pub inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { + inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { try self.terminal.screen.startHyperlink(uri, id); } @@ -902,7 +912,7 @@ pub const StreamHandler = struct { //------------------------------------------------------------------------- // OSC - pub fn changeWindowTitle(self: *StreamHandler, title: []const u8) !void { + fn windowTitle(self: *StreamHandler, title: []const u8) !void { var buf: [256]u8 = undefined; if (title.len >= buf.len) { log.warn("change title requested larger than our buffer size, ignoring", .{}); @@ -933,7 +943,7 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .set_title = buf }); } - pub inline fn setMouseShape( + inline fn setMouseShape( self: *StreamHandler, shape: terminal.MouseShape, ) !void { @@ -945,7 +955,7 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .set_mouse_shape = shape }); } - pub fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { + fn clipboardContents(self: *StreamHandler, kind: u8, data: []const u8) !void { // Note: we ignore the "kind" field and always use the standard clipboard. // iTerm also appears to do this but other terminals seem to only allow // certain. Let's investigate more. @@ -975,13 +985,13 @@ pub const StreamHandler = struct { }); } - pub inline fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) !void { + inline fn promptStart(self: *StreamHandler, aid: ?[]const u8, redraw: bool) void { _ = aid; self.terminal.markSemanticPrompt(.prompt); self.terminal.flags.shell_redraws_prompt = redraw; } - pub inline fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) !void { + inline fn promptContinuation(self: *StreamHandler, aid: ?[]const u8) void { _ = aid; self.terminal.markSemanticPrompt(.prompt_continuation); } @@ -995,11 +1005,11 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.start_command); } - pub inline fn endOfCommand(self: *StreamHandler, exit_code: ?u8) !void { + inline fn endOfCommand(self: *StreamHandler, exit_code: ?u8) void { self.surfaceMessageWriter(.{ .stop_command = exit_code }); } - pub fn reportPwd(self: *StreamHandler, url: []const u8) !void { + fn reportPwd(self: *StreamHandler, url: []const u8) !void { // Special handling for the empty URL. We treat the empty URL // as resetting the pwd as if we never saw a pwd. I can't find any // other terminal that does this but it seems like a reasonable @@ -1013,7 +1023,7 @@ pub const StreamHandler = struct { // If we haven't seen a title, we're using the pwd as our title. // Set it to blank which will reset our title behavior. if (!self.seen_title) { - try self.changeWindowTitle(""); + try self.windowTitle(""); assert(!self.seen_title); } @@ -1093,7 +1103,7 @@ pub const StreamHandler = struct { // If we haven't seen a title, use our pwd as the title. if (!self.seen_title) { - try self.changeWindowTitle(path); + try self.windowTitle(path); self.seen_title = false; } } @@ -1347,7 +1357,7 @@ pub const StreamHandler = struct { } } - pub fn showDesktopNotification( + fn showDesktopNotification( self: *StreamHandler, title: []const u8, body: []const u8, @@ -1500,7 +1510,7 @@ pub const StreamHandler = struct { } /// Display a GUI progress report. - pub fn handleProgressReport(self: *StreamHandler, report: terminal.osc.Command.ProgressReport) error{}!void { + fn progressReport(self: *StreamHandler, report: terminal.osc.Command.ProgressReport) void { self.surfaceMessageWriter(.{ .progress_report = report }); } }; From 5ba451d073acacaed371fad90cce9ee799f73136 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Oct 2025 11:27:47 -0700 Subject: [PATCH 192/702] terminal: configureCharset --- src/terminal/Terminal.zig | 2 +- src/terminal/charsets.zig | 116 +++++++++++++++++----------------- src/terminal/main.zig | 1 + src/terminal/stream.zig | 18 +++--- src/termio/stream_handler.zig | 5 +- 5 files changed, 72 insertions(+), 70 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 69bcbcb84..a8cee90fb 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -581,7 +581,7 @@ fn printCell( if (unmapped_c > std.math.maxInt(u8)) break :c ' '; // Get our lookup table and map it - const table = set.table(); + const table = charsets.table(set); break :c @intCast(table[@intCast(unmapped_c)]); }; diff --git a/src/terminal/charsets.zig b/src/terminal/charsets.zig index 9d49832df..b4fd58efc 100644 --- a/src/terminal/charsets.zig +++ b/src/terminal/charsets.zig @@ -16,76 +16,74 @@ pub const ActiveSlot = LibEnum( ); /// The list of supported character sets and their associated tables. -pub const Charset = enum { - utf8, - ascii, - british, - dec_special, +pub const Charset = LibEnum( + if (build_options.c_abi) .c else .zig, + &.{ "utf8", "ascii", "british", "dec_special" }, +); - /// 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 { - return switch (set) { - .british => &british, - .dec_special => &dec_special, +/// 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 { + return switch (set) { + .british => &british, + .dec_special => &dec_special, - // utf8 is not a table, callers should double-check if the - // charset is utf8 and NOT use tables. - .utf8 => unreachable, + // utf8 is not a table, callers should double-check if the + // charset is utf8 and NOT use tables. + .utf8 => unreachable, - // recommended that callers just map ascii directly but we can - // support a table - .ascii => &ascii, - }; - } -}; + // recommended that callers just map ascii directly but we can + // support a table + .ascii => &ascii, + }; +} /// Just a basic c => c ascii table const ascii = initTable(); /// https://vt100.net/docs/vt220-rm/chapter2.html const british = british: { - var table = initTable(); - table[0x23] = 0x00a3; - break :british table; + var tbl = initTable(); + tbl[0x23] = 0x00a3; + break :british tbl; }; /// https://en.wikipedia.org/wiki/DEC_Special_Graphics const dec_special = tech: { - var table = initTable(); - table[0x60] = 0x25C6; - table[0x61] = 0x2592; - table[0x62] = 0x2409; - table[0x63] = 0x240C; - table[0x64] = 0x240D; - table[0x65] = 0x240A; - table[0x66] = 0x00B0; - table[0x67] = 0x00B1; - table[0x68] = 0x2424; - table[0x69] = 0x240B; - table[0x6a] = 0x2518; - table[0x6b] = 0x2510; - table[0x6c] = 0x250C; - table[0x6d] = 0x2514; - table[0x6e] = 0x253C; - table[0x6f] = 0x23BA; - table[0x70] = 0x23BB; - table[0x71] = 0x2500; - table[0x72] = 0x23BC; - table[0x73] = 0x23BD; - table[0x74] = 0x251C; - table[0x75] = 0x2524; - table[0x76] = 0x2534; - table[0x77] = 0x252C; - table[0x78] = 0x2502; - table[0x79] = 0x2264; - table[0x7a] = 0x2265; - table[0x7b] = 0x03C0; - table[0x7c] = 0x2260; - table[0x7d] = 0x00A3; - table[0x7e] = 0x00B7; - break :tech table; + var tbl = initTable(); + tbl[0x60] = 0x25C6; + tbl[0x61] = 0x2592; + tbl[0x62] = 0x2409; + tbl[0x63] = 0x240C; + tbl[0x64] = 0x240D; + tbl[0x65] = 0x240A; + tbl[0x66] = 0x00B0; + tbl[0x67] = 0x00B1; + tbl[0x68] = 0x2424; + tbl[0x69] = 0x240B; + tbl[0x6a] = 0x2518; + tbl[0x6b] = 0x2510; + tbl[0x6c] = 0x250C; + tbl[0x6d] = 0x2514; + tbl[0x6e] = 0x253C; + tbl[0x6f] = 0x23BA; + tbl[0x70] = 0x23BB; + tbl[0x71] = 0x2500; + tbl[0x72] = 0x23BC; + tbl[0x73] = 0x23BD; + tbl[0x74] = 0x251C; + tbl[0x75] = 0x2524; + tbl[0x76] = 0x2534; + tbl[0x77] = 0x252C; + tbl[0x78] = 0x2502; + tbl[0x79] = 0x2264; + tbl[0x7a] = 0x2265; + tbl[0x7b] = 0x03C0; + tbl[0x7c] = 0x2260; + tbl[0x7d] = 0x00A3; + tbl[0x7e] = 0x00B7; + break :tech tbl; }; /// Our table length is 256 so we can contain all ASCII chars. @@ -107,11 +105,11 @@ test { // utf8 has no table if (@field(Charset, field.name) == .utf8) continue; - const table = @field(Charset, field.name).table(); + const tbl = table(@field(Charset, field.name)); // Yes, I could use `table_len` here, but I want to explicitly use a // hardcoded constant so that if there are miscompilations or a comptime // issue, we catch it. - try testing.expectEqual(@as(usize, 256), table.len); + try testing.expectEqual(@as(usize, 256), tbl.len); } } diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 59b5d0d53..5c19af023 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -25,6 +25,7 @@ pub const x11_color = @import("x11_color.zig"); pub const Charset = charsets.Charset; pub const CharsetSlot = charsets.Slots; pub const CharsetActiveSlot = charsets.ActiveSlot; +pub const charsetTable = charsets.table; pub const Cell = page.Cell; pub const Coordinate = point.Coordinate; pub const CSI = Parser.Action.CSI; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 025e995c1..d4d61f62b 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -123,6 +123,7 @@ pub const Action = union(Key) { prompt_continuation: PromptContinuation, end_of_command: EndOfCommand, mouse_shape: MouseShape, + configure_charset: ConfigureCharset, pub const Key = lib.Enum( lib_target, @@ -220,6 +221,7 @@ pub const Action = union(Key) { "prompt_continuation", "end_of_command", "mouse_shape", + "configure_charset", }, ); @@ -420,6 +422,11 @@ pub const Action = union(Key) { }; } }; + + pub const ConfigureCharset = lib.Struct(lib_target, struct { + slot: charsets.Slots, + charset: charsets.Charset, + }); }; /// Returns a type that can process a stream of tty control characters. @@ -1981,14 +1988,9 @@ pub fn Stream(comptime Handler: type) type { }, }; - if (@hasDecl(T, "configureCharset")) { - try self.handler.configureCharset(slot, set); - return; - } - - log.warn("unimplemented configureCharset callback slot={} set={}", .{ - slot, - set, + try self.handler.vt(.configure_charset, .{ + .slot = slot, + .charset = set, }); } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index d23e7606e..4e5795a10 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -316,6 +316,7 @@ pub const StreamHandler = struct { .prompt_continuation => self.promptContinuation(value.aid), .end_of_command => self.endOfCommand(value.exit_code), .mouse_shape => try self.setMouseShape(value), + .configure_charset => self.configureCharset(value.slot, value.charset), .dcs_hook => try self.dcsHook(value), .dcs_put => try self.dcsPut(value), .dcs_unhook => try self.dcsUnhook(), @@ -859,11 +860,11 @@ pub const StreamHandler = struct { self.messageWriter(try termio.Message.writeReq(self.alloc, self.enquiry_response)); } - pub fn configureCharset( + fn configureCharset( self: *StreamHandler, slot: terminal.CharsetSlot, set: terminal.Charset, - ) !void { + ) void { self.terminal.configureCharset(slot, set); } From 56376a8a384adc7a7f28011922561a8d9f596305 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Oct 2025 11:40:10 -0700 Subject: [PATCH 193/702] sgr: make C compat --- src/lib/union.zig | 2 +- src/terminal/color.zig | 20 ++++++++ src/terminal/sgr.zig | 103 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 114 insertions(+), 11 deletions(-) diff --git a/src/lib/union.zig b/src/lib/union.zig index 7e15aa84d..9fe5e999c 100644 --- a/src/lib/union.zig +++ b/src/lib/union.zig @@ -104,7 +104,7 @@ pub fn TaggedUnion( @tagName(tag), value: { switch (@typeInfo(@TypeOf(v))) { - .@"enum", .@"struct", .@"union" => if (@hasDecl(@TypeOf(v), "cval")) v.cval(), + .@"enum", .@"struct", .@"union" => if (@hasDecl(@TypeOf(v), "cval")) break :value v.cval(), else => {}, } diff --git a/src/terminal/color.zig b/src/terminal/color.zig index d108e205b..b71279dbb 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -68,6 +68,12 @@ pub const Name = enum(u8) { // Remainders are valid unnamed values in the 256 color palette. _, + pub const C = u8; + + pub fn cval(self: Name) C { + return @intFromEnum(self); + } + /// Default colors for tagged values. pub fn default(self: Name) !RGB { return switch (self) { @@ -179,6 +185,20 @@ pub const RGB = packed struct(u24) { g: u8 = 0, b: u8 = 0, + pub const C = extern struct { + r: u8, + g: u8, + b: u8, + }; + + pub fn cval(self: RGB) C { + return .{ + .r = self.r, + .g = self.g, + .b = self.b, + }; + } + pub fn eql(self: RGB, other: RGB) bool { return self.r == other.r and self.g == other.g and self.b == other.b; } diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index d589172ad..a345a7a90 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -1,26 +1,22 @@ //! SGR (Select Graphic Rendition) attrinvbute parsing and types. const std = @import("std"); +const build_options = @import("terminal_options"); const assert = std.debug.assert; const testing = std.testing; +const lib = @import("../lib/main.zig"); const color = @import("color.zig"); const SepList = @import("Parser.zig").Action.CSI.SepList; -/// Attribute type for SGR -pub const Attribute = union(enum) { - pub const Tag = std.meta.FieldEnum(Attribute); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; +/// Attribute type for SGR +pub const Attribute = union(Tag) { /// Unset all attributes unset, /// Unknown attribute, the raw CSI command parameters are here. - unknown: struct { - /// Full is the full SGR input. - full: []const u16, - - /// Partial is the remaining, where we got hung up. - partial: []const u16, - }, + unknown: Unknown, /// Bold the text. bold, @@ -85,6 +81,68 @@ pub const Attribute = union(enum) { /// Set foreground color as 256-color palette. @"256_fg": u8, + pub const Tag = lib.Enum( + lib_target, + &.{ + "unset", + "unknown", + "bold", + "reset_bold", + "italic", + "reset_italic", + "faint", + "underline", + "reset_underline", + "underline_color", + "256_underline_color", + "reset_underline_color", + "overline", + "reset_overline", + "blink", + "reset_blink", + "inverse", + "reset_inverse", + "invisible", + "reset_invisible", + "strikethrough", + "reset_strikethrough", + "direct_color_fg", + "direct_color_bg", + "8_bg", + "8_fg", + "reset_fg", + "reset_bg", + "8_bright_bg", + "8_bright_fg", + "256_bg", + "256_fg", + }, + ); + + pub const Unknown = struct { + /// Full is the full SGR input. + full: []const u16, + + /// Partial is the remaining, where we got hung up. + partial: []const u16, + + pub const C = extern struct { + full_ptr: [*]const u16, + full_len: usize, + partial_ptr: [*]const u16, + partial_len: usize, + }; + + pub fn cval(self: Unknown) Unknown.C { + return .{ + .full_ptr = self.full.ptr, + .full_len = self.full.len, + .partial_ptr = self.partial.ptr, + .partial_len = self.partial.len, + }; + } + }; + pub const Underline = enum(u3) { none = 0, single = 1, @@ -92,7 +150,28 @@ pub const Attribute = union(enum) { curly = 3, dotted = 4, dashed = 5, + + pub const C = u8; + + pub fn cval(self: Underline) Underline.C { + return @intFromEnum(self); + } }; + + /// C ABI functions. + const c_union = lib.TaggedUnion( + lib_target, + @This(), + // Padding size for C ABI compatibility. + // Largest variant is Unknown.C: 2 pointers + 2 usize = 32 bytes on 64-bit. + // We use [8]u64 (64 bytes) to allow room for future expansion while + // maintaining ABI compatibility. + [8]u64, + ); + pub const Value = c_union.Value; + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; }; /// Parser parses the attributes from a list of SGR parameters. @@ -380,6 +459,10 @@ fn testParseColon(params: []const u16) Attribute { return p.next().?; } +test "sgr: Attribute C compat" { + _ = Attribute.C; +} + test "sgr: Parser" { try testing.expect(testParse(&[_]u16{}) == .unset); try testing.expect(testParse(&[_]u16{0}) == .unset); From e49694439c2a0412ea35c506692f82fdb893d7d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Oct 2025 11:51:30 -0700 Subject: [PATCH 194/702] terminal: setAttribute --- src/terminal/stream.zig | 25 +++++++++++++------------ src/termio/stream_handler.zig | 14 +++++--------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index d4d61f62b..569144537 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -124,6 +124,7 @@ pub const Action = union(Key) { end_of_command: EndOfCommand, mouse_shape: MouseShape, configure_charset: ConfigureCharset, + set_attribute: sgr.Attribute, pub const Key = lib.Enum( lib_target, @@ -222,6 +223,7 @@ pub const Action = union(Key) { "end_of_command", "mouse_shape", "configure_charset", + "set_attribute", }, ); @@ -231,7 +233,7 @@ pub const Action = union(Key) { @This(), // TODO: Before shipping an ABI-compatible libghostty, verify this. // This was just arbitrarily chosen for now. - [8]u64, + [16]u64, ); pub const Tag = c_union.Tag; pub const Value = c_union.Value; @@ -1320,7 +1322,7 @@ pub fn Stream(comptime Handler: type) type { // SGR - Select Graphic Rendition 'm' => switch (input.intermediates.len) { - 0 => if (@hasDecl(T, "setAttribute")) { + 0 => { // log.info("parse SGR params={any}", .{input.params}); var p: sgr.Parser = .{ .params = input.params, @@ -1328,9 +1330,9 @@ pub fn Stream(comptime Handler: type) type { }; while (p.next()) |attr| { // log.info("SGR attribute: {}", .{attr}); - try self.handler.setAttribute(attr); + try self.handler.vt(.set_attribute, attr); } - } else log.warn("unimplemented CSI callback: {f}", .{input}), + }, 1 => switch (input.intermediates[0]) { '>' => blk: { @@ -3217,19 +3219,18 @@ test "stream: SGR with 17+ parameters for underline color" { attrs: ?sgr.Attribute = null, called: bool = false, - pub fn setAttribute(self: *@This(), attr: sgr.Attribute) !void { - self.attrs = attr; - self.called = true; - } - pub fn vt( self: *@This(), comptime action: anytype, value: anytype, ) !void { - _ = self; - _ = action; - _ = value; + switch (action) { + .set_attribute => { + self.attrs = value; + self.called = true; + }, + else => {}, + } } }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 4e5795a10..3fd074cf9 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -317,6 +317,11 @@ 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 }), + }, .dcs_hook => try self.dcsHook(value), .dcs_put => try self.dcsPut(value), .dcs_unhook => try self.dcsUnhook(), @@ -716,15 +721,6 @@ pub const StreamHandler = struct { } } - pub inline fn setAttribute(self: *StreamHandler, attr: terminal.Attribute) !void { - switch (attr) { - .unknown => |unk| log.warn("unimplemented or unknown SGR attribute: {any}", .{unk}), - - else => self.terminal.setAttribute(attr) catch |err| - log.warn("error setting attribute {}: {}", .{ attr, err }), - } - } - inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { try self.terminal.screen.startHyperlink(uri, id); } From a85ad0e4f82bff7110b3c500926ffead4b301ed0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Oct 2025 15:20:12 -0700 Subject: [PATCH 195/702] terminal: unused decls --- src/terminal/stream.zig | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 569144537..a9b00bf3d 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1841,10 +1841,7 @@ pub fn Stream(comptime Handler: type) type { try self.handler.vt(.active_status_display, display); }, - else => if (@hasDecl(T, "csiUnimplemented")) - try self.handler.csiUnimplemented(input) - else - log.warn("unimplemented CSI action: {f}", .{input}), + else => log.warn("unimplemented CSI action: {f}", .{input}), } } @@ -1959,12 +1956,7 @@ pub fn Stream(comptime Handler: type) type { }, } - // Fall through for when we don't have a handler. - if (@hasDecl(T, "oscUnimplemented")) { - try self.handler.oscUnimplemented(cmd); - } else { - log.warn("unimplemented OSC command: {s}", .{@tagName(cmd)}); - } + log.warn("unimplemented OSC command: {s}", .{@tagName(cmd)}); } inline fn configureCharset( @@ -2204,14 +2196,11 @@ pub fn Stream(comptime Handler: type) type { else => log.warn("unimplemented setMode: {f}", .{action}), }, - else => if (@hasDecl(T, "escUnimplemented")) - try self.handler.escUnimplemented(action) - else - log.warn("unimplemented ESC action: {f}", .{action}), - // Sets ST (string terminator). We don't have to do anything // because our parser always accepts ST. '\\' => {}, + + else => log.warn("unimplemented ESC action: {f}", .{action}), } } }; From e13f9b9e8c3137a675608cadd7a56a62f446be65 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 25 Oct 2025 06:40:14 -0700 Subject: [PATCH 196/702] terminal: kitty color --- src/terminal/kitty/color.zig | 9 +++++++++ src/terminal/osc.zig | 9 +++++++++ src/terminal/stream.zig | 28 ++++++++++++++-------------- src/termio/stream_handler.zig | 3 ++- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/terminal/kitty/color.zig b/src/terminal/kitty/color.zig index 099002f39..dface5723 100644 --- a/src/terminal/kitty/color.zig +++ b/src/terminal/kitty/color.zig @@ -1,4 +1,6 @@ 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; @@ -16,6 +18,13 @@ pub const OSC = struct { /// We must reply with the same string terminator (ST) as used in the /// request. terminator: Terminator = .st, + + /// We don't currently support encoding this to C in any way. + pub const C = void; + + pub fn cval(_: OSC) C { + return {}; + } }; pub const Special = enum { diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index f7324636a..effdfbd62 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -271,6 +271,8 @@ pub const Terminator = enum { /// Some applications and terminals use BELL (0x07) as the string terminator. bel, + pub const C = LibEnum(.c, &.{ "st", "bel" }); + /// Initialize the terminator based on the last byte seen. If the /// last byte is a BEL then we use BEL, otherwise we just assume ST. pub fn init(ch: ?u8) Terminator { @@ -289,6 +291,13 @@ pub const Terminator = enum { }; } + pub fn cval(self: Terminator) C { + return switch (self) { + .st => .st, + .bel => .bel, + }; + } + pub fn format( self: Terminator, comptime _: []const u8, diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index a9b00bf3d..2fb897d86 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -125,6 +125,7 @@ pub const Action = union(Key) { mouse_shape: MouseShape, configure_charset: ConfigureCharset, set_attribute: sgr.Attribute, + kitty_color_report: kitty.color.OSC, pub const Key = lib.Enum( lib_target, @@ -224,6 +225,7 @@ pub const Action = union(Key) { "mouse_shape", "configure_charset", "set_attribute", + "kitty_color_report", }, ); @@ -432,24 +434,25 @@ pub const Action = union(Key) { }; /// Returns a type that can process a stream of tty control characters. -/// This will call various callback functions on type T. Type T only has to -/// implement the callbacks it cares about; any unimplemented callbacks will -/// logged at runtime. +/// This will call the `vt` function on type T with the following signature: /// -/// To figure out what callbacks exist, search the source for "hasDecl". This -/// isn't ideal but for now that's the best approach. +/// fn(comptime action: Action.Key, value: Action.Value(action)) !void /// -/// This is implemented this way because we purposely do NOT want dynamic -/// dispatch for performance reasons. The way this is implemented forces -/// comptime resolution for all function calls. +/// The handler type T can choose to react to whatever actions it cares +/// about in its pursuit of implementing a terminal emulator or other +/// functionality. +/// +/// The "comptime" key is on purpose (vs. a standard Zig tagged union) +/// because it allows the compiler to optimize away unimplemented actions. +/// e.g. you don't need to pay a conditional branching cost on every single +/// action because the Zig compiler codegens separate code paths for every +/// single action at comptime. pub fn Stream(comptime Handler: type) type { return struct { const Self = @This(); pub const Action = streampkg.Action; - // We use T with @hasDecl so it needs to be a struct. Unwrap the - // pointer if we were given one. const T = switch (@typeInfo(Handler)) { .pointer => |p| p.child, else => Handler, @@ -1912,10 +1915,7 @@ pub fn Stream(comptime Handler: type) type { }, .kitty_color_protocol => |v| { - if (@hasDecl(T, "sendKittyColorReport")) { - try self.handler.sendKittyColorReport(v); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.kitty_color_report, v); }, .show_desktop_notification => |v| { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 3fd074cf9..8d6c5c92d 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -301,6 +301,7 @@ pub const StreamHandler = struct { log.debug("setting kitty keyboard mode: not {}", .{value.flags}); self.terminal.screen.kitty_keyboard.set(.not, value.flags); }, + .kitty_color_report => try self.kittyColorReport(value), .prompt_end => try self.promptEnd(), .end_of_input => try self.endOfInput(), .end_hyperlink => try self.endHyperlink(), @@ -1382,7 +1383,7 @@ pub const StreamHandler = struct { } } - pub fn sendKittyColorReport( + fn kittyColorReport( self: *StreamHandler, request: terminal.kitty.color.OSC, ) !void { From 1d03451d4f861c87706780ad9dc3d8038a684489 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 25 Oct 2025 07:02:54 -0700 Subject: [PATCH 197/702] terminal: OSC color operations --- src/terminal/stream.zig | 27 +++++++++++++++++++-------- src/termio/stream_handler.zig | 3 ++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 2fb897d86..95062c6cd 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -126,6 +126,7 @@ pub const Action = union(Key) { configure_charset: ConfigureCharset, set_attribute: sgr.Attribute, kitty_color_report: kitty.color.OSC, + color_operation: ColorOperation, pub const Key = lib.Enum( lib_target, @@ -226,6 +227,7 @@ pub const Action = union(Key) { "configure_charset", "set_attribute", "kitty_color_report", + "color_operation", }, ); @@ -431,6 +433,18 @@ pub const Action = union(Key) { slot: charsets.Slots, charset: charsets.Charset, }); + + pub const ColorOperation = struct { + op: osc.color.Operation, + requests: osc.color.List, + terminator: osc.Terminator, + + pub const C = void; + + pub fn cval(_: ColorOperation) ColorOperation.C { + return {}; + } + }; }; /// Returns a type that can process a stream of tty control characters. @@ -1904,14 +1918,11 @@ pub fn Stream(comptime Handler: type) type { }, .color_operation => |v| { - if (@hasDecl(T, "handleColorOperation")) { - try self.handler.handleColorOperation( - v.op, - &v.requests, - v.terminator, - ); - return; - } else log.warn("unimplemented OSC callback: {}", .{cmd}); + try self.handler.vt(.color_operation, .{ + .op = v.op, + .requests = v.requests, + .terminator = v.terminator, + }); }, .kitty_color_protocol => |v| { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8d6c5c92d..7f241f42c 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -302,6 +302,7 @@ pub const StreamHandler = struct { self.terminal.screen.kitty_keyboard.set(.not, value.flags); }, .kitty_color_report => try self.kittyColorReport(value), + .color_operation => try self.colorOperation(value.op, &value.requests, value.terminator), .prompt_end => try self.promptEnd(), .end_of_input => try self.endOfInput(), .end_hyperlink => try self.endHyperlink(), @@ -1106,7 +1107,7 @@ pub const StreamHandler = struct { } } - pub fn handleColorOperation( + fn colorOperation( self: *StreamHandler, op: terminal.osc.color.Operation, requests: *const terminal.osc.color.List, From d39cc6d478edd6e1f412fa680e5166ea4f24c898 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Sat, 25 Oct 2025 18:08:53 +0200 Subject: [PATCH 198/702] macOS: update window appearance based on `preferredBackgroundColor` --- .../Window Styles/TitlebarTabsVenturaTerminalWindow.swift | 4 ++++ .../Window Styles/TransparentTitlebarTerminalWindow.swift | 4 ++++ src/config/Config.zig | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 9aa8ec2eb..c0aad46b3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -143,6 +143,10 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { super.syncAppearance(surfaceConfig) + // override appearance based on the terminal's background color + if let preferredBackgroundColor { + appearance = (preferredBackgroundColor.isLightColor ? NSAppearance(named: .aqua) : NSAppearance(named: .darkAqua)) + } // Update our window light/darkness based on our updated background color let themeChanged = isLightTheme != OSColor(surfaceConfig.backgroundColor).isLightColor diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 7ae628341..08d56c83d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -59,6 +59,10 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { super.syncAppearance(surfaceConfig) + // override appearance based on the terminal's background color + if let preferredBackgroundColor { + appearance = (preferredBackgroundColor.isLightColor ? NSAppearance(named: .aqua) : NSAppearance(named: .darkAqua)) + } // Save our config in case we need to reapply lastSurfaceConfig = surfaceConfig diff --git a/src/config/Config.zig b/src/config/Config.zig index c9ae121e4..505381977 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1765,7 +1765,7 @@ keybind: Keybinds = .{}, /// * `ghostty` - Use the background and foreground colors specified in the /// Ghostty configuration. This is only supported on Linux builds. /// -/// On macOS, if `macos-titlebar-style` is "tabs", the window theme will be +/// On macOS, if `macos-titlebar-style` is `tabs` or `transparent`, the window theme will be /// automatically set based on the luminosity of the terminal background color. /// This only applies to terminal windows. This setting will still apply to /// non-terminal windows within Ghostty. From 186b91ef84c47880ea5f5e337425f387417392ab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 25 Oct 2025 13:37:29 -0700 Subject: [PATCH 199/702] ci: temporarily disable FreeBSD test since it is failing --- .github/workflows/test.yml | 118 ++++++++++++++++++------------------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef03c5f32..73f171b7b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1141,62 +1141,62 @@ jobs: run: | nix develop -c zig build test-valgrind - build-freebsd: - name: Build on FreeBSD - needs: test - runs-on: namespace-profile-mitchellh-sm-systemd - strategy: - matrix: - release: - - "14.3" - - "15.0" - timeout-minutes: 10 - steps: - - name: Checkout Ghostty - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - name: Start SSH - run: | - sudo systemctl start ssh - - - name: Set up FreeBSD VM - uses: vmactions/freebsd-vm@487ce35b96fae3e60d45b521735f5aa436ecfade # v1.2.4 - with: - release: ${{ matrix.release }} - copyback: false - usesh: true - prepare: | - pkg install -y \ - devel/blueprint-compiler \ - devel/gettext \ - devel/git \ - devel/pkgconf \ - ftp/curl \ - graphics/wayland \ - security/ca_root_nss \ - textproc/hs-pandoc \ - x11-fonts/jetbrains-mono \ - x11-toolkits/libadwaita \ - x11-toolkits/gtk40 \ - x11-toolkits/gtk4-layer-shell - curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/${{ needs.test.outputs.zig_version }}/zig-x86_64-freebsd-${{ needs.test.outputs.zig_version }}.tar.xz" && \ - mkdir /opt && \ - tar -xf /tmp/zig.tar.xz -C /opt && \ - rm /tmp/zig.tar.xz && \ - ln -s "/opt/zig-x86_64-freebsd-${{ needs.test.outputs.zig_version }}/zig" /usr/local/bin/zig - - run: | - zig env - - - name: Run tests - shell: freebsd {0} - run: | - cd $GITHUB_WORKSPACE - zig build test - - - name: Build GTK app runtime - shell: freebsd {0} - run: | - cd $GITHUB_WORKSPACE - zig build - ./zig-out/bin/ghostty +version + # build-freebsd: + # name: Build on FreeBSD + # needs: test + # runs-on: namespace-profile-mitchellh-sm-systemd + # strategy: + # matrix: + # release: + # - "14.3" + # - "15.0" + # timeout-minutes: 10 + # steps: + # - name: Checkout Ghostty + # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + # + # - name: Start SSH + # run: | + # sudo systemctl start ssh + # + # - name: Set up FreeBSD VM + # uses: vmactions/freebsd-vm@487ce35b96fae3e60d45b521735f5aa436ecfade # v1.2.4 + # with: + # release: ${{ matrix.release }} + # copyback: false + # usesh: true + # prepare: | + # pkg install -y \ + # devel/blueprint-compiler \ + # devel/gettext \ + # devel/git \ + # devel/pkgconf \ + # ftp/curl \ + # graphics/wayland \ + # security/ca_root_nss \ + # textproc/hs-pandoc \ + # x11-fonts/jetbrains-mono \ + # x11-toolkits/libadwaita \ + # x11-toolkits/gtk40 \ + # x11-toolkits/gtk4-layer-shell + # curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/${{ needs.test.outputs.zig_version }}/zig-x86_64-freebsd-${{ needs.test.outputs.zig_version }}.tar.xz" && \ + # mkdir /opt && \ + # tar -xf /tmp/zig.tar.xz -C /opt && \ + # rm /tmp/zig.tar.xz && \ + # ln -s "/opt/zig-x86_64-freebsd-${{ needs.test.outputs.zig_version }}/zig" /usr/local/bin/zig + # + # run: | + # zig env + # + # - name: Run tests + # shell: freebsd {0} + # run: | + # cd $GITHUB_WORKSPACE + # zig build test + # + # - name: Build GTK app runtime + # shell: freebsd {0} + # run: | + # cd $GITHUB_WORKSPACE + # zig build + # ./zig-out/bin/ghostty +version From 580262c96f57b462d3f2c4768b2abcf576c0da0d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 25 Oct 2025 14:52:33 -0700 Subject: [PATCH 200/702] terminal: add ReadonlyStream that updates terminal state (#9346) This adds a new stream handler implementation that updates terminal state in reaction to VT sequences, but doesn't perform any of the actions that would require responses (e.g. queries). This is exposed in two ways: first, as a standalone `ReadonlyStream` and `ReadonlyHandler` type that contains all the implementation. Second, as a convenience func on `Terminal` as `vtStream` and `vtHandler` which return their respective types preconfigured to update the calling terminal state. This dramatically simplifies libghostty-vt usage from Zig (and will eventually be exposed to C, too) since a Terminal on its own is ready to go as a full VT parser and state machine without needing to build any custom types! There's a second big bonus here which is that our `stream_readonly.zig` tests are true end-to-end tests for raw bytes to terminal state. This will let us test a wider variety of situations more broadly. To start, there are only a handful of tests implemented here. **AI disclosure:** Amp wrote basically this whole thing, but I reviewed it. https://ampcode.com/threads/T-3490efd2-1137-4112-96f6-4bf8a0141ff5 --- .github/workflows/test.yml | 2 +- example/zig-vt-stream/README.md | 33 ++ example/zig-vt-stream/build.zig | 39 ++ example/zig-vt-stream/build.zig.zon | 14 + example/zig-vt-stream/src/main.zig | 40 ++ src/terminal/Terminal.zig | 15 + src/terminal/main.zig | 3 + src/terminal/stream.zig | 26 ++ src/terminal/stream_readonly.zig | 542 ++++++++++++++++++++++++++++ src/termio/Termio.zig | 9 +- 10 files changed, 714 insertions(+), 9 deletions(-) create mode 100644 example/zig-vt-stream/README.md create mode 100644 example/zig-vt-stream/build.zig create mode 100644 example/zig-vt-stream/build.zig.zon create mode 100644 example/zig-vt-stream/src/main.zig create mode 100644 src/terminal/stream_readonly.zig diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 73f171b7b..b40acaa74 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -94,7 +94,7 @@ jobs: strategy: fail-fast: false matrix: - dir: [c-vt, c-vt-key-encode, c-vt-paste, zig-vt] + dir: [c-vt, c-vt-key-encode, c-vt-paste, zig-vt, zig-vt-stream] name: Example ${{ matrix.dir }} runs-on: namespace-profile-ghostty-sm needs: test diff --git a/example/zig-vt-stream/README.md b/example/zig-vt-stream/README.md new file mode 100644 index 000000000..d285009da --- /dev/null +++ b/example/zig-vt-stream/README.md @@ -0,0 +1,33 @@ +# Example: `vtStream` API for Parsing Terminal Streams + +This example demonstrates how to use the `vtStream` API to parse and process +VT sequences. The `vtStream` API is ideal for read-only terminal applications +that need to parse terminal output without responding to queries, such as: + +- Replay tooling +- CI log viewers +- PaaS builder output +- etc. + +The stream processes VT escape sequences and updates terminal state, while +ignoring sequences that require responses (like device status queries). + +Requires the Zig version stated in the `build.zig.zon` file. + +## Usage + +Run the program: + +```shell-session +zig build run +``` + +The example will process various VT sequences including: + +- Plain text output +- ANSI color codes +- Cursor positioning +- Line clearing +- Multiple line handling + +And display the final terminal state after processing all sequences. diff --git a/example/zig-vt-stream/build.zig b/example/zig-vt-stream/build.zig new file mode 100644 index 000000000..feee8f27d --- /dev/null +++ b/example/zig-vt-stream/build.zig @@ -0,0 +1,39 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + const test_step = b.step("test", "Run unit tests"); + + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + if (b.lazyDependency("ghostty", .{})) |dep| { + exe_mod.addImport( + "ghostty-vt", + dep.module("ghostty-vt"), + ); + } + + const exe = b.addExecutable(.{ + .name = "zig_vt_stream", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_module = exe_mod, + }); + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/example/zig-vt-stream/build.zig.zon b/example/zig-vt-stream/build.zig.zon new file mode 100644 index 000000000..036c79592 --- /dev/null +++ b/example/zig-vt-stream/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .zig_vt_stream, + .version = "0.0.0", + .fingerprint = 0x34c1f71303690b3f, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + .ghostty = .{ .path = "../../" }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/zig-vt-stream/src/main.zig b/example/zig-vt-stream/src/main.zig new file mode 100644 index 000000000..8fd438b70 --- /dev/null +++ b/example/zig-vt-stream/src/main.zig @@ -0,0 +1,40 @@ +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); + +pub fn main() !void { + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + var t: ghostty_vt.Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); + defer t.deinit(alloc); + + // Create a read-only VT stream for parsing terminal sequences + var stream = t.vtStream(); + defer stream.deinit(); + + // Basic text with newline + try stream.nextSlice("Hello, World!\r\n"); + + // ANSI color codes: ESC[1;32m = bold green, ESC[0m = reset + try stream.nextSlice("\x1b[1;32mGreen Text\x1b[0m\r\n"); + + // Cursor positioning: ESC[1;1H = move to row 1, column 1 + try stream.nextSlice("\x1b[1;1HTop-left corner\r\n"); + + // Cursor movement: ESC[5B = move down 5 lines + try stream.nextSlice("\x1b[5B"); + try stream.nextSlice("Moved down!\r\n"); + + // Erase line: ESC[2K = clear entire line + try stream.nextSlice("\x1b[2K"); + try stream.nextSlice("New content\r\n"); + + // Multiple lines + try stream.nextSlice("Line A\r\nLine B\r\nLine C\r\n"); + + // Get the final terminal state as a plain string + const str = try t.plainString(alloc); + defer alloc.free(str); + std.debug.print("{s}\n", .{str}); +} diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index a8cee90fb..2201a324c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -22,6 +22,8 @@ const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); const color = @import("color.zig"); const mouse_shape_pkg = @import("mouse_shape.zig"); +const ReadonlyHandler = @import("stream_readonly.zig").Handler; +const ReadonlyStream = @import("stream_readonly.zig").Stream; const size = @import("size.zig"); const pagepkg = @import("page.zig"); @@ -239,6 +241,19 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { self.* = undefined; } +/// Return a terminal.Stream that can process VT streams and update this +/// terminal state. The streams will only process read-only data that +/// modifies terminal state. Sequences that query or otherwise require +/// output will be ignored. +pub fn vtStream(self: *Terminal) ReadonlyStream { + return .initAlloc(self.gpa(), self.vtHandler()); +} + +/// This is the handler-side only for vtStream. +pub fn vtHandler(self: *Terminal) ReadonlyHandler { + return .init(self); +} + /// The general allocator we should use for this terminal. fn gpa(self: *Terminal) Allocator { return self.screen.alloc; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 5c19af023..0d6a053c8 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -6,6 +6,7 @@ const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const hyperlink = @import("hyperlink.zig"); const sgr = @import("sgr.zig"); +const stream_readonly = @import("stream_readonly.zig"); const style = @import("style.zig"); pub const apc = @import("apc.zig"); pub const dcs = @import("dcs.zig"); @@ -36,6 +37,8 @@ pub const PageList = @import("PageList.zig"); pub const Parser = @import("Parser.zig"); pub const Pin = PageList.Pin; pub const Point = point.Point; +pub const ReadonlyHandler = stream_readonly.Handler; +pub const ReadonlyStream = stream_readonly.Stream; pub const Screen = @import("Screen.zig"); pub const ScreenType = Terminal.ScreenType; pub const Scrollbar = PageList.Scrollbar; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 95062c6cd..a38e5ce9e 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -3,6 +3,7 @@ const std = @import("std"); const build_options = @import("terminal_options"); const assert = std.debug.assert; const testing = std.testing; +const Allocator = std.mem.Allocator; const simd = @import("../simd/main.zig"); const lib = @import("../lib/main.zig"); const Parser = @import("Parser.zig"); @@ -29,6 +30,8 @@ const debug = false; const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; +/// The possible actions that can be emitted by the Stream +/// function for handling. pub const Action = union(Key) { print: Print, print_repeat: usize, @@ -456,6 +459,8 @@ pub const Action = union(Key) { /// about in its pursuit of implementing a terminal emulator or other /// functionality. /// +/// The Handler type must also have a `deinit` function. +/// /// The "comptime" key is on purpose (vs. a standard Zig tagged union) /// because it allows the compiler to optimize away unimplemented actions. /// e.g. you don't need to pay a conditional branching cost on every single @@ -476,6 +481,19 @@ pub fn Stream(comptime Handler: type) type { parser: Parser, utf8decoder: UTF8Decoder, + /// Initialize an allocation-free stream. This will preallocate various + /// sizes as necessary and anything over that will be dropped. If you + /// want to support more dynamic behavior use initAlloc instead. + /// + /// As a concrete example of something that requires heap allocation, + /// consider OSC 52 (clipboard operations) which can be arbitrarily + /// large. + /// + /// If you want to limit allocation size, use an allocator with + /// a size limit with initAlloc. + /// + /// This takes ownership of the handler and will call deinit + /// when the stream is deinitialized. pub fn init(h: Handler) Self { return .{ .handler = h, @@ -484,8 +502,16 @@ pub fn Stream(comptime Handler: type) type { }; } + /// Initialize the stream that supports heap allocation as necessary. + pub fn initAlloc(alloc: Allocator, h: Handler) Self { + var self: Self = .init(h); + self.parser.osc_parser.alloc = alloc; + return self; + } + pub fn deinit(self: *Self) void { self.parser.deinit(); + self.handler.deinit(); } /// Process a string of characters. diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig new file mode 100644 index 000000000..4d284d990 --- /dev/null +++ b/src/terminal/stream_readonly.zig @@ -0,0 +1,542 @@ +const std = @import("std"); +const testing = std.testing; +const stream = @import("stream.zig"); +const Action = stream.Action; +const CursorStyle = @import("Screen.zig").CursorStyle; +const Mode = @import("modes.zig").Mode; +const Terminal = @import("Terminal.zig"); + +/// This is a Stream implementation that processes actions against +/// a Terminal and updates the Terminal state. It is called "readonly" because +/// it only processes actions that modify terminal state, while ignoring +/// any actions that require a response (like queries). +/// +/// If you're implementing a terminal emulator that only needs to render +/// output and doesn't need to respond (since it maybe isn't running the +/// actual program), this is the stream type to use. For example, this is +/// ideal for replay tooling, CI logs, PaaS builder output, etc. +pub const Stream = stream.Stream(Handler); + +/// See Stream, which is just the stream wrapper around this. +/// +/// This isn't attached directly to Terminal because there is additional +/// state and options we plan to add in the future, such as APC/DCS which +/// don't make sense to me to add to the Terminal directly. Instead, you +/// can call `vtHandler` on Terminal to initialize this handler. +pub const Handler = struct { + /// The terminal state to modify. + terminal: *Terminal, + + pub fn init(terminal: *Terminal) Handler { + return .{ + .terminal = terminal, + }; + } + + pub fn deinit(self: *Handler) void { + // Currently does nothing but may in the future so callers should + // call this. + _ = self; + } + + pub fn vt( + self: *Handler, + comptime action: Action.Tag, + value: Action.Value(action), + ) !void { + switch (action) { + .print => try self.terminal.print(value.cp), + .print_repeat => try self.terminal.printRepeat(value), + .backspace => self.terminal.backspace(), + .carriage_return => self.terminal.carriageReturn(), + .linefeed => try self.terminal.linefeed(), + .index => try self.terminal.index(), + .next_line => { + try self.terminal.index(); + self.terminal.carriageReturn(); + }, + .reverse_index => self.terminal.reverseIndex(), + .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_col => self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, value.value), + .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screen.cursor.x + 1), + .cursor_col_relative => self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1, + self.terminal.screen.cursor.x + 1 +| value.value, + ), + .cursor_row_relative => self.terminal.setCursorPos( + self.terminal.screen.cursor.y + 1 +| value.value, + self.terminal.screen.cursor.x + 1, + ), + .cursor_style => { + const blink = switch (value) { + .default, .steady_block, .steady_bar, .steady_underline => false, + .blinking_block, .blinking_bar, .blinking_underline => true, + }; + const style: CursorStyle = switch (value) { + .default, .blinking_block, .steady_block => .block, + .blinking_bar, .steady_bar => .bar, + .blinking_underline, .steady_underline => .underline, + }; + self.terminal.modes.set(.cursor_blinking, blink); + self.terminal.screen.cursor.cursor_style = style; + }, + .erase_display_below => self.terminal.eraseDisplay(.below, value), + .erase_display_above => self.terminal.eraseDisplay(.above, value), + .erase_display_complete => self.terminal.eraseDisplay(.complete, value), + .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), + .erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value), + .erase_line_right => self.terminal.eraseLine(.right, value), + .erase_line_left => self.terminal.eraseLine(.left, value), + .erase_line_complete => self.terminal.eraseLine(.complete, value), + .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value), + .delete_chars => self.terminal.deleteChars(value), + .erase_chars => self.terminal.eraseChars(value), + .insert_lines => self.terminal.insertLines(value), + .insert_blanks => self.terminal.insertBlanks(value), + .delete_lines => self.terminal.deleteLines(value), + .scroll_up => self.terminal.scrollUp(value), + .scroll_down => self.terminal.scrollDown(value), + .horizontal_tab => try self.horizontalTab(value), + .horizontal_tab_back => try self.horizontalTabBack(value), + .tab_clear_current => self.terminal.tabClear(.current), + .tab_clear_all => self.terminal.tabClear(.all), + .tab_set => self.terminal.tabSet(), + .tab_reset => self.terminal.tabReset(), + .set_mode => try self.setMode(value.mode, true), + .reset_mode => try self.setMode(value.mode, false), + .save_mode => self.terminal.modes.save(value.mode), + .restore_mode => { + const v = self.terminal.modes.restore(value.mode); + try self.setMode(value.mode, v); + }, + .top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right), + .left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right), + .left_and_right_margin_ambiguous => { + if (self.terminal.modes.get(.enable_left_and_right_margin)) { + self.terminal.setLeftAndRightMargin(0, 0); + } else { + self.terminal.saveCursor(); + } + }, + .save_cursor => self.terminal.saveCursor(), + .restore_cursor => try self.terminal.restoreCursor(), + .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), + .configure_charset => self.terminal.configureCharset(value.slot, value.charset), + .set_attribute => switch (value) { + .unknown => {}, + else => self.terminal.setAttribute(value) catch {}, + }, + .protected_mode_off => self.terminal.setProtectedMode(.off), + .protected_mode_iso => self.terminal.setProtectedMode(.iso), + .protected_mode_dec => self.terminal.setProtectedMode(.dec), + .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, + .kitty_keyboard_push => self.terminal.screen.kitty_keyboard.push(value.flags), + .kitty_keyboard_pop => self.terminal.screen.kitty_keyboard.pop(@intCast(value)), + .kitty_keyboard_set => self.terminal.screen.kitty_keyboard.set(.set, value.flags), + .kitty_keyboard_set_or => self.terminal.screen.kitty_keyboard.set(.@"or", value.flags), + .kitty_keyboard_set_not => self.terminal.screen.kitty_keyboard.set(.not, value.flags), + .modify_key_format => { + self.terminal.flags.modify_other_keys_2 = false; + switch (value) { + .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true, + else => {}, + } + }, + .active_status_display => self.terminal.status_display = value, + .decaln => try self.terminal.decaln(), + .full_reset => self.terminal.fullReset(), + .start_hyperlink => try self.terminal.screen.startHyperlink(value.uri, value.id), + .end_hyperlink => self.terminal.screen.endHyperlink(), + .prompt_start => { + self.terminal.screen.cursor.page_row.semantic_prompt = .prompt; + self.terminal.flags.shell_redraws_prompt = value.redraw; + }, + .prompt_continuation => self.terminal.screen.cursor.page_row.semantic_prompt = .prompt_continuation, + .prompt_end => self.terminal.markSemanticPrompt(.input), + .end_of_input => self.terminal.markSemanticPrompt(.command), + .end_of_command => self.terminal.screen.cursor.page_row.semantic_prompt = .input, + .mouse_shape => self.terminal.mouse_shape = value, + + // No supported DCS commands have any terminal-modifying effects, + // but they may in the future. For now we just ignore it. + .dcs_hook, + .dcs_put, + .dcs_unhook, + => {}, + + // APC can modify terminal state (Kitty graphics) but we don't + // currently support it in the readonly stream. + .apc_start, + .apc_end, + .apc_put, + => {}, + + // Have no terminal-modifying effect + .bell, + .enquiry, + .request_mode, + .request_mode_unknown, + .size_report, + .xtversion, + .device_attributes, + .device_status, + .kitty_keyboard_query, + .kitty_color_report, + .color_operation, + .window_title, + .report_pwd, + .show_desktop_notification, + .progress_report, + .clipboard_contents, + .title_push, + .title_pop, + => {}, + } + } + + inline fn horizontalTab(self: *Handler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screen.cursor.x; + try self.terminal.horizontalTab(); + if (x == self.terminal.screen.cursor.x) break; + } + } + + inline fn horizontalTabBack(self: *Handler, count: u16) !void { + for (0..count) |_| { + const x = self.terminal.screen.cursor.x; + try self.terminal.horizontalTabBack(); + if (x == self.terminal.screen.cursor.x) break; + } + } + + fn setMode(self: *Handler, mode: Mode, enabled: bool) !void { + // Set the mode on the terminal + self.terminal.modes.set(mode, enabled); + + // Some modes require additional processing + switch (mode) { + .autorepeat, + .reverse_colors, + => {}, + + .origin => self.terminal.setCursorPos(1, 1), + + .enable_left_and_right_margin => if (!enabled) { + self.terminal.scrolling_region.left = 0; + self.terminal.scrolling_region.right = self.terminal.cols - 1; + }, + + .alt_screen_legacy => self.terminal.switchScreenMode(.@"47", enabled), + .alt_screen => self.terminal.switchScreenMode(.@"1047", enabled), + .alt_screen_save_cursor_clear_enter => self.terminal.switchScreenMode(.@"1049", enabled), + + .save_cursor => if (enabled) { + self.terminal.saveCursor(); + } else { + try self.terminal.restoreCursor(); + }, + + .enable_mode_3 => {}, + + .@"132_column" => try self.terminal.deccolm( + self.terminal.screen.alloc, + if (enabled) .@"132_cols" else .@"80_cols", + ), + + .synchronized_output, + .linefeed, + .in_band_size_reports, + .focus_event, + => {}, + + .mouse_event_x10 => { + if (enabled) { + self.terminal.flags.mouse_event = .x10; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + .mouse_event_normal => { + if (enabled) { + self.terminal.flags.mouse_event = .normal; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + .mouse_event_button => { + if (enabled) { + self.terminal.flags.mouse_event = .button; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + .mouse_event_any => { + if (enabled) { + self.terminal.flags.mouse_event = .any; + } else { + self.terminal.flags.mouse_event = .none; + } + }, + + .mouse_format_utf8 => self.terminal.flags.mouse_format = if (enabled) .utf8 else .x10, + .mouse_format_sgr => self.terminal.flags.mouse_format = if (enabled) .sgr else .x10, + .mouse_format_urxvt => self.terminal.flags.mouse_format = if (enabled) .urxvt else .x10, + .mouse_format_sgr_pixels => self.terminal.flags.mouse_format = if (enabled) .sgr_pixels else .x10, + + else => {}, + } + } +}; + +test "basic print" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + try s.nextSlice("Hello"); + try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello", str); +} + +test "cursor movement" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Move cursor using escape sequences + try s.nextSlice("Hello\x1B[1;1H"); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + + // Move to position 2,3 + try s.nextSlice("\x1B[2;3H"); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); +} + +test "erase operations" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 20, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Print some text + try s.nextSlice("Hello World"); + try testing.expectEqual(@as(usize, 11), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + + // Move cursor to position 1,6 and erase from cursor to end of line + try s.nextSlice("\x1B[1;6H"); + try s.nextSlice("\x1B[K"); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello", str); +} + +test "tabs" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + try s.nextSlice("A\tB"); + try testing.expectEqual(@as(usize, 9), t.screen.cursor.x); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A B", str); +} + +test "modes" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Test wraparound mode + try testing.expect(t.modes.get(.wraparound)); + try s.nextSlice("\x1B[?7l"); // Disable wraparound + try testing.expect(!t.modes.get(.wraparound)); + try s.nextSlice("\x1B[?7h"); // Enable wraparound + try testing.expect(t.modes.get(.wraparound)); +} + +test "scrolling regions" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set scrolling region from line 5 to 20 + try s.nextSlice("\x1B[5;20r"); + try testing.expectEqual(@as(usize, 4), t.scrolling_region.top); + try testing.expectEqual(@as(usize, 19), t.scrolling_region.bottom); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); + try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); +} + +test "charsets" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Configure G0 as DEC special graphics + try s.nextSlice("\x1B(0"); + try s.nextSlice("`"); // Should print diamond character + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("◆", str); +} + +test "alt screen" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 5 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write to primary screen + try s.nextSlice("Primary"); + try testing.expectEqual(Terminal.ScreenType.primary, t.active_screen); + + // Switch to alt screen + try s.nextSlice("\x1B[?1049h"); + try testing.expectEqual(Terminal.ScreenType.alternate, t.active_screen); + + // Write to alt screen + try s.nextSlice("Alt"); + + // Switch back to primary + try s.nextSlice("\x1B[?1049l"); + try testing.expectEqual(Terminal.ScreenType.primary, t.active_screen); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Primary", str); +} + +test "cursor save and restore" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Move cursor to 10,15 + try s.nextSlice("\x1B[10;15H"); + try testing.expectEqual(@as(usize, 14), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + + // Save cursor + try s.nextSlice("\x1B7"); + + // Move cursor elsewhere + try s.nextSlice("\x1B[1;1H"); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + + // Restore cursor + try s.nextSlice("\x1B8"); + try testing.expectEqual(@as(usize, 14), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); +} + +test "attributes" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set bold and write text + try s.nextSlice("\x1B[1mBold\x1B[0m"); + + // Verify we can write attributes - just check the string was written + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Bold", str); +} + +test "DECALN screen alignment" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 3 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Run DECALN + try s.nextSlice("\x1B#8"); + + // Verify entire screen is filled with 'E' + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("EEEEEEEEEE\nEEEEEEEEEE\nEEEEEEEEEE", str); + + // Cursor should be at 1,1 + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); +} + +test "full reset" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Make some changes + try s.nextSlice("Hello"); + try s.nextSlice("\x1B[10;20H"); + try s.nextSlice("\x1B[5;20r"); // Set scroll region + try s.nextSlice("\x1B[?7l"); // Disable wraparound + + // Full reset + try s.nextSlice("\x1Bc"); + + // Verify reset state + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); + try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom); + try testing.expect(t.modes.get(.wraparound)); +} + +test "ignores query actions" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // These should be ignored without error + try s.nextSlice("\x1B[c"); // Device attributes + try s.nextSlice("\x1B[5n"); // Device status report + try s.nextSlice("\x1B[6n"); // Cursor position report + + // Terminal should still be functional + try s.nextSlice("Test"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Test", str); +} diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 2f1bf227d..01a8ef312 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -313,13 +313,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .size = opts.size, .backend = backend, .mailbox = opts.mailbox, - .terminal_stream = stream: { - var s: terminalpkg.Stream(StreamHandler) = .init(handler); - // Populate the OSC parser allocator (optional) because - // we want to support large OSC payloads such as OSC 52. - s.parser.osc_parser.alloc = alloc; - break :stream s; - }, + .terminal_stream = .initAlloc(alloc, handler), .thread_enter_state = thread_enter_state, }; } @@ -331,7 +325,6 @@ pub fn deinit(self: *Termio) void { self.mailbox.deinit(self.alloc); // Clear any StreamHandler state - self.terminal_stream.handler.deinit(); self.terminal_stream.deinit(); // Clear any initial state if we have it From 973cfd98a5636e40898865597afaf0600ddfbeff Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 25 Oct 2025 18:10:59 -0700 Subject: [PATCH 201/702] Update Inspector to the new Stream/VTHandler APIs (#9350) Fixes a double-free bug when closing the inspector. Not sure if there's a good way to add a test to check that allocation and deallocation are sound here? To instantiate an Inspector you need a Surface, which in turn requires an entire App/apprt. Doesn't look like the repo has any tests with that kind of scope at the moment? --- src/inspector/Inspector.zig | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 49b05bd7f..92da5a362 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -172,11 +172,7 @@ pub fn init(surface: *Surface) !Inspector { .surface = surface, .key_events = key_buf, .vt_events = vt_events, - .vt_stream = stream: { - var s: inspector.termio.Stream = .init(vt_handler); - s.parser.osc_parser.alloc = surface.alloc; - break :stream s; - }, + .vt_stream = .initAlloc(surface.alloc, vt_handler), }; } @@ -194,7 +190,6 @@ pub fn deinit(self: *Inspector) void { while (it.next()) |v| v.deinit(self.surface.alloc); self.vt_events.deinit(self.surface.alloc); - self.vt_stream.handler.deinit(); self.vt_stream.deinit(); } } From 75b4e8b5a73363aa7c6be1397e8649797b7403c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 25 Oct 2025 18:11:16 -0700 Subject: [PATCH 202/702] Update iTerm2 colorschemes (#9349) Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/release-20251020-150521-589c0ea Co-authored-by: mitchellh <1299+mitchellh@users.noreply.github.com> --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 1bd4d6a5b..2f238d8a2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251013-150525-147b9d3/ghostty-themes.tgz", - .hash = "N-V-__8AAPk1AwDvSIEm8ftVzPLJr-Uuzz65Ss2R4ljTXjSq", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251020-150521-589c0ea/ghostty-themes.tgz", + .hash = "N-V-__8AAPk1AwBAULkMLaJVuAoOyhTMRIh-joZs-jRJKHBJ", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index cf2857147..16f0f1f4e 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAPk1AwDvSIEm8ftVzPLJr-Uuzz65Ss2R4ljTXjSq": { + "N-V-__8AAPk1AwBAULkMLaJVuAoOyhTMRIh-joZs-jRJKHBJ": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251013-150525-147b9d3/ghostty-themes.tgz", - "hash": "sha256-vFn6MiR8tVkGyjSV7pz4k4msviSCjGHKM2SU84lJp/k=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251020-150521-589c0ea/ghostty-themes.tgz", + "hash": "sha256-y2vhwlDUpgC6x5XPpDY96KNSHd/sRhyJpgiuzstP9p0=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 1ac748b69..7ab87e26c 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AAPk1AwDvSIEm8ftVzPLJr-Uuzz65Ss2R4ljTXjSq"; + name = "N-V-__8AAPk1AwBAULkMLaJVuAoOyhTMRIh-joZs-jRJKHBJ"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251013-150525-147b9d3/ghostty-themes.tgz"; - hash = "sha256-vFn6MiR8tVkGyjSV7pz4k4msviSCjGHKM2SU84lJp/k="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251020-150521-589c0ea/ghostty-themes.tgz"; + hash = "sha256-y2vhwlDUpgC6x5XPpDY96KNSHd/sRhyJpgiuzstP9p0="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 398231198..c0576d8f2 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -29,7 +29,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/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251013-150525-147b9d3/ghostty-themes.tgz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251020-150521-589c0ea/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 d762d82c1..3de5fd0e6 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-20251013-150525-147b9d3/ghostty-themes.tgz", - "dest": "vendor/p/N-V-__8AAPk1AwDvSIEm8ftVzPLJr-Uuzz65Ss2R4ljTXjSq", - "sha256": "bc59fa32247cb55906ca3495ee9cf89389acbe24828c61ca336494f38949a7f9" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251020-150521-589c0ea/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AAPk1AwBAULkMLaJVuAoOyhTMRIh-joZs-jRJKHBJ", + "sha256": "cb6be1c250d4a600bac795cfa4363de8a3521ddfec461c89a608aececb4ff69d" }, { "type": "archive", From fd969b53a5553f284b28223da98cb92f09277a3a Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 26 Oct 2025 04:08:32 +0100 Subject: [PATCH 203/702] macOS: fix #8282 (#9343) After `ghostty_app_update_config`, `ghostty_action_config_change_s` was fired with the correct config. This happens synchronously, which will update `App.config` in `App.configChange(_:target:v:)`. Previously, after updating, `App.config` was set with the stale one, which caused #8282. --- macos/Sources/Ghostty/Ghostty.App.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 3db8e7a11..690caac34 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -149,10 +149,7 @@ extension Ghostty { } ghostty_app_update_config(app, newConfig.config!) - - // We can only set our config after updating it so that we don't free - // memory that may still be in use - self.config = newConfig + /// applied config will be updated in ``Self.configChange(_:target:v:)`` } func reloadConfig(surface: ghostty_surface_t, soft: Bool = false) { From 27b0978cd5c6aec7a9f902c94d793fa414996eab Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 25 Oct 2025 20:24:52 -0700 Subject: [PATCH 204/702] macos: use system beep for bell (#9339) This seems pretty straightforward. I've tested it and it does what I'd expect it to do. Fixes #9338 --- macos/Sources/App/macOS/AppDelegate.swift | 4 ++++ src/config/Config.zig | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 9a6eab47b..a723d015a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -724,6 +724,10 @@ class AppDelegate: NSObject, } @objc private func ghosttyBellDidRing(_ notification: Notification) { + if (ghostty.config.bellFeatures.contains(.system)) { + NSSound.beep() + } + if (ghostty.config.bellFeatures.contains(.attention)) { // Bounce the dock icon if we're not focused. NSApp.requestUserAttention(.informationalRequest) diff --git a/src/config/Config.zig b/src/config/Config.zig index 505381977..8ba1e47db 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2646,7 +2646,9 @@ keybind: Keybinds = .{}, /// This could result in an audiovisual effect, a notification, or something /// else entirely. Changing these effects require altering system settings: /// for instance under the "Sound > Alert Sound" setting in GNOME, -/// or the "Accessibility > System Bell" settings in KDE Plasma. (GTK only) +/// or the "Accessibility > System Bell" settings in KDE Plasma. +/// +/// On macOS, this plays the system alert sound. /// /// * `audio` /// From a82ad89ef3a0cb773eb649435b0d2ba59b5957aa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 25 Oct 2025 21:26:06 -0700 Subject: [PATCH 205/702] lib-vt: C API for SGR parser (#9352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This exposes the SGR parser to the C and Wasm APIs. An example is shown in c-vt-sgr. Compressed example: ```c #include #include #include int main() { // Create parser GhosttySgrParser parser; assert(ghostty_sgr_new(NULL, &parser) == GHOSTTY_SUCCESS); // Parse: ESC[1;31m (bold + red foreground) uint16_t params[] = {1, 31}; assert(ghostty_sgr_set_params(parser, params, NULL, 2) == GHOSTTY_SUCCESS); printf("Parsing: ESC[1;31m\n\n"); // Iterate through attributes GhosttySgrAttribute attr; while (ghostty_sgr_next(parser, &attr)) { switch (attr.tag) { case GHOSTTY_SGR_ATTR_BOLD: printf("✓ Bold enabled\n"); break; case GHOSTTY_SGR_ATTR_FG_8: printf("✓ Foreground color: %d (red)\n", attr.value.fg_8); break; default: break; } } ghostty_sgr_free(parser); return 0; } ``` **AI disclosure:** Amp wrote most of the C headers, but I verified it all. https://ampcode.com/threads/T-d9f145cb-e6ef-48a8-ad63-e5fc85c0d43e --- .github/workflows/test.yml | 3 +- example/c-vt-sgr/README.md | 21 +++ example/c-vt-sgr/build.zig | 42 +++++ example/c-vt-sgr/build.zig.zon | 24 +++ example/c-vt-sgr/src/main.c | 131 ++++++++++++++ include/ghostty/vt.h | 8 + include/ghostty/vt/color.h | 77 +++++++++ include/ghostty/vt/result.h | 2 + include/ghostty/vt/sgr.h | 306 +++++++++++++++++++++++++++++++++ src/lib_vt.zig | 19 +- src/terminal/c/main.zig | 8 + src/terminal/c/result.zig | 1 + src/terminal/c/sgr.zig | 142 +++++++++++++++ src/terminal/main.zig | 2 +- src/terminal/sgr.zig | 7 +- 15 files changed, 782 insertions(+), 11 deletions(-) create mode 100644 example/c-vt-sgr/README.md create mode 100644 example/c-vt-sgr/build.zig create mode 100644 example/c-vt-sgr/build.zig.zon create mode 100644 example/c-vt-sgr/src/main.c create mode 100644 include/ghostty/vt/color.h create mode 100644 include/ghostty/vt/sgr.h create mode 100644 src/terminal/c/sgr.zig diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b40acaa74..f506a62b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -94,7 +94,8 @@ jobs: strategy: fail-fast: false matrix: - dir: [c-vt, c-vt-key-encode, c-vt-paste, zig-vt, zig-vt-stream] + dir: + [c-vt, c-vt-key-encode, c-vt-paste, c-vt-sgr, zig-vt, zig-vt-stream] name: Example ${{ matrix.dir }} runs-on: namespace-profile-ghostty-sm needs: test diff --git a/example/c-vt-sgr/README.md b/example/c-vt-sgr/README.md new file mode 100644 index 000000000..c89e1aec9 --- /dev/null +++ b/example/c-vt-sgr/README.md @@ -0,0 +1,21 @@ +# Example: `ghostty-vt` SGR Parser + +This contains a simple example of how to use the `ghostty-vt` SGR parser +to parse terminal styling sequences and extract text attributes. + +This example demonstrates parsing a complex SGR sequence from Kakoune that +includes curly underline, RGB foreground/background colors, and RGB underline +color with mixed semicolon and colon separators. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-sgr/build.zig b/example/c-vt-sgr/build.zig new file mode 100644 index 000000000..ea6ea6e1e --- /dev/null +++ b/example/c-vt-sgr/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_sgr", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-sgr/build.zig.zon b/example/c-vt-sgr/build.zig.zon new file mode 100644 index 000000000..0d33b0897 --- /dev/null +++ b/example/c-vt-sgr/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_sgr, + .version = "0.0.0", + .fingerprint = 0x6e9c6d318e59c268, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-sgr/src/main.c b/example/c-vt-sgr/src/main.c new file mode 100644 index 000000000..21a529726 --- /dev/null +++ b/example/c-vt-sgr/src/main.c @@ -0,0 +1,131 @@ +#include +#include +#include + +int main() { + // Create parser + GhosttySgrParser parser; + GhosttyResult result = ghostty_sgr_new(NULL, &parser); + assert(result == GHOSTTY_SUCCESS); + + // Parse a complex SGR sequence from Kakoune + // This corresponds to the escape sequence: + // ESC[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136m + // + // Breaking down the sequence: + // - 4:3 = curly underline (colon-separated sub-parameters) + // - 38;2;51;51;51 = foreground RGB color (51, 51, 51) - dark gray + // - 48;2;170;170;170 = background RGB color (170, 170, 170) - light gray + // - 58;2;255;97;136 = underline RGB color (255, 97, 136) - pink + uint16_t params[] = {4, 3, 38, 2, 51, 51, 51, 48, 2, 170, 170, 170, 58, 2, 255, 97, 136}; + + // Separator array: ':' at position 0 (between 4 and 3), ';' elsewhere + char separators[] = ";;;;;;;;;;;;;;;;"; + separators[0] = ':'; + + result = ghostty_sgr_set_params(parser, params, separators, sizeof(params) / sizeof(params[0])); + assert(result == GHOSTTY_SUCCESS); + + printf("Parsing Kakoune SGR sequence:\n"); + printf("ESC[4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136m\n\n"); + + // Iterate through attributes + GhosttySgrAttribute attr; + int count = 0; + while (ghostty_sgr_next(parser, &attr)) { + count++; + printf("Attribute %d: ", count); + + switch (attr.tag) { + case GHOSTTY_SGR_ATTR_UNDERLINE: + printf("Underline style = "); + switch (attr.value.underline) { + case GHOSTTY_SGR_UNDERLINE_NONE: + printf("none\n"); + break; + case GHOSTTY_SGR_UNDERLINE_SINGLE: + printf("single\n"); + break; + case GHOSTTY_SGR_UNDERLINE_DOUBLE: + printf("double\n"); + break; + case GHOSTTY_SGR_UNDERLINE_CURLY: + printf("curly\n"); + break; + case GHOSTTY_SGR_UNDERLINE_DOTTED: + printf("dotted\n"); + break; + case GHOSTTY_SGR_UNDERLINE_DASHED: + printf("dashed\n"); + break; + default: + printf("unknown (%d)\n", attr.value.underline); + break; + } + break; + + case GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG: + printf("Foreground RGB = (%d, %d, %d)\n", + attr.value.direct_color_fg.r, + attr.value.direct_color_fg.g, + attr.value.direct_color_fg.b); + break; + + case GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG: + printf("Background RGB = (%d, %d, %d)\n", + attr.value.direct_color_bg.r, + attr.value.direct_color_bg.g, + attr.value.direct_color_bg.b); + break; + + case GHOSTTY_SGR_ATTR_UNDERLINE_COLOR: + printf("Underline color RGB = (%d, %d, %d)\n", + attr.value.underline_color.r, + attr.value.underline_color.g, + attr.value.underline_color.b); + break; + + case GHOSTTY_SGR_ATTR_FG_8: + printf("Foreground 8-color = %d\n", attr.value.fg_8); + break; + + case GHOSTTY_SGR_ATTR_BG_8: + printf("Background 8-color = %d\n", attr.value.bg_8); + break; + + case GHOSTTY_SGR_ATTR_FG_256: + printf("Foreground 256-color = %d\n", attr.value.fg_256); + break; + + case GHOSTTY_SGR_ATTR_BG_256: + printf("Background 256-color = %d\n", attr.value.bg_256); + break; + + case GHOSTTY_SGR_ATTR_BOLD: + printf("Bold\n"); + break; + + case GHOSTTY_SGR_ATTR_ITALIC: + printf("Italic\n"); + break; + + case GHOSTTY_SGR_ATTR_UNSET: + printf("Reset all attributes\n"); + break; + + case GHOSTTY_SGR_ATTR_UNKNOWN: + printf("Unknown attribute\n"); + break; + + default: + printf("Other attribute (tag=%d)\n", attr.tag); + break; + } + } + + printf("\nTotal attributes parsed: %d\n", count); + + // Cleanup + ghostty_sgr_free(parser); + return 0; +} diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index e6d922009..4f8fef88e 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -30,6 +30,7 @@ * The API is organized into the following groups: * - @ref key "Key Encoding" - Encode key events into terminal sequences * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences + * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences * - @ref paste "Paste Utilities" - Validate paste data safety * - @ref allocator "Memory Management" - Memory management and custom allocators * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions @@ -40,6 +41,7 @@ * - @ref c-vt/src/main.c - OSC parser example * - @ref c-vt-key-encode/src/main.c - Key encoding example * - @ref c-vt-paste/src/main.c - Paste safety check example + * - @ref c-vt-sgr/src/main.c - SGR parser example * */ @@ -58,6 +60,11 @@ * paste data is safe before sending it to the terminal. */ +/** @example c-vt-sgr/src/main.c + * This example demonstrates how to use the SGR parser to parse terminal + * styling sequences and extract text attributes like colors and underline styles. + */ + #ifndef GHOSTTY_VT_H #define GHOSTTY_VT_H @@ -68,6 +75,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/color.h b/include/ghostty/vt/color.h new file mode 100644 index 000000000..9e7fe6f4d --- /dev/null +++ b/include/ghostty/vt/color.h @@ -0,0 +1,77 @@ +/** + * @file color.h + * + * Color types and utilities. + */ + +#ifndef GHOSTTY_VT_COLOR_H +#define GHOSTTY_VT_COLOR_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * RGB color value. + * + * @ingroup sgr + */ +typedef struct { + uint8_t r; /**< Red component (0-255) */ + uint8_t g; /**< Green component (0-255) */ + uint8_t b; /**< Blue component (0-255) */ +} GhosttyColorRgb; + +/** + * Palette color index (0-255). + * + * @ingroup sgr + */ +typedef uint8_t GhosttyColorPaletteIndex; + +/** @addtogroup sgr + * @{ + */ + +/** Black color (0) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BLACK 0 +/** Red color (1) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_RED 1 +/** Green color (2) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_GREEN 2 +/** Yellow color (3) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_YELLOW 3 +/** Blue color (4) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BLUE 4 +/** Magenta color (5) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_MAGENTA 5 +/** Cyan color (6) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_CYAN 6 +/** White color (7) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_WHITE 7 +/** Bright black color (8) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 +/** Bright red color (9) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 +/** Bright green color (10) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 +/** Bright yellow color (11) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 +/** Bright blue color (12) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 +/** Bright magenta color (13) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 +/** Bright cyan color (14) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 +/** Bright white color (15) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_COLOR_H */ diff --git a/include/ghostty/vt/result.h b/include/ghostty/vt/result.h index cc382eade..65938ee76 100644 --- a/include/ghostty/vt/result.h +++ b/include/ghostty/vt/result.h @@ -15,6 +15,8 @@ typedef enum { GHOSTTY_SUCCESS = 0, /** Operation failed due to failed allocation */ GHOSTTY_OUT_OF_MEMORY = -1, + /** Operation failed due to invalid value */ + GHOSTTY_INVALID_VALUE = -2, } GhosttyResult; #endif /* GHOSTTY_VT_RESULT_H */ diff --git a/include/ghostty/vt/sgr.h b/include/ghostty/vt/sgr.h new file mode 100644 index 000000000..a296a280a --- /dev/null +++ b/include/ghostty/vt/sgr.h @@ -0,0 +1,306 @@ +/** + * @file sgr.h + * + * SGR (Select Graphic Rendition) attribute parsing and handling. + */ + +#ifndef GHOSTTY_VT_SGR_H +#define GHOSTTY_VT_SGR_H + +/** @defgroup sgr SGR Parser + * + * SGR (Select Graphic Rendition) attribute parser. + * + * SGR sequences are the syntax used to set styling attributes such as + * bold, italic, underline, and colors for text in terminal emulators. + * For example, you may be familiar with sequences like `ESC[1;31m`. The + * `1;31` is the SGR attribute list. + * + * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) + * and returns individual text attributes like bold, italic, colors, etc. + * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, + * and handles various color formats including 8-color, 16-color, 256-color, + * X11 named colors, and RGB in multiple formats. + * + * ## Basic Usage + * + * 1. Create a parser instance with ghostty_sgr_new() + * 2. Set SGR parameters with ghostty_sgr_set_params() + * 3. Iterate through attributes using ghostty_sgr_next() + * 4. Free the parser with ghostty_sgr_free() when done + * + * ## Example + * + * @code{.c} + * #include + * #include + * #include + * + * int main() { + * // Create parser + * GhosttySgrParser parser; + * GhosttyResult result = ghostty_sgr_new(NULL, &parser); + * assert(result == GHOSTTY_SUCCESS); + * + * // Parse "bold, red foreground" sequence: ESC[1;31m + * uint16_t params[] = {1, 31}; + * result = ghostty_sgr_set_params(parser, params, NULL, 2); + * assert(result == GHOSTTY_SUCCESS); + * + * // Iterate through attributes + * GhosttySgrAttribute attr; + * while (ghostty_sgr_next(parser, &attr)) { + * switch (attr.tag) { + * case GHOSTTY_SGR_ATTR_BOLD: + * printf("Bold enabled\n"); + * break; + * case GHOSTTY_SGR_ATTR_FG_8: + * printf("Foreground color: %d\n", attr.value.fg_8); + * break; + * default: + * break; + * } + * } + * + * // Cleanup + * ghostty_sgr_free(parser); + * return 0; + * } + * @endcode + * + * @{ + */ + +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Opaque handle to an SGR parser instance. + * + * This handle represents an SGR (Select Graphic Rendition) parser that can + * be used to parse SGR sequences and extract individual text attributes. + * + * @ingroup sgr + */ +typedef struct GhosttySgrParser *GhosttySgrParser; + +/** + * SGR attribute tags. + * + * These values identify the type of an SGR attribute in a tagged union. + * Use the tag to determine which field in the attribute value union to access. + * + * @ingroup sgr + */ +typedef enum { + GHOSTTY_SGR_ATTR_UNSET = 0, + GHOSTTY_SGR_ATTR_UNKNOWN = 1, + GHOSTTY_SGR_ATTR_BOLD = 2, + GHOSTTY_SGR_ATTR_RESET_BOLD = 3, + GHOSTTY_SGR_ATTR_ITALIC = 4, + GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, + GHOSTTY_SGR_ATTR_FAINT = 6, + GHOSTTY_SGR_ATTR_UNDERLINE = 7, + GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8, + GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9, + GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10, + GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11, + GHOSTTY_SGR_ATTR_OVERLINE = 12, + GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13, + GHOSTTY_SGR_ATTR_BLINK = 14, + GHOSTTY_SGR_ATTR_RESET_BLINK = 15, + GHOSTTY_SGR_ATTR_INVERSE = 16, + GHOSTTY_SGR_ATTR_RESET_INVERSE = 17, + GHOSTTY_SGR_ATTR_INVISIBLE = 18, + GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19, + GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20, + GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21, + GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22, + GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23, + GHOSTTY_SGR_ATTR_BG_8 = 24, + GHOSTTY_SGR_ATTR_FG_8 = 25, + GHOSTTY_SGR_ATTR_RESET_FG = 26, + GHOSTTY_SGR_ATTR_RESET_BG = 27, + GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28, + GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29, + GHOSTTY_SGR_ATTR_BG_256 = 30, + GHOSTTY_SGR_ATTR_FG_256 = 31, +} GhosttySgrAttributeTag; + +/** + * Underline style types. + * + * @ingroup sgr + */ +typedef enum { + GHOSTTY_SGR_UNDERLINE_NONE = 0, + GHOSTTY_SGR_UNDERLINE_SINGLE = 1, + GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, + GHOSTTY_SGR_UNDERLINE_CURLY = 3, + GHOSTTY_SGR_UNDERLINE_DOTTED = 4, + GHOSTTY_SGR_UNDERLINE_DASHED = 5, +} GhosttySgrUnderline; + +/** + * Unknown SGR attribute data. + * + * Contains the full parameter list and the partial list where parsing + * encountered an unknown or invalid sequence. + * + * @ingroup sgr + */ +typedef struct { + const uint16_t *full_ptr; + size_t full_len; + const uint16_t *partial_ptr; + size_t partial_len; +} GhosttySgrUnknown; + +/** + * SGR attribute value union. + * + * This union contains all possible attribute values. Use the tag field + * to determine which union member is active. Attributes without associated + * data (like bold, italic) don't use the union value. + * + * @ingroup sgr + */ +typedef union { + GhosttySgrUnknown unknown; + GhosttySgrUnderline underline; + GhosttyColorRgb underline_color; + GhosttyColorPaletteIndex underline_color_256; + GhosttyColorRgb direct_color_fg; + GhosttyColorRgb direct_color_bg; + GhosttyColorPaletteIndex bg_8; + GhosttyColorPaletteIndex fg_8; + GhosttyColorPaletteIndex bright_bg_8; + GhosttyColorPaletteIndex bright_fg_8; + GhosttyColorPaletteIndex bg_256; + GhosttyColorPaletteIndex fg_256; + uint64_t _padding[8]; +} GhosttySgrAttributeValue; + +/** + * SGR attribute (tagged union). + * + * A complete SGR attribute with both its type tag and associated value. + * Always check the tag field to determine which value union member is valid. + * + * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be + * identified by tag alone; the value union is not used for these and + * the memory in the value field is undefined. + * + * @ingroup sgr + */ +typedef struct { + GhosttySgrAttributeTag tag; + GhosttySgrAttributeValue value; +} GhosttySgrAttribute; + +/** + * Create a new SGR parser instance. + * + * Creates a new SGR (Select Graphic Rendition) parser using the provided + * allocator. The parser must be freed using ghostty_sgr_free() when + * no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param parser Pointer to store the created parser handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup sgr + */ +GhosttyResult ghostty_sgr_new(const GhosttyAllocator *allocator, GhosttySgrParser *parser); + +/** + * Free an SGR parser instance. + * + * Releases all resources associated with the SGR parser. After this call, + * the parser handle becomes invalid and must not be used. This includes + * any attributes previously returned by ghostty_sgr_next(). + * + * @param parser The parser handle to free (may be NULL) + * + * @ingroup sgr + */ +void ghostty_sgr_free(GhosttySgrParser parser); + +/** + * Reset an SGR parser instance to the beginning of the parameter list. + * + * Resets the parser's iteration state without clearing the parameters. + * After calling this, ghostty_sgr_next() will start from the beginning + * of the parameter list again. + * + * @param parser The parser handle to reset, must not be NULL + * + * @ingroup sgr + */ +void ghostty_sgr_reset(GhosttySgrParser parser); + +/** + * Set SGR parameters for parsing. + * + * Sets the SGR parameter list to parse. Parameters are the numeric values + * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). + * + * The separators array optionally specifies the separator type for each + * parameter position. Each byte should be either ';' for semicolon or ':' + * for colon. This is needed for certain color formats that use colon + * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator + * values are treated as semicolons. The separators array must have the same + * length as the params array, if it is not NULL. + * + * If separators is NULL, all parameters are assumed to be semicolon-separated. + * + * This function makes an internal copy of the parameter and separator data, + * so the caller can safely free or modify the input arrays after this call. + * + * After calling this function, the parser is automatically reset and ready + * to iterate from the beginning. + * + * @param parser The parser handle, must not be NULL + * @param params Array of SGR parameter values + * @param separators Optional array of separator characters (';' or ':'), or NULL + * @param len Number of parameters (and separators if provided) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup sgr + */ +GhosttyResult ghostty_sgr_set_params( + GhosttySgrParser parser, + const uint16_t *params, + const char *separators, + size_t len); + +/** + * Get the next SGR attribute. + * + * Parses and returns the next attribute from the parameter list. + * Call this function repeatedly until it returns false to process + * all attributes in the sequence. + * + * @param parser The parser handle, must not be NULL + * @param attr Pointer to store the next attribute + * @return true if an attribute was returned, false if no more attributes + * + * @ingroup sgr + */ +bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute *attr); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SGR_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index c66e5ab39..e1aa69659 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -98,13 +98,6 @@ comptime { // we want to reference the C API so that it gets exported. if (@import("root") == lib) { const c = terminal.c_api; - @export(&c.osc_new, .{ .name = "ghostty_osc_new" }); - @export(&c.osc_free, .{ .name = "ghostty_osc_free" }); - @export(&c.osc_next, .{ .name = "ghostty_osc_next" }); - @export(&c.osc_reset, .{ .name = "ghostty_osc_reset" }); - @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); - @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); - @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); @export(&c.key_event_new, .{ .name = "ghostty_key_event_new" }); @export(&c.key_event_free, .{ .name = "ghostty_key_event_free" }); @export(&c.key_event_set_action, .{ .name = "ghostty_key_event_set_action" }); @@ -125,7 +118,19 @@ comptime { @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); + @export(&c.osc_new, .{ .name = "ghostty_osc_new" }); + @export(&c.osc_free, .{ .name = "ghostty_osc_free" }); + @export(&c.osc_next, .{ .name = "ghostty_osc_next" }); + @export(&c.osc_reset, .{ .name = "ghostty_osc_reset" }); + @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); + @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); + @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); + @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); + @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); + @export(&c.sgr_reset, .{ .name = "ghostty_sgr_reset" }); + @export(&c.sgr_set_params, .{ .name = "ghostty_sgr_set_params" }); + @export(&c.sgr_next, .{ .name = "ghostty_sgr_next" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index f68333d9b..b935b264d 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -2,6 +2,7 @@ pub const osc = @import("osc.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); pub const paste = @import("paste.zig"); +pub const sgr = @import("sgr.zig"); // The full C API, unexported. pub const osc_new = osc.new; @@ -12,6 +13,12 @@ pub const osc_end = osc.end; pub const osc_command_type = osc.commandType; pub const osc_command_data = osc.commandData; +pub const sgr_new = sgr.new; +pub const sgr_free = sgr.free; +pub const sgr_reset = sgr.reset; +pub const sgr_set_params = sgr.setParams; +pub const sgr_next = sgr.next; + pub const key_event_new = key_event.new; pub const key_event_free = key_event.free; pub const key_event_set_action = key_event.set_action; @@ -41,6 +48,7 @@ test { _ = key_event; _ = key_encode; _ = paste; + _ = sgr; // We want to make sure we run the tests for the C allocator interface. _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/result.zig b/src/terminal/c/result.zig index a2ebc9b69..e9b5fc5e6 100644 --- a/src/terminal/c/result.zig +++ b/src/terminal/c/result.zig @@ -2,4 +2,5 @@ pub const Result = enum(c_int) { success = 0, out_of_memory = -1, + invalid_value = -2, }; diff --git a/src/terminal/c/sgr.zig b/src/terminal/c/sgr.zig new file mode 100644 index 000000000..f01a8a25b --- /dev/null +++ b/src/terminal/c/sgr.zig @@ -0,0 +1,142 @@ +const std = @import("std"); +const assert = std.debug.assert; +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"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.sgr); + +/// Wrapper around parser that tracks the allocator for C API usage. +const ParserWrapper = struct { + parser: sgr.Parser, + alloc: Allocator, +}; + +/// C: GhosttySgrParser +pub const Parser = ?*ParserWrapper; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Parser, +) callconv(.c) Result { + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(ParserWrapper) catch + return .out_of_memory; + ptr.* = .{ + .parser = .empty, + .alloc = alloc, + }; + result.* = ptr; + return .success; +} + +pub fn free(parser_: Parser) callconv(.c) void { + const wrapper = parser_ orelse return; + const alloc = wrapper.alloc; + const parser: *sgr.Parser = &wrapper.parser; + if (parser.params.len > 0) alloc.free(parser.params); + alloc.destroy(wrapper); +} + +pub fn reset(parser_: Parser) callconv(.c) void { + const wrapper = parser_ orelse return; + const parser: *sgr.Parser = &wrapper.parser; + parser.idx = 0; +} + +pub fn setParams( + parser_: Parser, + params: [*]const u16, + seps_: ?[*]const u8, + len: usize, +) callconv(.c) Result { + const wrapper = parser_ orelse return .invalid_value; + const alloc = wrapper.alloc; + const parser: *sgr.Parser = &wrapper.parser; + + // Copy our new parameters + const params_slice = alloc.dupe(u16, params[0..len]) catch + return .out_of_memory; + if (parser.params.len > 0) alloc.free(parser.params); + parser.params = params_slice; + + // If we have separators, set that state too. + parser.params_sep = .initEmpty(); + if (seps_) |seps| { + if (len > @TypeOf(parser.params_sep).bit_length) { + log.warn("ghostty_sgr_set_params: separators length {} exceeds max supported length {}", .{ + len, + @TypeOf(parser.params_sep).bit_length, + }); + return .invalid_value; + } + + for (seps[0..len], 0..) |sep, i| { + if (sep == ':') parser.params_sep.set(i); + } + } + + // Reset our parsing state + parser.idx = 0; + + return .success; +} + +pub fn next( + parser_: Parser, + result: *sgr.Attribute.C, +) callconv(.c) bool { + const wrapper = parser_ orelse return false; + const parser: *sgr.Parser = &wrapper.parser; + if (parser.next()) |attr| { + result.* = attr.cval(); + return true; + } + + return false; +} + +test "alloc" { + var p: Parser = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &p, + )); + free(p); +} + +test "simple params, no seps" { + var p: Parser = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &p, + )); + defer free(p); + + try testing.expectEqual(Result.success, setParams( + p, + &.{1}, + null, + 1, + )); + + // Set it twice on purpose to make sure we don't leak. + try testing.expectEqual(Result.success, setParams( + p, + &.{1}, + null, + 1, + )); + + // Verify we get bold + var attr: sgr.Attribute.C = undefined; + try testing.expect(next(p, &attr)); + try testing.expectEqual(.bold, attr.tag); + + // Nothing else + try testing.expect(!next(p, &attr)); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 0d6a053c8..c2d6e8cb4 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -5,7 +5,6 @@ const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const hyperlink = @import("hyperlink.zig"); -const sgr = @import("sgr.zig"); const stream_readonly = @import("stream_readonly.zig"); const style = @import("style.zig"); pub const apc = @import("apc.zig"); @@ -19,6 +18,7 @@ pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); pub const parse_table = @import("parse_table.zig"); pub const search = @import("search.zig"); +pub const sgr = @import("sgr.zig"); pub const size = @import("size.zig"); pub const tmux = if (options.tmux_control_mode) @import("tmux.zig") else struct {}; pub const x11_color = @import("x11_color.zig"); diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index a345a7a90..b9765ca6a 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -151,7 +151,7 @@ pub const Attribute = union(Tag) { dotted = 4, dashed = 5, - pub const C = u8; + pub const C = c_int; pub fn cval(self: Underline) Underline.C { return @intFromEnum(self); @@ -176,10 +176,13 @@ pub const Attribute = union(Tag) { /// Parser parses the attributes from a list of SGR parameters. pub const Parser = struct { - params: []const u16, + params: []const u16 = &.{}, params_sep: SepList = .initEmpty(), idx: usize = 0, + /// Empty state parser. + pub const empty: Parser = .{}; + /// 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) { From 2f5c09c1bf7d0a27f605aab6370a762fc8045a9e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 26 Oct 2025 06:52:32 -0700 Subject: [PATCH 206/702] ci: update milestone action (#9354) Fixes our failing CI, hopefully! --- .github/workflows/milestone.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index 25d8edaa0..e85c304b2 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -15,7 +15,7 @@ jobs: name: Milestone Update steps: - name: Set Milestone for PR - uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v2.11 + uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v3.0 if: github.event.pull_request.merged == true with: action: bind-pr # `bind-pr` is the default action @@ -24,7 +24,7 @@ jobs: # Bind milestone to closed issue that has a merged PR fix - name: Set Milestone for Issue - uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v2.11 + uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v3.0 if: github.event.issue.state == 'closed' with: action: bind-issue From 19d13776592466eb4291132fec2d1ab1a1a028d0 Mon Sep 17 00:00:00 2001 From: Justin Ma Date: Sun, 26 Oct 2025 23:26:18 +0800 Subject: [PATCH 207/702] ci: Upgrade hustcer/milestone-action to v3 (#9357) Upgrade `hustcer/milestone-action` to v3, try to fix set milestone error --- .github/workflows/milestone.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index e85c304b2..74f2dd7ce 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -15,7 +15,7 @@ jobs: name: Milestone Update steps: - name: Set Milestone for PR - uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v3.0 + uses: hustcer/milestone-action@dcd6c3742acc1846929c054251c64cccd555a00d # v3.0 if: github.event.pull_request.merged == true with: action: bind-pr # `bind-pr` is the default action @@ -24,7 +24,7 @@ jobs: # Bind milestone to closed issue that has a merged PR fix - name: Set Milestone for Issue - uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v3.0 + uses: hustcer/milestone-action@dcd6c3742acc1846929c054251c64cccd555a00d # v3.0 if: github.event.issue.state == 'closed' with: action: bind-issue From 7d7c0bf5cdb591238225d33c0b5088b94a8a65bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 26 Oct 2025 13:19:55 -0700 Subject: [PATCH 208/702] lib-vt: Wasm SGR helpers and example (#9362) This adds some convenience functions for parsing SGR sequences WebAssembly and adds an example demonstrating SGR parsing in the browser. --- example/wasm-key-encode/index.html | 4 +- example/wasm-sgr/README.md | 39 +++ example/wasm-sgr/index.html | 457 +++++++++++++++++++++++++++++ include/ghostty/vt/color.h | 19 ++ include/ghostty/vt/sgr.h | 176 ++++++++--- include/ghostty/vt/wasm.h | 36 ++- src/lib/allocator/convenience.zig | 13 +- src/lib_vt.zig | 13 +- src/terminal/c/color.zig | 12 + src/terminal/c/main.zig | 10 + src/terminal/c/sgr.zig | 39 +++ 11 files changed, 759 insertions(+), 59 deletions(-) create mode 100644 example/wasm-sgr/README.md create mode 100644 example/wasm-sgr/index.html create mode 100644 src/terminal/c/color.zig diff --git a/example/wasm-key-encode/index.html b/example/wasm-key-encode/index.html index 714988b4d..9f4d8bebb 100644 --- a/example/wasm-key-encode/index.html +++ b/example/wasm-key-encode/index.html @@ -458,7 +458,7 @@ // Set UTF-8 text from the key event (the actual character produced) if (event.key.length === 1) { const utf8Bytes = new TextEncoder().encode(event.key); - const utf8Ptr = wasmInstance.exports.ghostty_wasm_alloc_buffer(utf8Bytes.length); + const utf8Ptr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(utf8Bytes.length); new Uint8Array(getBuffer()).set(utf8Bytes, utf8Ptr); wasmInstance.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Bytes.length); } @@ -477,7 +477,7 @@ const required = new DataView(getBuffer()).getUint32(requiredPtr, true); - const bufPtr = wasmInstance.exports.ghostty_wasm_alloc_buffer(required); + const bufPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(required); const writtenPtr = wasmInstance.exports.ghostty_wasm_alloc_usize(); const encodeResult = wasmInstance.exports.ghostty_key_encoder_encode( encoderPtr, eventPtr, bufPtr, required, writtenPtr diff --git a/example/wasm-sgr/README.md b/example/wasm-sgr/README.md new file mode 100644 index 000000000..a107c910d --- /dev/null +++ b/example/wasm-sgr/README.md @@ -0,0 +1,39 @@ +# WebAssembly SGR Parser Example + +This example demonstrates how to use the Ghostty VT library from WebAssembly +to parse terminal SGR (Select Graphic Rendition) sequences and extract text +styling attributes. + +## Building + +First, build the WebAssembly module: + +```bash +zig build lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +``` + +This will create `zig-out/bin/ghostty-vt.wasm`. + +## Running + +**Important:** You must serve this via HTTP, not open it as a file directly. +Browsers block loading WASM files from `file://` URLs. + +From the **root of the ghostty repository**, serve with a local HTTP server: + +```bash +# Using Python (recommended) +python3 -m http.server 8000 + +# Or using Node.js +npx serve . + +# Or using PHP +php -S localhost:8000 +``` + +Then open your browser to: + +``` +http://localhost:8000/example/wasm-sgr/ +``` diff --git a/example/wasm-sgr/index.html b/example/wasm-sgr/index.html new file mode 100644 index 000000000..e62b26c7e --- /dev/null +++ b/example/wasm-sgr/index.html @@ -0,0 +1,457 @@ + + + + + + Ghostty VT SGR Parser - WebAssembly Example + + + +

Ghostty VT SGR Parser - WebAssembly Example

+

This example demonstrates parsing terminal SGR (Select Graphic Rendition) sequences using the Ghostty VT WebAssembly module.

+ +
Loading WebAssembly module...
+ +
+

SGR Sequence

+ + +

The parser runs live as you type.

+
+ +
Waiting for input...
+ +

Note: This example must be served via HTTP (not opened directly as a file). See the README for instructions.

+ + + + diff --git a/include/ghostty/vt/color.h b/include/ghostty/vt/color.h index 9e7fe6f4d..0d57b8db4 100644 --- a/include/ghostty/vt/color.h +++ b/include/ghostty/vt/color.h @@ -70,6 +70,25 @@ typedef uint8_t GhosttyColorPaletteIndex; /** @} */ +/** + * Get the RGB color components. + * + * This function extracts the individual red, green, and blue components + * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments + * where accessing struct fields directly is difficult. + * + * @param color The RGB color value + * @param r Pointer to store the red component (0-255) + * @param g Pointer to store the green component (0-255) + * @param b Pointer to store the blue component (0-255) + * + * @ingroup sgr + */ +void ghostty_color_rgb_get(GhosttyColorRgb color, + uint8_t* r, + uint8_t* g, + uint8_t* b); + #ifdef __cplusplus } #endif diff --git a/include/ghostty/vt/sgr.h b/include/ghostty/vt/sgr.h index a296a280a..0c1afc309 100644 --- a/include/ghostty/vt/sgr.h +++ b/include/ghostty/vt/sgr.h @@ -18,9 +18,9 @@ * * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) * and returns individual text attributes like bold, italic, colors, etc. - * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, - * and handles various color formats including 8-color, 16-color, 256-color, - * X11 named colors, and RGB in multiple formats. + * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, + * and handles various color formats including 8-color, 16-color, 256-color, + * X11 named colors, and RGB in multiple formats. * * ## Basic Usage * @@ -35,18 +35,18 @@ * #include * #include * #include - * + * * int main() { * // Create parser * GhosttySgrParser parser; * GhosttyResult result = ghostty_sgr_new(NULL, &parser); * assert(result == GHOSTTY_SUCCESS); - * + * * // Parse "bold, red foreground" sequence: ESC[1;31m * uint16_t params[] = {1, 31}; * result = ghostty_sgr_set_params(parser, params, NULL, 2); * assert(result == GHOSTTY_SUCCESS); - * + * * // Iterate through attributes * GhosttySgrAttribute attr; * while (ghostty_sgr_next(parser, &attr)) { @@ -61,7 +61,7 @@ * break; * } * } - * + * * // Cleanup * ghostty_sgr_free(parser); * return 0; @@ -71,12 +71,12 @@ * @{ */ +#include +#include +#include #include #include #include -#include -#include -#include #ifdef __cplusplus extern "C" { @@ -84,13 +84,13 @@ extern "C" { /** * Opaque handle to an SGR parser instance. - * + * * This handle represents an SGR (Select Graphic Rendition) parser that can * be used to parse SGR sequences and extract individual text attributes. * * @ingroup sgr */ -typedef struct GhosttySgrParser *GhosttySgrParser; +typedef struct GhosttySgrParser* GhosttySgrParser; /** * SGR attribute tags. @@ -158,9 +158,9 @@ typedef enum { * @ingroup sgr */ typedef struct { - const uint16_t *full_ptr; + const uint16_t* full_ptr; size_t full_len; - const uint16_t *partial_ptr; + const uint16_t* partial_ptr; size_t partial_len; } GhosttySgrUnknown; @@ -196,7 +196,7 @@ typedef union { * Always check the tag field to determine which value union member is valid. * * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be - * identified by tag alone; the value union is not used for these and + * identified by tag alone; the value union is not used for these and * the memory in the value field is undefined. * * @ingroup sgr @@ -208,94 +208,182 @@ typedef struct { /** * Create a new SGR parser instance. - * + * * Creates a new SGR (Select Graphic Rendition) parser using the provided * allocator. The parser must be freed using ghostty_sgr_free() when * no longer needed. - * - * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * + * @param allocator Pointer to the allocator to use for memory management, or + * NULL to use the default allocator * @param parser Pointer to store the created parser handle * @return GHOSTTY_SUCCESS on success, or an error code on failure - * + * * @ingroup sgr */ -GhosttyResult ghostty_sgr_new(const GhosttyAllocator *allocator, GhosttySgrParser *parser); +GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, + GhosttySgrParser* parser); /** * Free an SGR parser instance. - * + * * Releases all resources associated with the SGR parser. After this call, * the parser handle becomes invalid and must not be used. This includes * any attributes previously returned by ghostty_sgr_next(). - * + * * @param parser The parser handle to free (may be NULL) - * + * * @ingroup sgr */ void ghostty_sgr_free(GhosttySgrParser parser); /** * Reset an SGR parser instance to the beginning of the parameter list. - * + * * Resets the parser's iteration state without clearing the parameters. * After calling this, ghostty_sgr_next() will start from the beginning * of the parameter list again. - * + * * @param parser The parser handle to reset, must not be NULL - * + * * @ingroup sgr */ void ghostty_sgr_reset(GhosttySgrParser parser); /** * Set SGR parameters for parsing. - * + * * Sets the SGR parameter list to parse. Parameters are the numeric values * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). - * + * * The separators array optionally specifies the separator type for each * parameter position. Each byte should be either ';' for semicolon or ':' * for colon. This is needed for certain color formats that use colon * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator - * values are treated as semicolons. The separators array must have the same + * values are treated as semicolons. The separators array must have the same * length as the params array, if it is not NULL. - * + * * If separators is NULL, all parameters are assumed to be semicolon-separated. - * + * * This function makes an internal copy of the parameter and separator data, * so the caller can safely free or modify the input arrays after this call. - * + * * After calling this function, the parser is automatically reset and ready * to iterate from the beginning. - * + * * @param parser The parser handle, must not be NULL * @param params Array of SGR parameter values - * @param separators Optional array of separator characters (';' or ':'), or NULL + * @param separators Optional array of separator characters (';' or ':'), or + * NULL * @param len Number of parameters (and separators if provided) * @return GHOSTTY_SUCCESS on success, or an error code on failure - * + * * @ingroup sgr */ -GhosttyResult ghostty_sgr_set_params( - GhosttySgrParser parser, - const uint16_t *params, - const char *separators, - size_t len); +GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, + const uint16_t* params, + const char* separators, + size_t len); /** * Get the next SGR attribute. - * + * * Parses and returns the next attribute from the parameter list. * Call this function repeatedly until it returns false to process * all attributes in the sequence. - * + * * @param parser The parser handle, must not be NULL * @param attr Pointer to store the next attribute * @return true if an attribute was returned, false if no more attributes - * + * * @ingroup sgr */ -bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute *attr); +bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); + +/** + * Get the full parameter list from an unknown SGR attribute. + * + * This function retrieves the full parameter list that was provided to the + * parser when an unknown attribute was encountered. Primarily useful in + * WebAssembly environments where accessing struct fields directly is difficult. + * + * @param unknown The unknown attribute data + * @param ptr Pointer to store the pointer to the parameter array (may be NULL) + * @return The length of the full parameter array + * + * @ingroup sgr + */ +size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, + const uint16_t** ptr); + +/** + * Get the partial parameter list from an unknown SGR attribute. + * + * This function retrieves the partial parameter list where parsing stopped + * when an unknown attribute was encountered. Primarily useful in WebAssembly + * environments where accessing struct fields directly is difficult. + * + * @param unknown The unknown attribute data + * @param ptr Pointer to store the pointer to the parameter array (may be NULL) + * @return The length of the partial parameter array + * + * @ingroup sgr + */ +size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, + const uint16_t** ptr); + +/** + * Get the tag from an SGR attribute. + * + * This function extracts the tag that identifies which type of attribute + * this is. Primarily useful in WebAssembly environments where accessing + * struct fields directly is difficult. + * + * @param attr The SGR attribute + * @return The attribute tag + * + * @ingroup sgr + */ +GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); + +/** + * Get the value from an SGR attribute. + * + * This function returns a pointer to the value union from an SGR attribute. Use + * the tag to determine which field of the union is valid. Primarily useful in + * WebAssembly environments where accessing struct fields directly is difficult. + * + * @param attr Pointer to the SGR attribute + * @return Pointer to the attribute value union + * + * @ingroup sgr + */ +GhosttySgrAttributeValue* ghostty_sgr_attribute_value( + GhosttySgrAttribute* attr); + +#ifdef __wasm__ +/** + * Allocate memory for an SGR attribute (WebAssembly only). + * + * This is a convenience function for WebAssembly environments to allocate + * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. + * + * @return Pointer to the allocated attribute structure + * + * @ingroup wasm + */ +GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); + +/** + * Free memory for an SGR attribute (WebAssembly only). + * + * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. + * + * @param attr Pointer to the attribute structure to free + * + * @ingroup wasm + */ +void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); +#endif #ifdef __cplusplus } diff --git a/include/ghostty/vt/wasm.h b/include/ghostty/vt/wasm.h index 7edee529f..37a826326 100644 --- a/include/ghostty/vt/wasm.h +++ b/include/ghostty/vt/wasm.h @@ -47,7 +47,7 @@ * * // Allocate output buffer and size pointer * const bufferSize = 32; - * const bufPtr = exports.ghostty_wasm_alloc_buffer(bufferSize); + * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); * const writtenPtr = exports.ghostty_wasm_alloc_usize(); * * // Encode the key event @@ -85,22 +85,40 @@ void** ghostty_wasm_alloc_opaque(void); void ghostty_wasm_free_opaque(void **ptr); /** - * Allocate a buffer of the specified length. + * Allocate an array of uint8_t values. * - * @param len Number of bytes to allocate - * @return Pointer to allocated buffer, or NULL if allocation failed + * @param len Number of uint8_t elements to allocate + * @return Pointer to allocated array, or NULL if allocation failed * @ingroup wasm */ -uint8_t* ghostty_wasm_alloc_buffer(size_t len); +uint8_t* ghostty_wasm_alloc_u8_array(size_t len); /** - * Free a buffer allocated by ghostty_wasm_alloc_buffer(). + * Free an array allocated by ghostty_wasm_alloc_u8_array(). * - * @param ptr Pointer to the buffer to free, or NULL (NULL is safely ignored) - * @param len Length of the buffer (must match the length passed to alloc) + * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) + * @param len Length of the array (must match the length passed to alloc) * @ingroup wasm */ -void ghostty_wasm_free_buffer(uint8_t *ptr, size_t len); +void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); + +/** + * Allocate an array of uint16_t values. + * + * @param len Number of uint16_t elements to allocate + * @return Pointer to allocated array, or NULL if allocation failed + * @ingroup wasm + */ +uint16_t* ghostty_wasm_alloc_u16_array(size_t len); + +/** + * Free an array allocated by ghostty_wasm_alloc_u16_array(). + * + * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) + * @param len Length of the array (must match the length passed to alloc) + * @ingroup wasm + */ +void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); /** * Allocate a single uint8_t value. diff --git a/src/lib/allocator/convenience.zig b/src/lib/allocator/convenience.zig index 19543ad0e..0f5f88d29 100644 --- a/src/lib/allocator/convenience.zig +++ b/src/lib/allocator/convenience.zig @@ -24,12 +24,21 @@ pub fn freeOpaque(ptr: ?*Opaque) callconv(.c) void { if (ptr) |p| alloc.destroy(p); } -pub fn allocBuffer(len: usize) callconv(.c) ?[*]u8 { +pub fn allocU8Array(len: usize) callconv(.c) ?[*]u8 { const slice = alloc.alloc(u8, len) catch return null; return slice.ptr; } -pub fn freeBuffer(ptr: ?[*]u8, len: usize) callconv(.c) void { +pub fn freeU8Array(ptr: ?[*]u8, len: usize) callconv(.c) void { + if (ptr) |p| alloc.free(p[0..len]); +} + +pub fn allocU16Array(len: usize) callconv(.c) ?[*]u16 { + const slice = alloc.alloc(u16, len) catch return null; + return slice.ptr; +} + +pub fn freeU16Array(ptr: ?[*]u16, len: usize) callconv(.c) void { if (ptr) |p| alloc.free(p[0..len]); } diff --git a/src/lib_vt.zig b/src/lib_vt.zig index e1aa69659..aa37c6110 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -126,23 +126,32 @@ comptime { @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); + @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); @export(&c.sgr_reset, .{ .name = "ghostty_sgr_reset" }); @export(&c.sgr_set_params, .{ .name = "ghostty_sgr_set_params" }); @export(&c.sgr_next, .{ .name = "ghostty_sgr_next" }); + @export(&c.sgr_unknown_full, .{ .name = "ghostty_sgr_unknown_full" }); + @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); + @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); + @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { const alloc = @import("lib/allocator/convenience.zig"); @export(&alloc.allocOpaque, .{ .name = "ghostty_wasm_alloc_opaque" }); @export(&alloc.freeOpaque, .{ .name = "ghostty_wasm_free_opaque" }); - @export(&alloc.allocBuffer, .{ .name = "ghostty_wasm_alloc_buffer" }); - @export(&alloc.freeBuffer, .{ .name = "ghostty_wasm_free_buffer" }); + @export(&alloc.allocU8Array, .{ .name = "ghostty_wasm_alloc_u8_array" }); + @export(&alloc.freeU8Array, .{ .name = "ghostty_wasm_free_u8_array" }); + @export(&alloc.allocU16Array, .{ .name = "ghostty_wasm_alloc_u16_array" }); + @export(&alloc.freeU16Array, .{ .name = "ghostty_wasm_free_u16_array" }); @export(&alloc.allocU8, .{ .name = "ghostty_wasm_alloc_u8" }); @export(&alloc.freeU8, .{ .name = "ghostty_wasm_free_u8" }); @export(&alloc.allocUsize, .{ .name = "ghostty_wasm_alloc_usize" }); @export(&alloc.freeUsize, .{ .name = "ghostty_wasm_free_usize" }); + @export(&c.wasm_alloc_sgr_attribute, .{ .name = "ghostty_wasm_alloc_sgr_attribute" }); + @export(&c.wasm_free_sgr_attribute, .{ .name = "ghostty_wasm_free_sgr_attribute" }); } } } diff --git a/src/terminal/c/color.zig b/src/terminal/c/color.zig new file mode 100644 index 000000000..199339706 --- /dev/null +++ b/src/terminal/c/color.zig @@ -0,0 +1,12 @@ +const color = @import("../color.zig"); + +pub fn rgb_get( + c: color.RGB.C, + r: *u8, + g: *u8, + b: *u8, +) callconv(.c) void { + r.* = c.r; + g.* = c.g; + b.* = c.b; +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index b935b264d..bc92597f5 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -1,3 +1,4 @@ +pub const color = @import("color.zig"); pub const osc = @import("osc.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); @@ -13,11 +14,19 @@ pub const osc_end = osc.end; pub const osc_command_type = osc.commandType; pub const osc_command_data = osc.commandData; +pub const color_rgb_get = color.rgb_get; + pub const sgr_new = sgr.new; pub const sgr_free = sgr.free; pub const sgr_reset = sgr.reset; pub const sgr_set_params = sgr.setParams; pub const sgr_next = sgr.next; +pub const sgr_unknown_full = sgr.unknown_full; +pub const sgr_unknown_partial = sgr.unknown_partial; +pub const sgr_attribute_tag = sgr.attribute_tag; +pub const sgr_attribute_value = sgr.attribute_value; +pub const wasm_alloc_sgr_attribute = sgr.wasm_alloc_attribute; +pub const wasm_free_sgr_attribute = sgr.wasm_free_attribute; pub const key_event_new = key_event.new; pub const key_event_free = key_event.free; @@ -44,6 +53,7 @@ pub const key_encoder_encode = key_encode.encode; pub const paste_is_safe = paste.is_safe; test { + _ = color; _ = osc; _ = key_event; _ = key_encode; diff --git a/src/terminal/c/sgr.zig b/src/terminal/c/sgr.zig index f01a8a25b..e65b9e3ee 100644 --- a/src/terminal/c/sgr.zig +++ b/src/terminal/c/sgr.zig @@ -100,6 +100,45 @@ pub fn next( return false; } +pub fn unknown_full( + unknown: sgr.Attribute.Unknown.C, + ptr: ?*[*]const u16, +) callconv(.c) usize { + if (ptr) |p| p.* = unknown.full_ptr; + return unknown.full_len; +} + +pub fn unknown_partial( + unknown: sgr.Attribute.Unknown.C, + ptr: ?*[*]const u16, +) callconv(.c) usize { + if (ptr) |p| p.* = unknown.partial_ptr; + return unknown.partial_len; +} + +pub fn attribute_tag( + attr: sgr.Attribute.C, +) callconv(.c) sgr.Attribute.Tag { + return attr.tag; +} + +pub fn attribute_value( + attr: *sgr.Attribute.C, +) callconv(.c) *sgr.Attribute.CValue { + return &attr.value; +} + +pub fn wasm_alloc_attribute() callconv(.c) *sgr.Attribute.C { + const alloc = std.heap.wasm_allocator; + const ptr = alloc.create(sgr.Attribute.C) catch @panic("out of memory"); + return ptr; +} + +pub fn wasm_free_attribute(attr: *sgr.Attribute.C) callconv(.c) void { + const alloc = std.heap.wasm_allocator; + alloc.destroy(attr); +} + test "alloc" { var p: Parser = undefined; try testing.expectEqual(Result.success, new( From 929c0d1a9b3c00a4a1d0e261e15aa791dd3c2c3e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:24:51 -0700 Subject: [PATCH 209/702] build(deps): bump namespacelabs/nscloud-cache-action from 1.2.19 to 1.2.20 (#9366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.19 to 1.2.20.
Release notes

Sourced from namespacelabs/nscloud-cache-action's releases.

v1.2.20

What's Changed

Full Changelog: https://github.com/namespacelabs/nscloud-cache-action/compare/v1...v1.2.20

Commits
  • d93899d Merge pull request #43 from namespacelabs/kirill/cc
  • 6238c10 mode=xcode: Only cache CompilationCache directory.
  • 5e5a55d Merge pull request #42 from namespacelabs/kirill/xcode
  • 7183752 Add xcode, swiftpm and cocoapods to the list of documented cache modes.
  • 2ecee75 Merge pull request #41 from namespacelabs/kirill/xcode
  • 23f7256 xcode mode: Enable Xcode compilation cache.
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=namespacelabs/nscloud-cache-action&package-manager=github_actions&previous-version=1.2.19&new-version=1.2.20)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 2 +- .github/workflows/snap.yml | 2 +- .github/workflows/test.yml | 48 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index da669b073..f78e1c143 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index e5af4ac38..6dd56173f 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 2331dbba9..b90083407 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -161,7 +161,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index cfe5591f4..1228427cd 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -38,7 +38,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f506a62b2..49bb9f162 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,7 +72,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -107,7 +107,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -140,7 +140,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -174,7 +174,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -217,7 +217,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -253,7 +253,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -282,7 +282,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -315,7 +315,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -361,7 +361,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -580,7 +580,7 @@ jobs: echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -622,7 +622,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -670,7 +670,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -705,7 +705,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -769,7 +769,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -798,7 +798,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -826,7 +826,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -853,7 +853,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -880,7 +880,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -907,7 +907,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -934,7 +934,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -968,7 +968,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -995,7 +995,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -1030,7 +1030,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix @@ -1118,7 +1118,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index c14ee56a6..0d2d114c8 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@caff5c9dc51d8126e7d16141fb015c478374256b # v1.2.19 + uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 with: path: | /nix From 06966b42ada61f28f3b1096cdc490753b7653ff9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:25:13 -0700 Subject: [PATCH 210/702] build(deps): bump actions/download-artifact from 5.0.0 to 6.0.0 (#9365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5.0.0 to 6.0.0.
Release notes

Sourced from actions/download-artifact's releases.

v6.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

New Contributors

Full Changelog: https://github.com/actions/download-artifact/compare/v5...v6.0.0

Commits
  • 018cc2c Merge pull request #438 from actions/danwkennedy/prepare-6.0.0
  • 815651c Revert "Remove github.dep.yml"
  • bb3a066 Remove github.dep.yml
  • fa1ce46 Prepare v6.0.0
  • 4a24838 Merge pull request #431 from danwkennedy/patch-1
  • 5e3251c Readme: spell out the first use of GHES
  • abefc31 Merge pull request #424 from actions/yacaovsnc/update_readme
  • ac43a60 Update README with artifact extraction details
  • de96f46 Merge pull request #417 from actions/yacaovsnc/update_readme
  • 7993cb4 Remove migration guide for artifact download changes
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=5.0.0&new-version=6.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-tag.yml | 10 +++++----- .github/workflows/snap.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 6dd56173f..8a967be1e 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -286,7 +286,7 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash - name: Download macOS Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: macos @@ -309,7 +309,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Download macOS Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: macos @@ -357,17 +357,17 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} steps: - name: Download macOS Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: macos - name: Download Sparkle Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: sparkle - name: Download Source Tarball Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: source-tarball diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 1228427cd..90c19dae8 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -26,7 +26,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: run-id: ${{ inputs.source-run-id }} artifact-ids: ${{ inputs.source-artifact-id }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49bb9f162..29408f961 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1062,7 +1062,7 @@ jobs: uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20 - name: Download Source Tarball Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: source-tarball From db75502fec94de27e7491a39082bd69eac827490 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:25:20 -0700 Subject: [PATCH 211/702] build(deps): bump actions/upload-artifact from 4.6.2 to 5.0.0 (#9364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 5.0.0.
Release notes

Sourced from actions/upload-artifact's releases.

v5.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v5.0.0

Commits
  • 330a01c Merge pull request #734 from actions/danwkennedy/prepare-5.0.0
  • 03f2824 Update github.dep.yml
  • 905a1ec Prepare v5.0.0
  • 2d9f9cd Merge pull request #725 from patrikpolyak/patch-1
  • 9687587 Merge branch 'main' into patch-1
  • 2848b2c Merge pull request #727 from danwkennedy/patch-1
  • 9b51177 Spell out the first use of GHES
  • cd231ca Update GHES guidance to include reference to Node 20 version
  • de65e23 Merge pull request #712 from actions/nebuk89-patch-1
  • 8747d8c Update README.md
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=4.6.2&new-version=5.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-tag.yml | 6 +++--- .github/workflows/test.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 8a967be1e..4fa001a64 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -113,7 +113,7 @@ jobs: nix develop -c minisign -S -m "ghostty-source.tar.gz" -s minisign.key < minisign.password - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: source-tarball path: |- @@ -269,7 +269,7 @@ jobs: zip -9 -r --symlinks ../../../ghostty-macos-universal-dsym.zip Ghostty.app.dSYM/ - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: macos path: |- @@ -340,7 +340,7 @@ jobs: mv appcast_new.xml appcast.xml - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: sparkle path: |- diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29408f961..fae9fa4c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -384,7 +384,7 @@ jobs: - name: Upload artifact id: upload-artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: source-tarball path: |- From 86ec29237cb2db6564f3f19c213ee8ec033e640d Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 27 Oct 2025 12:06:55 -0400 Subject: [PATCH 212/702] cli: make +ssh-cache contains() a read-only op (#9369) contains() checks the cache for an existing entry. It's a read-only operation, so we can drop the write bit and fixupPermissions() call. This is also consistent with the list() operation. fixupPermissions() is unnecessary in this code path. It provided minimal additional security because all of our creation and update operations enforce 0o600 (owner-only) permissions, so anyone tampering with this file has already gotten around that. The contents of this (ssh host cache) file are also not sensitive enough to warrant any additional hardening on reads. --- src/cli/ssh-cache/DiskCache.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index a3c5b13de..8e23b30cf 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -181,13 +181,12 @@ pub fn contains( // Open our file const file = std.fs.openFileAbsolute( self.path, - .{ .mode = .read_write }, + .{}, ) catch |err| switch (err) { error.FileNotFound => return false, else => return err, }; defer file.close(); - try fixupPermissions(file); // Read existing entries var entries = try readEntries(alloc, file); From 88444d4bd768f92effe971784d6b50b44dab0670 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Tue, 21 Oct 2025 13:01:22 -0700 Subject: [PATCH 213/702] macOS: Adjust documentView padding on layout changes --- macos/Sources/Ghostty/SurfaceScrollView.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 08d249c4e..2a9e49d9a 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -174,10 +174,19 @@ class SurfaceScrollView: NSView { } } - // Keep document width synchronized with content width + // Keep document width synchronized with content width, and + // recalculate the height of the document view to account for the + // change in padding around the cell grid due to the resize. + var documentHeight = documentView.frame.height + let cellHeight = surfaceView.cellSize.height + if cellHeight > 0 { + let oldPadding = fmod(documentHeight, cellHeight) + let newPadding = fmod(contentSize.height, cellHeight) + documentHeight += newPadding - oldPadding + } documentView.setFrameSize(CGSize( width: contentSize.width, - height: documentView.frame.height + height: documentHeight )) // Inform the actual pty of our size change. This doesn't change the actual view @@ -261,8 +270,7 @@ class SurfaceScrollView: NSView { // The full document height must include the vertical padding around the cell // grid, otherwise the content view ends up misaligned with the surface. let documentGridHeight = CGFloat(scrollbar.total) * cellHeight - let gridHeight = CGFloat(scrollbar.len) * cellHeight - let padding = scrollView.contentSize.height - gridHeight + let padding = fmod(scrollView.contentSize.height, cellHeight) let documentHeight = documentGridHeight + padding // Our width should be the content width to account for visible scrollers. From 17f2dc59fa8bc03ed6686b67fc7034504894b427 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 28 Oct 2025 07:23:00 -0700 Subject: [PATCH 214/702] terminal: formatter that can emit VT sequences (#9374) This adds a new formatter that can be used with standard Zig `{f}` formatting that emits any portion of the terminal screen as VT sequences. In addition to simply styling, this can emit the entire terminal/screen state such as cursor positions, active style, terminal modes, etc. To do this, I've extracted all formatting to a dedicated `formatter` package within `terminal`. This handles all formatting types (currently plaintext and VT formatting, but can imagine things like HTML in the future). Presently, we have "formatting" split out across a variety of places in Terminal, Screen, PageList, and Page. I didn't remove this code yet but I intend to unify it all on formatter in the future. This also doesn't expose this functionality in any user-facing way yet. This PR just adds it to the ghostty-vt Zig module and unit tests it. Ghostty app changes will come later. **This also improves the readonly stream** to handle OSC color operations for _setting_ but it doesn't emit any responses of course, since its readonly. --- src/terminal/formatter.zig | 2743 ++++++++++++++++++++++++++++++ src/terminal/main.zig | 1 + src/terminal/page.zig | 39 +- src/terminal/stream.zig | 31 +- src/terminal/stream_readonly.zig | 100 +- src/terminal/style.zig | 378 ++++ 6 files changed, 3256 insertions(+), 36 deletions(-) create mode 100644 src/terminal/formatter.zig diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig new file mode 100644 index 000000000..9e6632dca --- /dev/null +++ b/src/terminal/formatter.zig @@ -0,0 +1,2743 @@ +const std = @import("std"); +const size = @import("size.zig"); +const charsets = @import("charsets.zig"); +const kitty = @import("kitty.zig"); +const modespkg = @import("modes.zig"); +const Screen = @import("Screen.zig"); +const Terminal = @import("Terminal.zig"); +const Cell = @import("page.zig").Cell; +const Page = @import("page.zig").Page; +const PageList = @import("PageList.zig"); +const Row = @import("page.zig").Row; +const Selection = @import("Selection.zig"); +const Style = @import("style.zig").Style; + +// TODO: +// - Rectangular selection + +/// Formats available. +pub const Format = enum { + /// Plain text + plain, + + /// Include VT sequences to preserve colors, styles, URLs, etc. + /// This is predominantly SGR sequences but may contain others as needed. + /// + /// Note that for reference colors, like palette indices, this will + /// vary based on the formatter and you should see the docs. For example, + /// PageFormatter with VT will emit SGR sequences with palette indices, + /// not the color itself. + vt, + + pub fn styled(self: Format) bool { + return switch (self) { + .plain => false, + .vt => true, + }; + } +}; + +/// Common encoding options regardless of what exact formatter is used. +pub const Options = struct { + /// The format to emit. + emit: Format, + + /// Whether to unwrap soft-wrapped lines. If false, this will emit the + /// screen contents as it is rendered on the page in the given size. + unwrap: bool = false, + + pub const plain: Options = .{ .emit = .plain }; + pub const vt: Options = .{ .emit = .vt }; +}; + +/// Terminal formatter formats the active terminal screen. +/// +/// This will always only emit data related to the currently active screen. +/// If you want to emit data for a specific screen (e.g. primary vs alt), then +/// switch to that screen in the terminal prior to using this. +/// +/// If you want to emit data for all screens (a less common operation), then +/// you must create a no-content TerminalFormatter followed by multiple +/// explicit ScreenFormatter calls. This isn't a common operation so this +/// little extra work should be acceptable. +/// +/// For styled formatting, this will emit the palette colors at the +/// beginning so that the output can be rendered properly according to +/// the current terminal state. +pub const TerminalFormatter = struct { + /// The terminal to format. + terminal: *const Terminal, + + /// The common options + opts: Options, + + /// The content to include. + content: ScreenFormatter.Content, + + /// Extra stuff to emit, such as terminal modes, palette, cursor, etc. + /// This information is ONLY emitted when the format is "vt". + extra: Extra, + + pub const Extra = packed struct { + /// Emit the palette using OSC 4 sequences. + palette: bool, + + /// Emit terminal modes that differ from their defaults using CSI h/l + /// sequences. Defaults are according to the Ghostty defaults which + /// are generally match most terminal defaults. This will include + /// things like current screen, bracketed mode, mouse event reporting, + /// etc. + modes: bool, + + /// Emit scrolling region state using DECSTBM and DECSLRM sequences. + scrolling_region: bool, + + /// Emit tabstop positions by clearing all tabs (CSI 3 g) and setting + /// each configured tabstop with HTS. + tabstops: bool, + + /// Emit the present working directory using OSC 7. + pwd: bool, + + /// Emit keyboard modes such as ModifyOtherKeys using CSI > 4 m + /// sequences. + keyboard: bool, + + /// The screen extras to emit. TerminalFormatter always only + /// emits data for the currently active screen. If you want to emit + /// data for all screens, you should manually construct a no-content + /// terminal formatter, followed by screen formatters. + screen: ScreenFormatter.Extra, + + /// Emit nothing. + pub const none: Extra = .{ + .palette = false, + .modes = false, + .scrolling_region = false, + .tabstops = false, + .pwd = false, + .keyboard = false, + .screen = .none, + }; + + /// Emit style-relevant information only such as palettes. + pub const styles: Extra = .{ + .palette = true, + .modes = false, + .scrolling_region = false, + .tabstops = false, + .pwd = false, + .keyboard = false, + .screen = .styles, + }; + + /// Emit everything. This reconstructs the terminal state as closely + /// as possible. + pub const all: Extra = .{ + .palette = true, + .modes = true, + .scrolling_region = true, + .tabstops = true, + .pwd = true, + .keyboard = true, + .screen = .all, + }; + }; + + pub fn init( + terminal: *const Terminal, + opts: Options, + ) TerminalFormatter { + return .{ + .terminal = terminal, + .opts = opts, + .content = .{ .selection = null }, + .extra = .styles, + }; + } + + pub fn format( + self: TerminalFormatter, + writer: *std.Io.Writer, + ) !void { + // Emit palette before screen content if using VT format. Technically + // we could do this after but this way if replay is slow for whatever + // reason the colors will be right right away. + if (self.opts.emit == .vt and self.extra.palette) { + for (self.terminal.color_palette.colors, 0..) |rgb, i| { + try writer.print( + "\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", + .{ i, rgb.r, rgb.g, rgb.b }, + ); + } + } + + // Emit terminal modes that differ from defaults. We probably have + // some modes we want to emit before and some after, but for now for + // simplicity we just emit them all before. If we make this more complex + // later we should add test cases for it. + if (self.opts.emit == .vt and self.extra.modes) { + inline for (@typeInfo(modespkg.Mode).@"enum".fields) |field| { + const mode: modespkg.Mode = @enumFromInt(field.value); + const current = self.terminal.modes.get(mode); + const default_val = @field(self.terminal.modes.default, field.name); + + if (current != default_val) { + const tag: modespkg.ModeTag = @bitCast(@intFromEnum(mode)); + const prefix = if (tag.ansi) "" else "?"; + const suffix = if (current) "h" else "l"; + try writer.print("\x1b[{s}{d}{s}", .{ prefix, tag.value, suffix }); + } + } + } + + var screen_formatter: ScreenFormatter = .init(&self.terminal.screen, self.opts); + screen_formatter.content = self.content; + screen_formatter.extra = self.extra.screen; + try screen_formatter.format(writer); + + // Extra terminal state to emit after the screen contents so that + // it doesn't impact the emitted contents. + if (self.opts.emit == .vt) { + // Emit scrolling region using DECSTBM and DECSLRM + if (self.extra.scrolling_region) { + const region = &self.terminal.scrolling_region; + + // DECSTBM: top and bottom margins (1-indexed) + // Only emit if not the full screen + if (region.top != 0 or region.bottom != self.terminal.rows - 1) { + try writer.print("\x1b[{d};{d}r", .{ region.top + 1, region.bottom + 1 }); + } + + // DECSLRM: left and right margins (1-indexed) + // Only emit if not the full width + if (region.left != 0 or region.right != self.terminal.cols - 1) { + try writer.print("\x1b[{d};{d}s", .{ region.left + 1, region.right + 1 }); + } + } + + // Emit tabstop positions + if (self.extra.tabstops) { + // Clear all tabs (CSI 3 g) + try writer.print("\x1b[3g", .{}); + + // Set each configured tabstop by moving cursor and using HTS + for (0..self.terminal.cols) |col| { + if (self.terminal.tabstops.get(col)) { + // Move cursor to the column (1-indexed) + try writer.print("\x1b[{d}G", .{col + 1}); + // Set tab (HTS) + try writer.print("\x1bH", .{}); + } + } + } + + // Emit keyboard modes such as ModifyOtherKeys + if (self.extra.keyboard) { + // Only emit if modify_other_keys_2 is true + if (self.terminal.flags.modify_other_keys_2) { + try writer.print("\x1b[>4;2m", .{}); + } + } + + // Emit present working directory using OSC 7 + if (self.extra.pwd) { + const pwd = self.terminal.pwd.items; + if (pwd.len > 0) try writer.print("\x1b]7;{s}\x1b\\", .{pwd}); + } + } + } +}; + +/// Screen formatter formats a single terminal screen (e.g. primary vs alt). +pub const ScreenFormatter = struct { + /// The screen to format. + screen: *const Screen, + + /// The common options + opts: Options, + + /// The content to include. + content: Content, + + /// Extra stuff to emit, such as cursor, style, hyperlinks, etc. + /// This information is ONLY emitted when the format is "vt". + extra: Extra, + + pub const Content = union(enum) { + /// Emit no content, only terminal state such as modes, palette, etc. + /// via extra. + none, + + /// Emit the content specified by the selection. Null for all. + selection: ?Selection, + }; + + pub const Extra = packed struct { + /// Emit cursor position using CUP (CSI H). + cursor: bool, + + /// Emit current SGR style state based on the cursor's active style_id. + /// This reconstructs the SGR attributes (bold, italic, colors, etc.) at + /// the cursor position. + style: bool, + + /// Emit current hyperlink state using OSC 8 sequences. + /// This sets the active hyperlink based on cursor.hyperlink_id. + hyperlink: bool, + + /// Emit character protection mode using DECSCA. + protection: bool, + + /// Emit Kitty keyboard protocol state using CSI > u and CSI = sequences. + kitty_keyboard: bool, + + /// Emit character set designations and invocations. + /// This includes G0-G3 designations (ESC ( ) * +) and GL/GR invocations. + charsets: bool, + + /// Emit nothing. + pub const none: Extra = .{ + .cursor = false, + .style = false, + .hyperlink = false, + .protection = false, + .kitty_keyboard = false, + .charsets = false, + }; + + /// Emit style-relevant information only. + pub const styles: Extra = .{ + .cursor = false, + .style = true, + .hyperlink = true, + .protection = false, + .kitty_keyboard = false, + .charsets = false, + }; + + /// Emit everything. This reconstructs the screen state as closely + /// as possible. + pub const all: Extra = .{ + .cursor = true, + .style = true, + .hyperlink = true, + .protection = true, + .kitty_keyboard = true, + .charsets = true, + }; + }; + + pub fn init( + screen: *const Screen, + opts: Options, + ) ScreenFormatter { + return .{ + .screen = screen, + .opts = opts, + .content = .{ .selection = null }, + .extra = .none, + }; + } + + pub fn format( + self: ScreenFormatter, + writer: *std.Io.Writer, + ) !void { + switch (self.content) { + .none => {}, + + .selection => |selection_| { + // Emit our pagelist contents according to our selection. + var list_formatter: PageListFormatter = .init(&self.screen.pages, self.opts); + if (selection_) |sel| { + list_formatter.top_left = sel.topLeft(self.screen); + list_formatter.bottom_right = sel.bottomRight(self.screen); + } + try list_formatter.format(writer); + }, + } + + // Emit extra screen state after content if we care. The state has + // to be emitted after since some state such as cursor position and + // style are impacted by content rendering. + switch (self.opts.emit) { + .plain => return, + .vt => {}, + } + + // Emit current SGR style state + if (self.extra.style) { + const cursor = &self.screen.cursor; + try writer.print("{f}", .{cursor.style.formatterVt()}); + } + + // Emit current hyperlink state using OSC 8 + if (self.extra.hyperlink) { + const cursor = &self.screen.cursor; + if (cursor.hyperlink) |link| { + // Start hyperlink with uri (and explicit id if present) + switch (link.id) { + .explicit => |id| try writer.print( + "\x1b]8;id={s};{s}\x1b\\", + .{ id, link.uri }, + ), + .implicit => try writer.print( + "\x1b]8;;{s}\x1b\\", + .{link.uri}, + ), + } + } + } + + // Emit character protection mode using DECSCA + if (self.extra.protection) { + const cursor = &self.screen.cursor; + if (cursor.protected) { + // DEC protected mode + try writer.print("\x1b[1\"q", .{}); + } + } + + // Emit Kitty keyboard protocol state using CSI = u + if (self.extra.kitty_keyboard) { + const current_flags = self.screen.kitty_keyboard.current(); + if (current_flags.int() != kitty.KeyFlags.disabled.int()) { + const flags = current_flags.int(); + try writer.print("\x1b[={d};1u", .{flags}); + } + } + + // Emit character set designations and invocations + if (self.extra.charsets) { + const charset = &self.screen.charset; + + // Emit G0-G3 designations + for (std.enums.values(charsets.Slots)) |slot| { + const cs = charset.charsets.get(slot); + if (cs != .utf8) { // Only emit non-default charsets + const intermediate: u8 = switch (slot) { + .G0 => '(', + .G1 => ')', + .G2 => '*', + .G3 => '+', + }; + const final: u8 = switch (cs) { + .ascii => 'B', + .british => 'A', + .dec_special => '0', + else => continue, + }; + try writer.print("\x1b{c}{c}", .{ intermediate, final }); + } + } + + // Emit GL invocation if not G0 + if (charset.gl != .G0) { + const seq = switch (charset.gl) { + .G0 => unreachable, + .G1 => "\x0e", // SO - Shift Out + .G2 => "\x1bn", // LS2 + .G3 => "\x1bo", // LS3 + }; + try writer.print("{s}", .{seq}); + } + + // Emit GR invocation if not G2 + if (charset.gr != .G2) { + const seq = switch (charset.gr) { + .G0 => unreachable, // GR can't be G0 + .G1 => "\x1b~", // LS1R + .G2 => unreachable, + .G3 => "\x1b|", // LS3R + }; + try writer.print("{s}", .{seq}); + } + } + + // Emit cursor position using CUP (CSI H) + if (self.extra.cursor) { + const cursor = &self.screen.cursor; + // CUP is 1-indexed + try writer.print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 }); + } + } +}; + +/// PageList formatter formats multiple pages as represented by a PageList. +pub const PageListFormatter = struct { + /// The pagelist to format. + list: *const PageList, + + /// The common options + opts: Options, + + /// The bounds of the PageList to format. The top left and bottom right + /// MUST be ordered properly. + top_left: ?PageList.Pin, + bottom_right: ?PageList.Pin, + + pub fn init( + list: *const PageList, + opts: Options, + ) PageListFormatter { + return PageListFormatter{ + .list = list, + .opts = opts, + .top_left = null, + .bottom_right = null, + }; + } + + pub fn format( + self: PageListFormatter, + writer: *std.Io.Writer, + ) !void { + const tl: PageList.Pin = self.top_left orelse self.list.getTopLeft(.screen); + const br: PageList.Pin = self.bottom_right orelse self.list.getBottomRight(.screen).?; + + var page_state: ?PageFormatter.TrailingState = null; + var iter = tl.pageIterator(.right_down, br); + while (iter.next()) |chunk| { + var formatter: PageFormatter = .init(&chunk.node.data, self.opts); + formatter.start_y = chunk.start; + formatter.end_y = chunk.end; + formatter.trailing_state = page_state; + + // Apply start_x if this is the first chunk + if (chunk.node == tl.node) formatter.start_x = tl.x; + + // Apply end_x if this is the last chunk and it ends at br.y + if (chunk.node == br.node and + formatter.end_y == br.y + 1) formatter.end_x = br.x + 1; + + page_state = try formatter.formatWithState(writer); + } + } +}; + +/// Page formatter. +/// +/// For styled formatting such as VT, this will emit references for palette +/// colors. If you want to capture the palette as-is at the type of formatting, +/// you'll have to emit the sequences for setting up the palette prior to +/// this formatting. (TODO: A function to do this) +pub const PageFormatter = struct { + /// The page to format. + page: *const Page, + + /// The common options + opts: Options, + + /// Start and end points within the page to format. If end x is not given + /// then it will be the full width. If end y is not given then it will be + /// the full height. + /// + /// The start x is considered the X in the first row and end X is + /// X in the final row. This isn't a rectangle selection by default. + start_x: size.CellCountInt, + start_y: size.CellCountInt, + end_x: ?size.CellCountInt, + end_y: ?size.CellCountInt, + + /// The previous trailing state from the prior page. If you're iterating + /// over multiple pages this helps ensure that unwrapping and other + /// accounting works properly. + trailing_state: ?TrailingState, + + /// Trailing state. This is used to ensure that rows wrapped across + /// multiple pages are unwrapped properly, as well as other accounting + /// we may do in the future. + pub const TrailingState = struct { + rows: usize = 0, + cells: usize = 0, + + pub const empty: TrailingState = .{ .rows = 0, .cells = 0 }; + }; + + /// Initializes a page formatter. Other options can be set directly on the + /// struct after initialization and before calling `format()`. + pub fn init(page: *const Page, opts: Options) PageFormatter { + return PageFormatter{ + .page = page, + .opts = opts, + .start_x = 0, + .start_y = 0, + .end_x = null, + .end_y = null, + .trailing_state = null, + }; + } + + pub fn format( + self: PageFormatter, + writer: *std.Io.Writer, + ) !void { + _ = try self.formatWithState(writer); + } + + pub fn formatWithState( + self: PageFormatter, + writer: *std.Io.Writer, + ) !TrailingState { + var blank_rows: usize = 0; + var blank_cells: usize = 0; + + // Continue our prior trailing state if we have it, but only if we're + // starting from the beginning (start_y and start_x are both 0). + // If a non-zero start position is specified, ignore trailing state. + if (self.trailing_state) |state| { + if (self.start_y == 0 and self.start_x == 0) { + blank_rows = state.rows; + blank_cells = state.cells; + } + } + + // Setup our starting row and perform some validation for overflows. + const start_y: size.CellCountInt = self.start_y; + if (start_y >= self.page.size.rows) return .{ .rows = blank_rows, .cells = blank_cells }; + const end_y_unclamped: size.CellCountInt = self.end_y orelse self.page.size.rows; + if (start_y >= end_y_unclamped) return .{ .rows = blank_rows, .cells = blank_cells }; + const end_y = @min(end_y_unclamped, self.page.size.rows); + + // Setup our starting column and perform some validation for overflows. + // Note: start_x only applies to the first row, end_x only applies to the last row. + const start_x: size.CellCountInt = self.start_x; + if (start_x >= self.page.size.cols) return .{ .rows = blank_rows, .cells = blank_cells }; + const end_x_unclamped: size.CellCountInt = self.end_x orelse self.page.size.cols; + const end_x = @min(end_x_unclamped, self.page.size.cols); + + // If we only have a single row, validate that start_x < end_x + if (start_y + 1 == end_y and start_x >= end_x) { + return .{ .rows = blank_rows, .cells = blank_cells }; + } + + // Our style for non-plain formats + var style: Style = .{}; + + for (start_y..end_y) |y_usize| { + const y: size.CellCountInt = @intCast(y_usize); + const row: *Row = self.page.getRow(y); + const cells: []const Cell = self.page.getCells(row); + + // Determine the x range for this row + // - First row: start_x to end of row (or end_x if single row) + // - Last row: start of row to end_x + // - Middle rows: full width + const is_first_row = (y == start_y); + const is_last_row = (y == end_y - 1); + const row_start_x: size.CellCountInt = if (is_first_row) start_x else 0; + const row_end_x: size.CellCountInt = if (is_last_row) end_x else self.page.size.cols; + const cells_subset = cells[row_start_x..row_end_x]; + + // If this row is blank, accumulate to avoid a bunch of extra + // work later. If it isn't blank, make sure we dump all our + // blanks. + if (!Cell.hasTextAny(cells_subset)) { + blank_rows += 1; + continue; + } + + for (1..blank_rows + 1) |_| { + try writer.writeAll("\r\n"); + } + blank_rows = 0; + + // If we're not wrapped, we always add a newline so after + // the row is printed we can add a newline. + if (!row.wrap or !self.opts.unwrap) blank_rows += 1; + + // If the row doesn't continue a wrap then we need to reset + // our blank cell count. + if (!row.wrap_continuation or !self.opts.unwrap) blank_cells = 0; + + // Go through each cell and print it + for (cells_subset) |*cell| { + // Skip spacers. These happen naturally when wide characters + // are printed again on the screen (for well-behaved terminals!) + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } + + // If we have a zero value, then we accumulate a counter. We + // only want to turn zero values into spaces if we have a non-zero + // char sometime later. + if (!cell.hasText() or cell.codepoint() == ' ') { + blank_cells += 1; + continue; + } + + // This cell is not blank. If we have accumulated blank cells + // then we want to emit them now. + if (blank_cells > 0) { + try writer.splatByteAll(' ', blank_cells); + blank_cells = 0; + } + + switch (cell.content_tag) { + // We combine codepoint and graphemes because both have + // shared style handling. We use comptime to dup it. + inline .codepoint, .codepoint_grapheme => |tag| { + // If we're emitting styling and we have styles, then + // we need to load the style and emit any sequences + // as necessary. + if (self.opts.emit.styled() and cell.hasStyling()) style: { + // Get the style. + const cell_style = self.page.styles.get( + self.page.memory, + cell.style_id, + ); + + // If the style hasn't changed since our last + // emitted style, don't bloat the output. + if (cell_style.eql(style)) break :style; + + // New style, emit it. + style = cell_style.*; + try writer.print("{f}", .{style.formatterVt()}); + } + + try writer.print("{u}", .{cell.content.codepoint}); + if (comptime tag == .codepoint_grapheme) { + for (self.page.lookupGrapheme(cell).?) |cp| { + try writer.print("{u}", .{cp}); + } + } + }, + + // Unreachable since we do hasText() above + .bg_color_palette, + .bg_color_rgb, + => unreachable, + } + } + } + + return .{ .rows = blank_rows, .cells = blank_cells }; + } +}; + +test "Page plain single line" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + // Verify we have only a single page + const pages = &t.screen.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; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello, world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 12), state.cells); +} + +test "Page plain multiline" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + // Verify we have only a single page + const pages = &t.screen.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; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain multi blank lines" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\n\r\n\r\nworld"); + + // Verify we have only a single page + const pages = &t.screen.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; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello\r\n\r\n\r\nworld", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 3), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain trailing blank lines" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\n\r\n"); + + // Verify we have only a single page + const pages = &t.screen.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; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output. We expect there to be no trailing newlines because + // we can't differentiate trailing blank lines as being meaningful because + // the page formatter can't see the cursor position. + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain trailing whitespace" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello \r\nworld "); + + // Verify we have only a single page + const pages = &t.screen.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; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output. We expect there to be no trailing newlines because + // we can't differentiate trailing blank lines as being meaningful because + // the page formatter can't see the cursor position. + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain with prior trailing state rows" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + formatter.trailing_state = .{ .rows = 2, .cells = 0 }; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("\r\n\r\nhello", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain with prior trailing state cells no wrapped line" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + formatter.trailing_state = .{ .rows = 0, .cells = 3 }; + + const state = try formatter.formatWithState(&builder.writer); + // Blank cells are reset when row is not a wrap continuation + try testing.expectEqualStrings("hello", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain with prior trailing state cells with wrap continuation" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("world"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + + // Surgically modify the first row to be a wrap continuation + const row = page.getRow(0); + row.wrap_continuation = true; + + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + formatter.trailing_state = .{ .rows = 0, .cells = 3 }; + + const state = try formatter.formatWithState(&builder.writer); + // Blank cells are preserved when row is a wrap continuation with unwrap enabled + try testing.expectEqualStrings(" world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain soft-wrapped without unwrap" { + 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 = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world test"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .plain); + + const state = try formatter.formatWithState(&builder.writer); + // Without unwrap, wrapped lines show as separate lines + try testing.expectEqualStrings("hello worl\r\nd test", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); +} + +test "Page plain soft-wrapped with unwrap" { + 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 = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world test"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + + const state = try formatter.formatWithState(&builder.writer); + // With unwrap, wrapped lines are joined together + try testing.expectEqualStrings("hello world test", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); +} + +test "Page plain soft-wrapped 3 lines without unwrap" { + 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 = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world this is a test"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .plain); + + const state = try formatter.formatWithState(&builder.writer); + // Without unwrap, wrapped lines show as separate lines + try testing.expectEqualStrings("hello worl\r\nd this is\r\na test", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); +} + +test "Page plain soft-wrapped 3 lines with unwrap" { + 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 = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world this is a test"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + + const state = try formatter.formatWithState(&builder.writer); + // With unwrap, wrapped lines are joined together + try testing.expectEqualStrings("hello world this is a test", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); +} + +test "Page plain start_y subset" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\ntest"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 1; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("world\r\ntest", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); +} + +test "Page plain end_y subset" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\ntest"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_y = 2; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain start_y and end_y range" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 1; + formatter.end_y = 3; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("world\r\ntest", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); +} + +test "Page plain start_y out of bounds" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 30; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); +} + +test "Page plain end_y greater than rows" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_y = 30; + + const state = try formatter.formatWithState(&builder.writer); + // Should clamp to page.size.rows and work normally + try testing.expectEqualStrings("hello", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain end_y less than start_y" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 5; + formatter.end_y = 2; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); +} + +test "Page plain start_x on first row only" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 6; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 11), state.cells); +} + +test "Page plain end_x on last row only" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("first line\r\nsecond line\r\nthird line"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_y = 3; + formatter.end_x = 6; + + const state = try formatter.formatWithState(&builder.writer); + // First two rows: full width, last row: up to end_x=6 + try testing.expectEqualStrings("first line\r\nsecond line\r\nthird", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, 1), state.cells); +} + +test "Page plain start_x and end_x multiline" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 6; + formatter.end_y = 3; + formatter.end_x = 4; + + const state = try formatter.formatWithState(&builder.writer); + // First row: "world" (start_x=6 to end of row) + // Second row: "test case" (full row) + // Third row: "foo " (start to end_x=4) + try testing.expectEqualStrings("world\r\ntest case\r\nfoo", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, 1), state.cells); +} + +test "Page plain start_x out of bounds" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 100; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); +} + +test "Page plain end_x greater than cols" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_x = 100; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain end_x less than start_x single row" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 10; + formatter.end_y = 1; + formatter.end_x = 5; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); +} + +test "Page plain start_y non-zero ignores trailing state" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 1; + formatter.trailing_state = .{ .rows = 5, .cells = 10 }; + + const state = try formatter.formatWithState(&builder.writer); + // Should NOT output the 5 newlines from trailing_state because start_y is non-zero + try testing.expectEqualStrings("world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain start_x non-zero ignores trailing state" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 6; + formatter.trailing_state = .{ .rows = 2, .cells = 8 }; + + const state = try formatter.formatWithState(&builder.writer); + // Should NOT output the 2 newlines or 8 spaces from trailing_state because start_x is non-zero + try testing.expectEqualStrings("world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 11), state.cells); +} + +test "Page plain start_y and start_x zero uses trailing state" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 0; + formatter.start_x = 0; + formatter.trailing_state = .{ .rows = 2, .cells = 0 }; + + const state = try formatter.formatWithState(&builder.writer); + // SHOULD output the 2 newlines from trailing_state because both start_y and start_x are 0 + try testing.expectEqualStrings("\r\n\r\nhello", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain single line with styling" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, \x1b[1mworld\x1b[0m"); + + // Verify we have only a single page + const pages = &t.screen.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; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello, world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 12), state.cells); +} + +test "Page VT single line plain text" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello", builder.writer.buffered()); +} + +test "Page VT single line with bold" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mhello\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", builder.writer.buffered()); +} + +test "Page VT multiple styles" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld", builder.writer.buffered()); +} + +test "Page VT with foreground color" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[31mred\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred", builder.writer.buffered()); +} + +test "Page VT multi-line with styles" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\r\n\x1b[0m\x1b[3msecond", builder.writer.buffered()); +} + +test "Page VT duplicate style not emitted twice" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", builder.writer.buffered()); +} + +test "PageList plain single line" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + const formatter: PageListFormatter = .init(&t.screen.pages, .plain); + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings("hello, world", builder.writer.buffered()); +} + +test "PageList plain spanning two pages" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screen.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page almost completely + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("page one"); + + // Verify we're still on one page + try testing.expect(pages.pages.first == pages.pages.last); + + // Add one more newline to push content to a second page + try s.nextSlice("\r\n"); + try testing.expect(pages.pages.first != pages.pages.last); + + // Write content on the second page + try s.nextSlice("page two"); + + // Format the entire PageList + var formatter: PageListFormatter = .init(pages, .plain); + try formatter.format(&builder.writer); + const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + try testing.expectEqualStrings("page one\r\npage two", output); +} + +test "PageList soft-wrapped line spanning two pages without unwrap" { + 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 = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screen.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page with soft-wrapped content + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello world test"); + + // Verify we're on two pages due to wrapping + try testing.expect(pages.pages.first != pages.pages.last); + + // Format without unwrap - should show line breaks + var formatter: PageListFormatter = .init(pages, .plain); + try formatter.format(&builder.writer); + const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + try testing.expectEqualStrings("hello worl\r\nd test", output); +} + +test "PageList soft-wrapped line spanning two pages with unwrap" { + 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 = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screen.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page with soft-wrapped content + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello world test"); + + // Verify we're on two pages due to wrapping + try testing.expect(pages.pages.first != pages.pages.last); + + // Format with unwrap - should join the wrapped lines + var formatter: PageListFormatter = .init(pages, .{ .emit = .plain, .unwrap = true }); + try formatter.format(&builder.writer); + const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + try testing.expectEqualStrings("hello world test", output); +} + +test "PageList VT spanning two pages" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screen.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page almost completely + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("\x1b[1mpage one"); + + // Verify we're still on one page + try testing.expect(pages.pages.first == pages.pages.last); + + // Add one more newline to push content to a second page + try s.nextSlice("\r\n"); + try testing.expect(pages.pages.first != pages.pages.last); + + // New content is still styled + try s.nextSlice("page two"); + + // Format the entire PageList with VT + var formatter: PageListFormatter = .init(pages, .vt); + try formatter.format(&builder.writer); + const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + try testing.expectEqualStrings("\x1b[0m\x1b[1mpage one\r\n\x1b[0m\x1b[1mpage two", output); +} + +test "PageList plain with x offset on single page" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); + + const pages = &t.screen.pages; + const node = pages.pages.first.?; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = .{ .node = node, .y = 0, .x = 6 }; + formatter.bottom_right = .{ .node = node, .y = 2, .x = 3 }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("world\r\ntest case\r\nfoo", builder.writer.buffered()); +} + +test "PageList plain with x offset spanning two pages" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screen.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill first page almost completely + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello world"); + + // Verify we're still on one page + try testing.expect(pages.pages.first == pages.pages.last); + + // Push to second page + try s.nextSlice("\r\n"); + try testing.expect(pages.pages.first != pages.pages.last); + + try s.nextSlice("foo bar test"); + + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = .{ .node = first_node, .y = first_node.data.size.rows - 1, .x = 6 }; + formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 3 }; + + try formatter.format(&builder.writer); + const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + try testing.expectEqualStrings("world\r\nfoo", output); +} + +test "PageList plain with start_x only" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screen.pages; + const node = pages.pages.first.?; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = .{ .node = node, .y = 0, .x = 6 }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("world", builder.writer.buffered()); +} + +test "PageList plain with end_x only" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world\r\ntest"); + + const pages = &t.screen.pages; + const node = pages.pages.first.?; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.bottom_right = .{ .node = node, .y = 1, .x = 2 }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello world\r\ntes", builder.writer.buffered()); +} + +test "TerminalFormatter plain no selection" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + const formatter: TerminalFormatter = .init(&t, .plain); + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); +} + +test "TerminalFormatter vt with palette" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Modify some palette colors using VT sequences + try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); + try s.nextSlice("test"); + + const formatter: TerminalFormatter = .init(&t, .vt); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify the palettes match + try testing.expectEqual(t.color_palette.colors[0], t2.color_palette.colors[0]); + try testing.expectEqual(t.color_palette.colors[1], t2.color_palette.colors[1]); + try testing.expectEqual(t.color_palette.colors[255], t2.color_palette.colors[255]); +} + +test "TerminalFormatter with selection" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("line1\r\nline2\r\nline3"); + + var formatter: TerminalFormatter = .init(&t, .plain); + formatter.content = .{ .selection = .init( + t.screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + false, + ) }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("line2", builder.writer.buffered()); +} + +test "Screen plain single line" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + const formatter: ScreenFormatter = .init(&t.screen, .plain); + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello, world", builder.writer.buffered()); +} + +test "Screen plain multiline" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + const formatter: ScreenFormatter = .init(&t.screen, .plain); + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); +} + +test "Screen plain with selection" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("line1\r\nline2\r\nline3"); + + var formatter: ScreenFormatter = .init(&t.screen, .plain); + formatter.content = .{ .selection = .init( + t.screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + false, + ) }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("line2", builder.writer.buffered()); +} + +test "Screen vt with cursor position" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Position cursor at a specific location + try s.nextSlice("hello\r\nworld"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.cursor = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify cursor positions match + try testing.expectEqual(t.screen.cursor.x, t2.screen.cursor.x); + try testing.expectEqual(t.screen.cursor.y, t2.screen.cursor.y); +} + +test "Screen vt with style" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set some style attributes + try s.nextSlice("\x1b[1;31mhello"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.style = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify styles match + try testing.expect(t.screen.cursor.style.eql(t2.screen.cursor.style)); +} + +test "Screen vt with hyperlink" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set a hyperlink + try s.nextSlice("\x1b]8;;http://example.com\x1b\\hello"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.hyperlink = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify hyperlinks match + const has_link1 = t.screen.cursor.hyperlink != null; + const has_link2 = t2.screen.cursor.hyperlink != null; + try testing.expectEqual(has_link1, has_link2); + + if (has_link1) { + const link1 = t.screen.cursor.hyperlink.?; + const link2 = t2.screen.cursor.hyperlink.?; + try testing.expectEqualStrings(link1.uri, link2.uri); + } +} + +test "Screen vt with protection" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Enable protection mode + try s.nextSlice("\x1b[1\"qhello"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.protection = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify protection state matches + try testing.expectEqual(t.screen.cursor.protected, t2.screen.cursor.protected); +} + +test "Screen vt with kitty keyboard" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set kitty keyboard flags (disambiguate + report_events = 3) + try s.nextSlice("\x1b[=3;1uhello"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.kitty_keyboard = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify kitty keyboard state matches + const flags1 = t.screen.kitty_keyboard.current().int(); + const flags2 = t2.screen.kitty_keyboard.current().int(); + try testing.expectEqual(flags1, flags2); +} + +test "Screen vt with charsets" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set G0 to DEC special and shift to G1 + try s.nextSlice("\x1b(0\x0ehello"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.charsets = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify charset state matches + try testing.expectEqual(t.screen.charset.gl, t2.screen.charset.gl); + try testing.expectEqual(t.screen.charset.gr, t2.screen.charset.gr); + try testing.expectEqual( + t.screen.charset.charsets.get(.G0), + t2.screen.charset.charsets.get(.G0), + ); +} + +test "Terminal vt with scrolling region" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set scrolling region: top=5, bottom=20 + try s.nextSlice("\x1b[6;21rhello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.scrolling_region = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify scrolling regions match + try testing.expectEqual(t.scrolling_region.top, t2.scrolling_region.top); + try testing.expectEqual(t.scrolling_region.bottom, t2.scrolling_region.bottom); + try testing.expectEqual(t.scrolling_region.left, t2.scrolling_region.left); + try testing.expectEqual(t.scrolling_region.right, t2.scrolling_region.right); +} + +test "Terminal vt with modes" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Enable some modes that differ from defaults + try s.nextSlice("\x1b[?2004h"); // Bracketed paste + try s.nextSlice("\x1b[?1000h"); // Mouse event normal + try s.nextSlice("\x1b[?7l"); // Disable wraparound (default is true) + try s.nextSlice("hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.modes = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify modes match + try testing.expectEqual(t.modes.get(.bracketed_paste), t2.modes.get(.bracketed_paste)); + try testing.expectEqual(t.modes.get(.mouse_event_normal), t2.modes.get(.mouse_event_normal)); + try testing.expectEqual(t.modes.get(.wraparound), t2.modes.get(.wraparound)); +} + +test "Terminal vt with tabstops" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Clear all tabs and set custom tabstops + try s.nextSlice("\x1b[3g"); // Clear all tabs + try s.nextSlice("\x1b[5G\x1bH"); // Set tab at column 5 + try s.nextSlice("\x1b[15G\x1bH"); // Set tab at column 15 + try s.nextSlice("\x1b[30G\x1bH"); // Set tab at column 30 + try s.nextSlice("hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.tabstops = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify tabstops match (columns are 0-indexed in the API) + try testing.expectEqual(t.tabstops.get(4), t2.tabstops.get(4)); + try testing.expectEqual(t.tabstops.get(14), t2.tabstops.get(14)); + try testing.expectEqual(t.tabstops.get(29), t2.tabstops.get(29)); + try testing.expect(t2.tabstops.get(4)); // Column 5 (1-indexed) + try testing.expect(t2.tabstops.get(14)); // Column 15 (1-indexed) + try testing.expect(t2.tabstops.get(29)); // Column 30 (1-indexed) + try testing.expect(!t2.tabstops.get(8)); // Not a tab +} + +test "Terminal vt with keyboard modes" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set modify other keys mode 2 + try s.nextSlice("\x1b[>4;2m"); + try s.nextSlice("hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.keyboard = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify keyboard mode matches + try testing.expectEqual(t.flags.modify_other_keys_2, t2.flags.modify_other_keys_2); + try testing.expect(t2.flags.modify_other_keys_2); +} + +test "Terminal vt with pwd" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set pwd using OSC 7 + try s.nextSlice("\x1b]7;file://host/home/user\x1b\\hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.pwd = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify pwd matches + try testing.expectEqualStrings(t.pwd.items, t2.pwd.items); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index c2d6e8cb4..bdcbfe77f 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -13,6 +13,7 @@ pub const osc = @import("osc.zig"); 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 kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 331168a27..e38e96e92 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -11,7 +11,10 @@ const color = @import("color.zig"); const hyperlink = @import("hyperlink.zig"); const kitty = @import("kitty.zig"); const sgr = @import("sgr.zig"); -const style = @import("style.zig"); +const stylepkg = @import("style.zig"); +const Style = stylepkg.Style; +const StyleId = stylepkg.Id; +const StyleSet = stylepkg.Set; const size = @import("size.zig"); const getOffset = size.getOffset; const Offset = size.Offset; @@ -86,7 +89,7 @@ pub const Page = struct { assert(std.heap.page_size_min % @max( @alignOf(Row), @alignOf(Cell), - style.Set.base_align.toByteUnits(), + StyleSet.base_align.toByteUnits(), ) == 0); } @@ -124,7 +127,7 @@ pub const Page = struct { grapheme_map: GraphemeMap, /// The available set of styles in use on this page. - styles: style.Set, + styles: StyleSet, /// The structures used for tracking hyperlinks within the page. /// The map maps cell offsets to hyperlink IDs and the IDs are in @@ -236,7 +239,7 @@ pub const Page = struct { .rows = rows, .cells = cells, .dirty = buf.member(usize, l.dirty_start), - .styles = style.Set.init( + .styles = StyleSet.init( buf.add(l.styles_start), l.styles_layout, .{}, @@ -372,7 +375,7 @@ pub const Page = struct { const alloc = arena.allocator(); var graphemes_seen: usize = 0; - var styles_seen = std.AutoHashMap(style.Id, usize).init(alloc); + var styles_seen = std.AutoHashMap(StyleId, usize).init(alloc); defer styles_seen.deinit(); var hyperlinks_seen = std.AutoHashMap(hyperlink.Id, usize).init(alloc); defer hyperlinks_seen.deinit(); @@ -409,7 +412,7 @@ pub const Page = struct { } } - if (cell.style_id != style.default_id) { + if (cell.style_id != stylepkg.default_id) { // If a cell has a style, it must be present in the styles // set. Accessing it with `get` asserts that. _ = self.styles.get( @@ -767,7 +770,7 @@ pub const Page = struct { for (other_cells) |cell| { assert(!cell.hasGrapheme()); assert(!cell.hyperlink); - assert(cell.style_id == style.default_id); + assert(cell.style_id == stylepkg.default_id); } } @@ -782,7 +785,7 @@ pub const Page = struct { // hit an integrity check if we have to return an error because // the page can't fit the new memory. dst_cell.hyperlink = false; - dst_cell.style_id = style.default_id; + dst_cell.style_id = stylepkg.default_id; if (dst_cell.content_tag == .codepoint_grapheme) { dst_cell.content_tag = .codepoint; } @@ -791,7 +794,7 @@ pub const Page = struct { // 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 = style.default_id; + dst_cell.style_id = stylepkg.default_id; } // Copy the grapheme codepoints @@ -867,7 +870,7 @@ pub const Page = struct { try self.setHyperlink(dst_row, dst_cell, dst_id); } - if (src_cell.style_id != style.default_id) style: { + if (src_cell.style_id != stylepkg.default_id) style: { dst_row.styled = true; if (other == self) { @@ -995,7 +998,7 @@ pub const Page = struct { // The destination row has styles if any of the cells are styled if (!dst_row.styled) dst_row.styled = styled: for (dst_cells) |c| { - if (c.style_id != style.default_id) break :styled true; + if (c.style_id != stylepkg.default_id) break :styled true; } else false; // Clear our source row now that the copy is complete. We can NOT @@ -1101,7 +1104,7 @@ pub const Page = struct { if (row.styled) { for (cells) |*cell| { - if (cell.style_id == style.default_id) continue; + if (cell.style_id == stylepkg.default_id) continue; self.styles.release(self.memory, cell.style_id); } @@ -1720,7 +1723,7 @@ pub const Page = struct { dirty_start: usize, dirty_size: usize, styles_start: usize, - styles_layout: style.Set.Layout, + styles_layout: StyleSet.Layout, grapheme_alloc_start: usize, grapheme_alloc_layout: GraphemeAlloc.Layout, grapheme_map_start: usize, @@ -1756,8 +1759,8 @@ pub const Page = struct { const dirty_start = alignForward(usize, cells_end, @alignOf(usize)); const dirty_end: usize = dirty_start + (dirty_usize_length * @sizeOf(usize)); - const styles_layout: style.Set.Layout = .init(cap.styles); - const styles_start = alignForward(usize, dirty_end, style.Set.base_align.toByteUnits()); + const styles_layout: StyleSet.Layout = .init(cap.styles); + const styles_start = alignForward(usize, dirty_end, StyleSet.base_align.toByteUnits()); const styles_end = styles_start + styles_layout.total_size; const grapheme_alloc_layout = GraphemeAlloc.layout(cap.grapheme_bytes); @@ -1886,7 +1889,7 @@ pub const Capacity = struct { const string_alloc_start = alignBackward(usize, hyperlink_set_start - layout.string_alloc_layout.total_size, StringAlloc.base_align.toByteUnits()); const grapheme_map_start = alignBackward(usize, string_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align.toByteUnits()); const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align.toByteUnits()); - const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align.toByteUnits()); + const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, StyleSet.base_align.toByteUnits()); // The size per row is: // - The row metadata itself @@ -2014,7 +2017,7 @@ pub const Cell = packed struct(u64) { /// The style ID to use for this cell within the style map. Zero /// is always the default style so no lookup is required. - style_id: style.Id = 0, + style_id: StyleId = 0, /// The wide property of this cell, for wide characters. Characters in /// a terminal grid can only be 1 or 2 cells wide. A wide character @@ -2123,7 +2126,7 @@ pub const Cell = packed struct(u64) { } pub fn hasStyling(self: Cell) bool { - return self.style_id != style.default_id; + return self.style_id != stylepkg.default_id; } /// Returns true if the cell has no text or styling. diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index a38e5ce9e..23211fa80 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1992,8 +1992,6 @@ pub fn Stream(comptime Handler: type) type { log.warn("invalid OSC, should never happen", .{}); }, } - - log.warn("unimplemented OSC command: {s}", .{@tagName(cmd)}); } inline fn configureCharset( @@ -2255,8 +2253,8 @@ test "stream: print" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .print => self.c = value.cp, @@ -2276,8 +2274,8 @@ test "simd: print invalid utf-8" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .print => self.c = value.cp, @@ -2297,8 +2295,8 @@ test "simd: complete incomplete utf-8" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .print => self.c = value.cp, @@ -2322,8 +2320,8 @@ test "stream: cursor right (CUF)" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .cursor_right => self.amount = value.value, @@ -2354,8 +2352,8 @@ test "stream: dec set mode (SM) and reset mode (RM)" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .set_mode => self.mode = value.mode, @@ -2383,8 +2381,8 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .set_mode => self.mode = value.mode, @@ -2417,11 +2415,10 @@ test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { _ = self; - _ = action; _ = value; } }; diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 4d284d990..86a525284 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -160,6 +160,7 @@ pub const Handler = struct { .end_of_input => self.terminal.markSemanticPrompt(.command), .end_of_command => self.terminal.screen.cursor.page_row.semantic_prompt = .input, .mouse_shape => self.terminal.mouse_shape = value, + .color_operation => try self.colorOperation(value.op, &value.requests), // No supported DCS commands have any terminal-modifying effects, // but they may in the future. For now we just ignore it. @@ -186,7 +187,6 @@ pub const Handler = struct { .device_status, .kitty_keyboard_query, .kitty_color_report, - .color_operation, .window_title, .report_pwd, .show_desktop_notification, @@ -291,6 +291,56 @@ pub const Handler = struct { else => {}, } } + + fn colorOperation( + self: *Handler, + op: @import("osc/color.zig").Operation, + requests: *const @import("osc/color.zig").List, + ) !void { + _ = op; + if (requests.count() == 0) return; + + var it = requests.constIterator(0); + while (it.next()) |req| { + switch (req.*) { + .set => |set| { + switch (set.target) { + .palette => |i| { + self.terminal.color_palette.colors[i] = set.color; + self.terminal.color_palette.mask.set(i); + }, + .dynamic, + .special, + => {}, + } + }, + + .reset => |target| switch (target) { + .palette => |i| { + const mask = &self.terminal.color_palette.mask; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + }, + .dynamic, + .special, + => {}, + }, + + .reset_palette => { + const mask = &self.terminal.color_palette.mask; + var mask_iterator = mask.iterator(.{}); + while (mask_iterator.next()) |i| { + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + } + mask.* = .initEmpty(); + }, + + .query, + .reset_special, + => {}, + } + } + } }; test "basic print" { @@ -540,3 +590,51 @@ test "ignores query actions" { defer testing.allocator.free(str); try testing.expectEqualStrings("Test", str); } + +test "OSC 4 set and reset palette" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Save default color + const default_color_0 = t.default_palette[0]; + + // Set color 0 to red + try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + try testing.expectEqual(@as(u8, 0xff), t.color_palette.colors[0].r); + try testing.expectEqual(@as(u8, 0x00), t.color_palette.colors[0].g); + try testing.expectEqual(@as(u8, 0x00), t.color_palette.colors[0].b); + try testing.expect(t.color_palette.mask.isSet(0)); + + // Reset color 0 + try s.nextSlice("\x1b]104;0\x1b\\"); + try testing.expectEqual(default_color_0, t.color_palette.colors[0]); + try testing.expect(!t.color_palette.mask.isSet(0)); +} + +test "OSC 104 reset all palette colors" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set multiple colors + try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + try s.nextSlice("\x1b]4;1;rgb:00/ff/00\x1b\\"); + try s.nextSlice("\x1b]4;2;rgb:00/00/ff\x1b\\"); + try testing.expect(t.color_palette.mask.isSet(0)); + try testing.expect(t.color_palette.mask.isSet(1)); + try testing.expect(t.color_palette.mask.isSet(2)); + + // Reset all palette colors + try s.nextSlice("\x1b]104\x1b\\"); + try testing.expectEqual(t.default_palette[0], t.color_palette.colors[0]); + try testing.expectEqual(t.default_palette[1], t.color_palette.colors[1]); + try testing.expectEqual(t.default_palette[2], t.color_palette.colors[2]); + try testing.expect(!t.color_palette.mask.isSet(0)); + try testing.expect(!t.color_palette.mask.isSet(1)); + try testing.expect(!t.color_palette.mask.isSet(2)); +} diff --git a/src/terminal/style.zig b/src/terminal/style.zig index eac577a53..fea4666b8 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -293,6 +293,74 @@ pub const Style = struct { _ = try writer.write(" }"); } + /// Returns a formatter that renders this style as VT sequences, + /// to be used with `{f}`. This always resets the style first `\x1b[0m` + /// since a style is meant to be fully self-contained. + /// + /// For individual styles, this always emits multiple SGR sequences + /// (i.e. an individual `\x1b[m` for each attribute) rather than + /// trying to combine them into a single sequence. We do this because + /// terminals have varying levels of support for combined sequences + /// especially with mixed separators (e.g. `:` vs `;`). + pub fn formatterVt(self: *const Style) VTFormatter { + return .{ .style = self }; + } + + const VTFormatter = struct { + style: *const Style, + + pub fn format( + self: VTFormatter, + writer: *std.Io.Writer, + ) !void { + // Always reset the style. Styles are fully self-contained. + // Even if this style is empty, then that means we want to go + // back to the default. + try writer.writeAll("\x1b[0m"); + + // Our flags + if (self.style.flags.bold) try writer.writeAll("\x1b[1m"); + if (self.style.flags.faint) try writer.writeAll("\x1b[2m"); + if (self.style.flags.italic) try writer.writeAll("\x1b[3m"); + if (self.style.flags.blink) try writer.writeAll("\x1b[5m"); + if (self.style.flags.inverse) try writer.writeAll("\x1b[7m"); + if (self.style.flags.invisible) try writer.writeAll("\x1b[8m"); + if (self.style.flags.strikethrough) try writer.writeAll("\x1b[9m"); + if (self.style.flags.overline) try writer.writeAll("\x1b[53m"); + switch (self.style.flags.underline) { + .none => {}, + .single => try writer.writeAll("\x1b[4m"), + .double => try writer.writeAll("\x1b[4:2m"), + .curly => try writer.writeAll("\x1b[4:3m"), + .dotted => try writer.writeAll("\x1b[4:4m"), + .dashed => try writer.writeAll("\x1b[4:5m"), + } + + // Various RGB colors. + try formatColor(writer, 38, self.style.fg_color); + try formatColor(writer, 48, self.style.bg_color); + try formatColor(writer, 58, self.style.underline_color); + } + + fn formatColor( + writer: *std.Io.Writer, + prefix: u8, + value: Color, + ) !void { + switch (value) { + .none => {}, + .palette => |idx| try writer.print( + "\x1b[{d};5;{}m", + .{ prefix, idx }, + ), + .rgb => |rgb| try writer.print( + "\x1b[{d};2;{};{};{}m", + .{ prefix, rgb.r, rgb.g, rgb.b }, + ), + } + } + }; + /// `PackedStyle` represents the same data as `Style` but without padding, /// which is necessary for hashing via re-interpretation of the underlying /// bytes. @@ -394,6 +462,316 @@ pub const Set = RefCountedSet( }, ); +test "Style VT formatting empty" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{}; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m", builder.writer.buffered()); +} + +test "Style VT formatting bold" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .bold = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[1m", builder.writer.buffered()); +} + +test "Style VT formatting faint" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .faint = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[2m", builder.writer.buffered()); +} + +test "Style VT formatting italic" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .italic = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[3m", builder.writer.buffered()); +} + +test "Style VT formatting blink" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .blink = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[5m", builder.writer.buffered()); +} + +test "Style VT formatting inverse" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .inverse = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[7m", builder.writer.buffered()); +} + +test "Style VT formatting invisible" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .invisible = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[8m", builder.writer.buffered()); +} + +test "Style VT formatting strikethrough" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .strikethrough = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[9m", builder.writer.buffered()); +} + +test "Style VT formatting overline" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .overline = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[53m", builder.writer.buffered()); +} + +test "Style VT formatting underline single" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .single } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4m", builder.writer.buffered()); +} + +test "Style VT formatting underline double" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .double } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:2m", builder.writer.buffered()); +} + +test "Style VT formatting underline curly" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .curly } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:3m", builder.writer.buffered()); +} + +test "Style VT formatting underline dotted" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .dotted } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:4m", builder.writer.buffered()); +} + +test "Style VT formatting underline dashed" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .dashed } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:5m", builder.writer.buffered()); +} + +test "Style VT formatting fg palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .palette = 42 } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;42m", builder.writer.buffered()); +} + +test "Style VT formatting fg rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[38;2;255;128;64m", builder.writer.buffered()); +} + +test "Style VT formatting bg palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .palette = 7 } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[48;5;7m", builder.writer.buffered()); +} + +test "Style VT formatting bg rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .rgb = .{ .r = 32, .g = 64, .b = 96 } } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[48;2;32;64;96m", builder.writer.buffered()); +} + +test "Style VT formatting underline_color palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .underline_color = .{ .palette = 15 } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[58;5;15m", builder.writer.buffered()); +} + +test "Style VT formatting underline_color rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .underline_color = .{ .rgb = .{ .r = 200, .g = 100, .b = 50 } } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[58;2;200;100;50m", builder.writer.buffered()); +} + +test "Style VT formatting multiple flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .bold = true, .italic = true, .underline = .single } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[1m\x1b[3m\x1b[4m", builder.writer.buffered()); +} + +test "Style VT formatting all flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ + .bold = true, + .faint = true, + .italic = true, + .blink = true, + .inverse = true, + .invisible = true, + .strikethrough = true, + .overline = true, + .underline = .curly, + } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[1m\x1b[2m\x1b[3m\x1b[5m\x1b[7m\x1b[8m\x1b[9m\x1b[53m\x1b[4:3m", + builder.writer.buffered(), + ); +} + +test "Style VT formatting combined colors and flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .rgb = .{ .r = 255, .g = 0, .b = 0 } }, + .bg_color = .{ .palette = 8 }, + .underline_color = .{ .rgb = .{ .r = 0, .g = 255, .b = 0 } }, + .flags = .{ .bold = true, .italic = true, .underline = .double }, + }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[1m\x1b[3m\x1b[4:2m\x1b[38;2;255;0;0m\x1b[48;5;8m\x1b[58;2;0;255;0m", + builder.writer.buffered(), + ); +} + +test "Style VT formatting all colors rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .rgb = .{ .r = 10, .g = 20, .b = 30 } }, + .bg_color = .{ .rgb = .{ .r = 40, .g = 50, .b = 60 } }, + .underline_color = .{ .rgb = .{ .r = 70, .g = 80, .b = 90 } }, + }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[38;2;10;20;30m\x1b[48;2;40;50;60m\x1b[58;2;70;80;90m", + builder.writer.buffered(), + ); +} + +test "Style VT formatting all colors palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .palette = 1 }, + .bg_color = .{ .palette = 2 }, + .underline_color = .{ .palette = 3 }, + }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[38;5;1m\x1b[48;5;2m\x1b[58;5;3m", + builder.writer.buffered(), + ); +} + test "Set basic usage" { const testing = std.testing; const alloc = testing.allocator; From d62235cb62005a16de614f90d764a7916655850b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 28 Oct 2025 11:23:16 -0700 Subject: [PATCH 215/702] terminal: Add `pin_map/point_map` to formatter (#9379) This adds the option `pin_map` or `point_map` (for pages) to formatter, allowing callers to get a byte-by-byte mapping for where on the screen each encoding maps to. This is used by search internals and hyperlinks. I haven't hooked that all up yet. This diff was big enough I wanted this as one. Tests significantly cover the new feature. Next up, we'll rip out `selectionString` and replace it with formatters! --- src/terminal/formatter.zig | 1500 ++++++++++++++++++++++++++++++++++-- 1 file changed, 1428 insertions(+), 72 deletions(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 9e6632dca..136ff80bb 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1,4 +1,6 @@ const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; const size = @import("size.zig"); const charsets = @import("charsets.zig"); const kitty = @import("kitty.zig"); @@ -6,8 +8,10 @@ const modespkg = @import("modes.zig"); const Screen = @import("Screen.zig"); const Terminal = @import("Terminal.zig"); const Cell = @import("page.zig").Cell; +const Coordinate = @import("point.zig").Coordinate; const Page = @import("page.zig").Page; const PageList = @import("PageList.zig"); +const Pin = PageList.Pin; const Row = @import("page.zig").Row; const Selection = @import("Selection.zig"); const Style = @import("style.zig").Style; @@ -46,10 +50,25 @@ pub const Options = struct { /// screen contents as it is rendered on the page in the given size. unwrap: bool = false, + /// Trim trailing whitespace on lines with other text. Trailing blank + /// lines are always trimmed. This only affects trailing whitespace + /// on rows that have at least one other cell with text. Whitespace + /// is currently only space characters (0x20). + trim: bool = true, + pub const plain: Options = .{ .emit = .plain }; pub const vt: Options = .{ .emit = .vt }; }; +/// Maps byte positions in formatted output to PageList pins. +/// +/// Used by formatters that operate on PageLists to track the source position +/// of each byte written. The caller is responsible for freeing the map. +pub const PinMap = struct { + alloc: Allocator, + map: *std.ArrayList(Pin), +}; + /// Terminal formatter formats the active terminal screen. /// /// This will always only emit data related to the currently active screen. @@ -78,6 +97,18 @@ pub const TerminalFormatter = struct { /// This information is ONLY emitted when the format is "vt". extra: Extra, + /// If non-null, then `map` will contain the Pin of every byte + /// byte written to the writer offset by the byte index. It is the + /// caller's responsibility to free the map. + /// + /// Note that some emitted bytes may not correspond to any Pin, such as + /// the extra data around terminal state (palette, modes, etc.). For these, + /// we'll map it to the most previous pin so there is some continuity but + /// its an arbitrary choice. + /// + /// Warning: there is a significant performance hit to track this + pin_map: ?PinMap, + pub const Extra = packed struct { /// Emit the palette using OSC 4 sequences. palette: bool, @@ -153,6 +184,7 @@ pub const TerminalFormatter = struct { .opts = opts, .content = .{ .selection = null }, .extra = .styles, + .pin_map = null, }; } @@ -170,6 +202,25 @@ pub const TerminalFormatter = struct { .{ i, rgb.r, rgb.g, rgb.b }, ); } + + // If we have a pin_map, add the bytes we wrote to map. + if (self.pin_map) |*m| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + var extra_formatter: TerminalFormatter = self; + extra_formatter.content = .none; + extra_formatter.pin_map = null; + extra_formatter.extra = .none; + extra_formatter.extra.palette = true; + try extra_formatter.format(&discarding.writer); + + // Map all those bytes to the same pin. Use the top left to ensure + // the node pointer is always properly initialized. + m.map.appendNTimes( + m.alloc, + self.terminal.screen.pages.getTopLeft(.screen), + discarding.count, + ) catch return error.WriteFailed; + } } // Emit terminal modes that differ from defaults. We probably have @@ -189,11 +240,31 @@ pub const TerminalFormatter = struct { try writer.print("\x1b[{s}{d}{s}", .{ prefix, tag.value, suffix }); } } + + // If we have a pin_map, add the bytes we wrote to map. + if (self.pin_map) |*m| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + var extra_formatter: TerminalFormatter = self; + extra_formatter.content = .none; + extra_formatter.pin_map = null; + extra_formatter.extra = .none; + extra_formatter.extra.modes = true; + try extra_formatter.format(&discarding.writer); + + // Map all those bytes to the same pin. Use the top left to ensure + // the node pointer is always properly initialized. + m.map.appendNTimes( + m.alloc, + self.terminal.screen.pages.getTopLeft(.screen), + discarding.count, + ) catch return error.WriteFailed; + } } var screen_formatter: ScreenFormatter = .init(&self.terminal.screen, self.opts); screen_formatter.content = self.content; screen_formatter.extra = self.extra.screen; + screen_formatter.pin_map = self.pin_map; try screen_formatter.format(writer); // Extra terminal state to emit after the screen contents so that @@ -245,6 +316,33 @@ pub const TerminalFormatter = struct { const pwd = self.terminal.pwd.items; if (pwd.len > 0) try writer.print("\x1b]7;{s}\x1b\\", .{pwd}); } + + // If we have a pin_map, add the bytes we wrote to map. + if (self.pin_map) |*m| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + var extra_formatter: TerminalFormatter = self; + extra_formatter.content = .none; + extra_formatter.pin_map = null; + extra_formatter.extra = .none; + extra_formatter.extra.scrolling_region = self.extra.scrolling_region; + extra_formatter.extra.tabstops = self.extra.tabstops; + extra_formatter.extra.keyboard = self.extra.keyboard; + extra_formatter.extra.pwd = self.extra.pwd; + try extra_formatter.format(&discarding.writer); + + m.map.appendNTimes( + m.alloc, + if (m.map.items.len > 0) pin: { + const last = m.map.items[m.map.items.len - 1]; + break :pin .{ + .node = last.node, + .x = last.x, + .y = last.y, + }; + } else self.terminal.screen.pages.getTopLeft(.screen), + discarding.count, + ) catch return error.WriteFailed; + } } } }; @@ -264,6 +362,18 @@ pub const ScreenFormatter = struct { /// This information is ONLY emitted when the format is "vt". extra: Extra, + /// If non-null, then `map` will contain the Pin of every byte + /// byte written to the writer offset by the byte index. It is the + /// caller's responsibility to free the map. + /// + /// Note that some emitted bytes may not correspond to any Pin, such as + /// the extra data around screen state. For these, we'll map it to the + /// most previous pin so there is some continuity but its an arbitrary + /// choice. + /// + /// Warning: there is a significant performance hit to track this + pin_map: ?PinMap, + pub const Content = union(enum) { /// Emit no content, only terminal state such as modes, palette, etc. /// via extra. @@ -326,6 +436,12 @@ pub const ScreenFormatter = struct { .kitty_keyboard = true, .charsets = true, }; + + fn isSet(self: Extra) bool { + const Int = @typeInfo(Extra).@"struct".backing_integer.?; + const v: Int = @bitCast(self); + return v != 0; + } }; pub fn init( @@ -337,6 +453,7 @@ pub const ScreenFormatter = struct { .opts = opts, .content = .{ .selection = null }, .extra = .none, + .pin_map = null, }; } @@ -350,6 +467,7 @@ pub const ScreenFormatter = struct { .selection => |selection_| { // Emit our pagelist contents according to our selection. var list_formatter: PageListFormatter = .init(&self.screen.pages, self.opts); + list_formatter.pin_map = self.pin_map; if (selection_) |sel| { list_formatter.top_left = sel.topLeft(self.screen); list_formatter.bottom_right = sel.bottomRight(self.screen); @@ -363,7 +481,7 @@ pub const ScreenFormatter = struct { // style are impacted by content rendering. switch (self.opts.emit) { .plain => return, - .vt => {}, + .vt => if (!self.extra.isSet()) return, } // Emit current SGR style state @@ -461,6 +579,37 @@ pub const ScreenFormatter = struct { // CUP is 1-indexed try writer.print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 }); } + + // If we have a pin_map, we need to count how many bytes the extras + // will emit so we can map them all to the same pin. We do this by + // formatting to a discarding writer with content=none. + if (self.pin_map) |*m| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + var extra_formatter: ScreenFormatter = self; + extra_formatter.content = .none; + extra_formatter.pin_map = null; + try extra_formatter.format(&discarding.writer); + + // Map all those bytes to the same pin. Use the first page node + // to ensure the node pointer is always properly initialized. + m.map.appendNTimes( + m.alloc, + if (m.map.items.len > 0) pin: { + // There is a weird Zig miscompilation here on 0.15.2. + // If I return the m.map.items value directly then we + // get undefined memory (even though we're copying a + // Pin struct). If we duplicate here like this we do + // not. + const last = m.map.items[m.map.items.len - 1]; + break :pin .{ + .node = last.node, + .x = last.x, + .y = last.y, + }; + } else self.screen.pages.getTopLeft(.screen), + discarding.count, + ) catch return error.WriteFailed; + } } }; @@ -477,6 +626,13 @@ pub const PageListFormatter = struct { top_left: ?PageList.Pin, bottom_right: ?PageList.Pin, + /// If non-null, then `map` will contain the Pin of every byte + /// byte written to the writer offset by the byte index. It is the + /// caller's responsibility to free the map. + /// + /// Warning: there is a significant performance hit to track this + pin_map: ?PinMap, + pub fn init( list: *const PageList, opts: Options, @@ -486,6 +642,7 @@ pub const PageListFormatter = struct { .opts = opts, .top_left = null, .bottom_right = null, + .pin_map = null, }; } @@ -496,6 +653,10 @@ pub const PageListFormatter = struct { const tl: PageList.Pin = self.top_left orelse self.list.getTopLeft(.screen); const br: PageList.Pin = self.bottom_right orelse self.list.getBottomRight(.screen).?; + // If we keep track of pins, we'll need this. + var point_map: std.ArrayList(Coordinate) = .empty; + defer if (self.pin_map) |*m| point_map.deinit(m.alloc); + var page_state: ?PageFormatter.TrailingState = null; var iter = tl.pageIterator(.right_down, br); while (iter.next()) |chunk| { @@ -511,7 +672,27 @@ pub const PageListFormatter = struct { if (chunk.node == br.node and formatter.end_y == br.y + 1) formatter.end_x = br.x + 1; + // If we're tracking pins, then we setup a point map for the + // page formatter (cause it can't track pins). And then we convert + // this to pins later. + if (self.pin_map) |*m| { + point_map.clearRetainingCapacity(); + formatter.point_map = .{ .alloc = m.alloc, .map = &point_map }; + } + page_state = try formatter.formatWithState(writer); + + // If we're tracking pins then grab our points and write them + // to our pin map. + if (self.pin_map) |*m| { + for (point_map.items) |coord| { + m.map.append(m.alloc, .{ + .node = chunk.node, + .x = coord.x, + .y = @intCast(coord.y), + }) catch return error.WriteFailed; + } + } } } }; @@ -540,6 +721,16 @@ pub const PageFormatter = struct { end_x: ?size.CellCountInt, end_y: ?size.CellCountInt, + /// If non-null, then `map` will contain the x/y coordinate of every + /// byte written to the writer offset by the byte index. It is the + /// caller's responsibility to free the map. + /// + /// Warning: there is a significant performance hit to track this + point_map: ?struct { + alloc: Allocator, + map: *std.ArrayList(Coordinate), + }, + /// The previous trailing state from the prior page. If you're iterating /// over multiple pages this helps ensure that unwrapping and other /// accounting works properly. @@ -565,6 +756,7 @@ pub const PageFormatter = struct { .start_y = 0, .end_x = null, .end_y = null, + .point_map = null, .trailing_state = null, }; } @@ -638,10 +830,40 @@ pub const PageFormatter = struct { continue; } - for (1..blank_rows + 1) |_| { - try writer.writeAll("\r\n"); + if (blank_rows > 0) { + for (0..blank_rows) |_| try writer.writeAll("\r\n"); + + // \r and \n map to the row that ends with this newline. + // If we're continuing (trailing state) then this will be + // in a prior page, so we just map to the first row of this + // page. + if (self.point_map) |*map| { + const start: Coordinate = if (map.map.items.len > 0) + map.map.items[map.map.items.len - 1] + else + .{ .x = 0, .y = 0 }; + + // The first one inherits the x value. + map.map.appendNTimes( + map.alloc, + .{ .x = start.x, .y = start.y }, + 2, // \r and \n + ) catch return error.WriteFailed; + + // All others have x = 0 since they reference their prior + // blank line. + for (1..blank_rows) |y_offset_usize| { + const y_offset: size.CellCountInt = @intCast(y_offset_usize); + map.map.appendNTimes( + map.alloc, + .{ .x = 0, .y = start.y + y_offset }, + 2, // \r and \n + ) catch return error.WriteFailed; + } + } + + blank_rows = 0; } - blank_rows = 0; // If we're not wrapped, we always add a newline so after // the row is printed we can add a newline. @@ -652,7 +874,9 @@ pub const PageFormatter = struct { if (!row.wrap_continuation or !self.opts.unwrap) blank_cells = 0; // Go through each cell and print it - for (cells_subset) |*cell| { + for (cells_subset, row_start_x..) |*cell, x_usize| { + const x: size.CellCountInt = @intCast(x_usize); + // Skip spacers. These happen naturally when wide characters // are printed again on the screen (for well-behaved terminals!) switch (cell.wide) { @@ -663,7 +887,11 @@ pub const PageFormatter = struct { // If we have a zero value, then we accumulate a counter. We // only want to turn zero values into spaces if we have a non-zero // char sometime later. - if (!cell.hasText() or cell.codepoint() == ' ') { + if (!cell.hasText()) { + blank_cells += 1; + continue; + } + if (cell.codepoint() == ' ' and self.opts.trim) { blank_cells += 1; continue; } @@ -672,6 +900,34 @@ pub const PageFormatter = struct { // then we want to emit them now. if (blank_cells > 0) { try writer.splatByteAll(' ', blank_cells); + + if (self.point_map) |*map| { + // Map each blank cell to its coordinate. Blank cells can span + // multiple rows if they carry over from wrap continuation. + var remaining_blanks = blank_cells; + var blank_x = x; + var blank_y = y; + while (remaining_blanks > 0) : (remaining_blanks -= 1) { + if (blank_x > 0) { + // We have space in this row + blank_x -= 1; + } else if (blank_y > 0) { + // Wrap to previous row + blank_y -= 1; + blank_x = self.page.size.cols - 1; + } else { + // Can't go back further, just use (0, 0) + blank_x = 0; + blank_y = 0; + } + + map.map.append( + map.alloc, + .{ .x = blank_x, .y = blank_y }, + ) catch return error.WriteFailed; + } + } + blank_cells = 0; } @@ -696,6 +952,17 @@ pub const PageFormatter = struct { // New style, emit it. style = cell_style.*; try writer.print("{f}", .{style.formatterVt()}); + + // If we have a point map, we map the style to + // this cell. + if (self.point_map) |*map| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + try discarding.writer.print("{f}", .{style.formatterVt()}); + for (0..discarding.count) |_| map.map.append(map.alloc, .{ + .x = x, + .y = y, + }) catch return error.WriteFailed; + } } try writer.print("{u}", .{cell.content.codepoint}); @@ -704,6 +971,23 @@ pub const PageFormatter = struct { try writer.print("{u}", .{cp}); } } + + // If we have a point map, all codepoints map to this + // cell. + if (self.point_map) |*map| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + try discarding.writer.print("{u}", .{cell.content.codepoint}); + if (comptime tag == .codepoint_grapheme) { + for (self.page.lookupGrapheme(cell).?) |cp| { + try writer.print("{u}", .{cp}); + } + } + + for (0..discarding.count) |_| map.map.append(map.alloc, .{ + .x = x, + .y = y, + }) catch return error.WriteFailed; + } }, // Unreachable since we do hasText() above @@ -743,13 +1027,26 @@ test "Page plain single line" { // Create the formatter const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .plain); + var formatter: PageFormatter = .init(page, .plain); + + // 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 const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("hello, world", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello, world", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 12), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); } test "Page plain multiline" { @@ -777,13 +1074,31 @@ test "Page plain multiline" { // Create the formatter const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .plain); + var formatter: PageFormatter = .init(page, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; // Verify output const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\r\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[7 + i], + ); } test "Page plain multi blank lines" { @@ -811,13 +1126,35 @@ test "Page plain multi blank lines" { // Create the formatter const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .plain); + var formatter: PageFormatter = .init(page, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; // Verify output const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("hello\r\n\r\n\r\nworld", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\r\n\r\n\r\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 3), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r after row 0 + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n after row 0 + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[7]); // \r after blank row 1 + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[8]); // \n after blank row 1 + try testing.expectEqual(Coordinate{ .x = 0, .y = 2 }, point_map.items[9]); // \r after blank row 2 + try testing.expectEqual(Coordinate{ .x = 0, .y = 2 }, point_map.items[10]); // \n after blank row 2 + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 3 }, + point_map.items[11 + i], + ); } test "Page plain trailing blank lines" { @@ -845,15 +1182,33 @@ test "Page plain trailing blank lines" { // Create the formatter const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .plain); + var formatter: PageFormatter = .init(page, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; // Verify output. We expect there to be no trailing newlines because // we can't differentiate trailing blank lines as being meaningful because // the page formatter can't see the cursor position. const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\r\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[7 + i], + ); } test "Page plain trailing whitespace" { @@ -881,15 +1236,90 @@ test "Page plain trailing whitespace" { // Create the formatter const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .plain); + var formatter: PageFormatter = .init(page, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; // Verify output. We expect there to be no trailing newlines because // we can't differentiate trailing blank lines as being meaningful because // the page formatter can't see the cursor position. const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\r\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[7 + i], + ); +} + +test "Page plain trailing whitespace no trim" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello \r\nworld "); + + // Verify we have only a single page + const pages = &t.screen.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, + .trim = false, + }); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output. We expect there to be no trailing newlines because + // we can't differentiate trailing blank lines as being meaningful because + // the page formatter can't see the cursor position. + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello \r\nworld ", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 7), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..8) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[8]); // \r + try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[9]); // \n + for (0..7) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[10 + i], + ); } test "Page plain with prior trailing state rows" { @@ -918,10 +1348,26 @@ test "Page plain with prior trailing state rows" { var formatter: PageFormatter = .init(page, .plain); formatter.trailing_state = .{ .rows = 2, .cells = 0 }; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("\r\n\r\nhello", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\r\n\r\nhello", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \r first blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[1]); // \n first blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[2]); // \r second blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[3]); // \n second blank row + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[4 + i], + ); } test "Page plain with prior trailing state cells no wrapped line" { @@ -950,11 +1396,23 @@ test "Page plain with prior trailing state cells no wrapped line" { var formatter: PageFormatter = .init(page, .plain); formatter.trailing_state = .{ .rows = 0, .cells = 3 }; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // Blank cells are reset when row is not a wrap continuation - try testing.expectEqualStrings("hello", builder.writer.buffered()); + try testing.expectEqualStrings("hello", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); } test "Page plain with prior trailing state cells with wrap continuation" { @@ -988,11 +1446,27 @@ test "Page plain with prior trailing state cells with wrap continuation" { var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); formatter.trailing_state = .{ .rows = 0, .cells = 3 }; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // Blank cells are preserved when row is a wrap continuation with unwrap enabled - try testing.expectEqualStrings(" world", builder.writer.buffered()); + try testing.expectEqualStrings(" world", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map - 3 spaces from prior trailing state + "world" + try testing.expectEqual(output.len, point_map.items.len); + // The 3 blank cells can't go back beyond (0,0) so they all map to (0,0) + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // space + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[1]); // space + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[2]); // space + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[3 + i], + ); } test "Page plain soft-wrapped without unwrap" { @@ -1018,13 +1492,31 @@ test "Page plain soft-wrapped without unwrap" { try testing.expect(pages.pages.first == pages.pages.last); const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .plain); + var formatter: PageFormatter = .init(page, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // Without unwrap, wrapped lines show as separate lines - try testing.expectEqualStrings("hello worl\r\nd test", builder.writer.buffered()); + try testing.expectEqualStrings("hello worl\r\nd test", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \r + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[11]); // \n + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[12 + i], + ); } test "Page plain soft-wrapped with unwrap" { @@ -1050,13 +1542,29 @@ test "Page plain soft-wrapped with unwrap" { try testing.expect(pages.pages.first == pages.pages.last); const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // With unwrap, wrapped lines are joined together - try testing.expectEqualStrings("hello world test", builder.writer.buffered()); + try testing.expectEqualStrings("hello world test", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[10 + i], + ); } test "Page plain soft-wrapped 3 lines without unwrap" { @@ -1082,13 +1590,37 @@ test "Page plain soft-wrapped 3 lines without unwrap" { try testing.expect(pages.pages.first == pages.pages.last); const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .plain); + var formatter: PageFormatter = .init(page, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // Without unwrap, wrapped lines show as separate lines - try testing.expectEqualStrings("hello worl\r\nd this is\r\na test", builder.writer.buffered()); + try testing.expectEqualStrings("hello worl\r\nd this is\r\na test", output); try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \r + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[11]); // \n + for (0..9) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[12 + i], + ); + try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[21]); // \r + try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[22]); // \n + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[23 + i], + ); } test "Page plain soft-wrapped 3 lines with unwrap" { @@ -1114,13 +1646,33 @@ test "Page plain soft-wrapped 3 lines with unwrap" { try testing.expect(pages.pages.first == pages.pages.last); const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // With unwrap, wrapped lines are joined together - try testing.expectEqualStrings("hello world this is a test", builder.writer.buffered()); + try testing.expectEqualStrings("hello world this is a test", output); try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); + + // Verify point map - unwrapped text spans 3 rows + try testing.expectEqual(output.len, point_map.items.len); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[10 + i], + ); + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[20 + i], + ); } test "Page plain start_y subset" { @@ -1147,10 +1699,28 @@ test "Page plain start_y subset" { var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("world\r\ntest", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("world\r\ntest", output); try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \r + try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[6]); // \n + for (0..4) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[7 + i], + ); } test "Page plain end_y subset" { @@ -1177,10 +1747,28 @@ test "Page plain end_y subset" { var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 2; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\r\nworld", output); try testing.expectEqual(@as(usize, 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[7 + i], + ); } test "Page plain start_y and end_y range" { @@ -1208,10 +1796,28 @@ test "Page plain start_y and end_y range" { formatter.start_y = 1; formatter.end_y = 3; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("world\r\ntest", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("world\r\ntest", output); try testing.expectEqual(@as(usize, 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \r + try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[6]); // \n + for (0..4) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[7 + i], + ); } test "Page plain start_y out of bounds" { @@ -1238,10 +1844,18 @@ test "Page plain start_y out of bounds" { var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 30; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("", output); try testing.expectEqual(@as(usize, 0), state.rows); try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map is empty + try testing.expectEqual(@as(usize, 0), point_map.items.len); } test "Page plain end_y greater than rows" { @@ -1268,11 +1882,23 @@ test "Page plain end_y greater than rows" { var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 30; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // Should clamp to page.size.rows and work normally - try testing.expectEqualStrings("hello", builder.writer.buffered()); + try testing.expectEqualStrings("hello", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); } test "Page plain end_y less than start_y" { @@ -1300,10 +1926,18 @@ test "Page plain end_y less than start_y" { formatter.start_y = 5; formatter.end_y = 2; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("", output); try testing.expectEqual(@as(usize, 0), state.rows); try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map is empty + try testing.expectEqual(@as(usize, 0), point_map.items.len); } test "Page plain start_x on first row only" { @@ -1330,10 +1964,22 @@ test "Page plain start_x on first row only" { var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("world", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("world", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 11), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 6), .y = 0 }, + point_map.items[i], + ); } test "Page plain end_x on last row only" { @@ -1361,11 +2007,35 @@ test "Page plain end_x on last row only" { formatter.end_y = 3; formatter.end_x = 6; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // First two rows: full width, last row: up to end_x=6 - try testing.expectEqualStrings("first line\r\nsecond line\r\nthird", builder.writer.buffered()); + try testing.expectEqualStrings("first line\r\nsecond line\r\nthird", output); try testing.expectEqual(@as(usize, 1), state.rows); try testing.expectEqual(@as(usize, 1), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..10) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \r + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[11]); // \n + for (0..11) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[12 + i], + ); + try testing.expectEqual(Coordinate{ .x = 10, .y = 1 }, point_map.items[23]); // \r + try testing.expectEqual(Coordinate{ .x = 10, .y = 1 }, point_map.items[24]); // \n + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[25 + i], + ); } test "Page plain start_x and end_x multiline" { @@ -1394,13 +2064,37 @@ test "Page plain start_x and end_x multiline" { formatter.end_y = 3; formatter.end_x = 4; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // First row: "world" (start_x=6 to end of row) // Second row: "test case" (full row) // Third row: "foo " (start to end_x=4) - try testing.expectEqualStrings("world\r\ntest case\r\nfoo", builder.writer.buffered()); + try testing.expectEqualStrings("world\r\ntest case\r\nfoo", output); try testing.expectEqual(@as(usize, 1), state.rows); try testing.expectEqual(@as(usize, 1), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 6), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[5]); // \r + try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[6]); // \n + for (0..9) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[7 + i], + ); + try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[16]); // \r + try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[17]); // \n + for (0..3) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 2 }, + point_map.items[18 + i], + ); } test "Page plain start_x out of bounds" { @@ -1427,10 +2121,18 @@ test "Page plain start_x out of bounds" { var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 100; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("", output); try testing.expectEqual(@as(usize, 0), state.rows); try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map is empty + try testing.expectEqual(@as(usize, 0), point_map.items.len); } test "Page plain end_x greater than cols" { @@ -1457,10 +2159,22 @@ test "Page plain end_x greater than cols" { var formatter: PageFormatter = .init(page, .plain); formatter.end_x = 100; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("hello", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); } test "Page plain end_x less than start_x single row" { @@ -1489,10 +2203,18 @@ test "Page plain end_x less than start_x single row" { formatter.end_y = 1; formatter.end_x = 5; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("", output); try testing.expectEqual(@as(usize, 0), state.rows); try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map is empty + try testing.expectEqual(@as(usize, 0), point_map.items.len); } test "Page plain start_y non-zero ignores trailing state" { @@ -1520,11 +2242,23 @@ test "Page plain start_y non-zero ignores trailing state" { formatter.start_y = 1; formatter.trailing_state = .{ .rows = 5, .cells = 10 }; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // Should NOT output the 5 newlines from trailing_state because start_y is non-zero - try testing.expectEqualStrings("world", builder.writer.buffered()); + try testing.expectEqualStrings("world", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 1 }, + point_map.items[i], + ); } test "Page plain start_x non-zero ignores trailing state" { @@ -1552,11 +2286,23 @@ test "Page plain start_x non-zero ignores trailing state" { formatter.start_x = 6; formatter.trailing_state = .{ .rows = 2, .cells = 8 }; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // Should NOT output the 2 newlines or 8 spaces from trailing_state because start_x is non-zero - try testing.expectEqualStrings("world", builder.writer.buffered()); + try testing.expectEqualStrings("world", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 11), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 6), .y = 0 }, + point_map.items[i], + ); } test "Page plain start_y and start_x zero uses trailing state" { @@ -1585,11 +2331,27 @@ test "Page plain start_y and start_x zero uses trailing state" { formatter.start_x = 0; formatter.trailing_state = .{ .rows = 2, .cells = 0 }; + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); // SHOULD output the 2 newlines from trailing_state because both start_y and start_x are 0 - try testing.expectEqualStrings("\r\n\r\nhello", builder.writer.buffered()); + try testing.expectEqualStrings("\r\n\r\nhello", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \r first blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[1]); // \n first blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[2]); // \r second blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[3]); // \n second blank row + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[4 + i], + ); } test "Page plain single line with styling" { @@ -1617,13 +2379,25 @@ test "Page plain single line with styling" { // Create the formatter const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .plain); + var formatter: PageFormatter = .init(page, .plain); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; // Verify output const state = try formatter.formatWithState(&builder.writer); - try testing.expectEqualStrings("hello, world", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello, world", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 12), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..12) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); } test "Page VT single line plain text" { @@ -1647,9 +2421,22 @@ test "Page VT single line plain text" { const pages = &t.screen.pages; const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .vt); + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + try formatter.format(&builder.writer); - try testing.expectEqualStrings("hello", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello", output); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); } test "Page VT single line with bold" { @@ -1673,9 +2460,29 @@ test "Page VT single line with bold" { const pages = &t.screen.pages; const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .vt); + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + try formatter.format(&builder.writer); - try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", output); + + // Verify point map - style sequences should point to first character they style + try testing.expectEqual(output.len, point_map.items.len); + // \x1b[0m = 4 bytes, \x1b[1m = 4 bytes, total 8 bytes of style sequences + // All style bytes should map to the first styled character at (0, 0) + for (0..8) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 0 }, + point_map.items[i], + ); + // Then "hello" maps to its respective positions + for (0..5) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[8 + i], + ); } test "Page VT multiple styles" { @@ -1699,9 +2506,18 @@ test "Page VT multiple styles" { const pages = &t.screen.pages; const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .vt); + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + try formatter.format(&builder.writer); - try testing.expectEqualStrings("\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld", output); + + // Verify point map matches output length + try testing.expectEqual(output.len, point_map.items.len); } test "Page VT with foreground color" { @@ -1725,9 +2541,29 @@ test "Page VT with foreground color" { const pages = &t.screen.pages; const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .vt); + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + try formatter.format(&builder.writer); - try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred", output); + + // Verify point map - style sequences should point to first character they style + try testing.expectEqual(output.len, point_map.items.len); + // \x1b[0m = 4 bytes, \x1b[38;5;1m = 9 bytes, total 13 bytes of style sequences + // All style bytes should map to the first styled character at (0, 0) + for (0..13) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 0 }, + point_map.items[i], + ); + // Then "red" maps to its respective positions + for (0..3) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[13 + i], + ); } test "Page VT multi-line with styles" { @@ -1751,9 +2587,18 @@ test "Page VT multi-line with styles" { const pages = &t.screen.pages; const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .vt); + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + try formatter.format(&builder.writer); - try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\r\n\x1b[0m\x1b[3msecond", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\r\n\x1b[0m\x1b[3msecond", output); + + // Verify point map matches output length + try testing.expectEqual(output.len, point_map.items.len); } test "Page VT duplicate style not emitted twice" { @@ -1777,9 +2622,18 @@ test "Page VT duplicate style not emitted twice" { const pages = &t.screen.pages; const page = &pages.pages.last.?.data; - const formatter: PageFormatter = .init(page, .vt); + var formatter: PageFormatter = .init(page, .vt); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + try formatter.format(&builder.writer); - try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", output); + + // Verify point map matches output length + try testing.expectEqual(output.len, point_map.items.len); } test "PageList plain single line" { @@ -1800,9 +2654,22 @@ test "PageList plain single line" { try s.nextSlice("hello, world"); - const formatter: PageListFormatter = .init(&t.screen.pages, .plain); - try builder.writer.print("{f}", .{formatter}); - try testing.expectEqualStrings("hello, world", builder.writer.buffered()); + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: PageListFormatter = .init(&t.screen.pages, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello, world", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + for (0..output.len) |i| try testing.expectEqual( + Pin{ .node = node, .x = @intCast(i), .y = 0 }, + pin_map.items[i], + ); } test "PageList plain spanning two pages" { @@ -1839,10 +2706,44 @@ test "PageList plain spanning two pages" { try s.nextSlice("page two"); // Format the entire PageList + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: PageListFormatter = .init(pages, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + const full_output = builder.writer.buffered(); + const output = std.mem.trimStart(u8, full_output, "\r\n"); try testing.expectEqualStrings("page one\r\npage two", output); + + // Verify pin map + try testing.expectEqual(full_output.len, pin_map.items.len); + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + const trimmed_count = full_output.len - output.len; + + // First part (trimmed blank lines) maps to first node + for (0..trimmed_count) |i| { + try testing.expectEqual(first_node, pin_map.items[i].node); + } + + // "page one" (8 chars) maps to first node + for (0..8) |i| { + const idx = trimmed_count + i; + try testing.expectEqual(first_node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + } + + // \r\n - these map to last node as they represent the transition to new page + try testing.expectEqual(last_node, pin_map.items[trimmed_count + 8].node); + try testing.expectEqual(last_node, pin_map.items[trimmed_count + 9].node); + + // "page two" (8 chars) maps to last node + for (0..8) |i| { + const idx = trimmed_count + 10 + i; + try testing.expectEqual(last_node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + } } test "PageList soft-wrapped line spanning two pages without unwrap" { @@ -1872,10 +2773,42 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { try testing.expect(pages.pages.first != pages.pages.last); // Format without unwrap - should show line breaks + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: PageListFormatter = .init(pages, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + const full_output = builder.writer.buffered(); + const output = std.mem.trimStart(u8, full_output, "\r\n"); try testing.expectEqualStrings("hello worl\r\nd test", output); + + // Verify pin map + try testing.expectEqual(full_output.len, pin_map.items.len); + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + const trimmed_count = full_output.len - output.len; + + // First part (trimmed blank lines) maps to first node + for (0..trimmed_count) |i| { + try testing.expectEqual(first_node, pin_map.items[i].node); + } + + // First line maps to first node + for (0..10) |i| { + const idx = trimmed_count + i; + try testing.expectEqual(first_node, pin_map.items[idx].node); + } + + // \r\n - these map to last node as they represent the transition to new page + try testing.expectEqual(last_node, pin_map.items[trimmed_count + 10].node); + try testing.expectEqual(last_node, pin_map.items[trimmed_count + 11].node); + + // "d test" (6 chars) maps to last node + for (0..6) |i| { + const idx = trimmed_count + 12 + i; + try testing.expectEqual(last_node, pin_map.items[idx].node); + } } test "PageList soft-wrapped line spanning two pages with unwrap" { @@ -1905,10 +2838,38 @@ test "PageList soft-wrapped line spanning two pages with unwrap" { try testing.expect(pages.pages.first != pages.pages.last); // Format with unwrap - should join the wrapped lines + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: PageListFormatter = .init(pages, .{ .emit = .plain, .unwrap = true }); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + const full_output = builder.writer.buffered(); + const output = std.mem.trimStart(u8, full_output, "\r\n"); try testing.expectEqualStrings("hello world test", output); + + // Verify pin map + try testing.expectEqual(full_output.len, pin_map.items.len); + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + const trimmed_count = full_output.len - output.len; + + // First part (trimmed blank lines) maps to first node + for (0..trimmed_count) |i| { + try testing.expectEqual(first_node, pin_map.items[i].node); + } + + // First line from first page + for (0..10) |i| { + const idx = trimmed_count + i; + try testing.expectEqual(first_node, pin_map.items[idx].node); + } + + // "d test" (6 chars) from last page + for (0..6) |i| { + const idx = trimmed_count + 10 + i; + try testing.expectEqual(last_node, pin_map.items[idx].node); + } } test "PageList VT spanning two pages" { @@ -1945,10 +2906,30 @@ test "PageList VT spanning two pages" { try s.nextSlice("page two"); // Format the entire PageList with VT + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: PageListFormatter = .init(pages, .vt); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + const full_output = builder.writer.buffered(); + const output = std.mem.trimStart(u8, full_output, "\r\n"); try testing.expectEqualStrings("\x1b[0m\x1b[1mpage one\r\n\x1b[0m\x1b[1mpage two", output); + + // Verify pin map + try testing.expectEqual(full_output.len, pin_map.items.len); + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + + // Just verify we have entries for both pages in the pin map + var first_count: usize = 0; + var last_count: usize = 0; + for (pin_map.items) |pin| { + if (pin.node == first_node) first_count += 1; + if (pin.node == last_node) last_count += 1; + } + try testing.expect(first_count > 0); + try testing.expect(last_count > 0); } test "PageList plain with x offset on single page" { @@ -1972,12 +2953,29 @@ test "PageList plain with x offset on single page" { const pages = &t.screen.pages; const node = pages.pages.first.?; + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: PageListFormatter = .init(pages, .plain); formatter.top_left = .{ .node = node, .y = 0, .x = 6 }; formatter.bottom_right = .{ .node = node, .y = 2, .x = 3 }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - try testing.expectEqualStrings("world\r\ntest case\r\nfoo", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("world\r\ntest case\r\nfoo", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + for (pin_map.items) |pin| { + try testing.expectEqual(node, pin.node); + } + + // "world" starts at x=6, y=0 + for (0..5) |i| { + try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + } } test "PageList plain with x offset spanning two pages" { @@ -2015,13 +3013,40 @@ test "PageList plain with x offset spanning two pages" { const first_node = pages.pages.first.?; const last_node = pages.pages.last.?; + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: PageListFormatter = .init(pages, .plain); formatter.top_left = .{ .node = first_node, .y = first_node.data.size.rows - 1, .x = 6 }; formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 3 }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + const full_output = builder.writer.buffered(); + const output = std.mem.trimStart(u8, full_output, "\r\n"); try testing.expectEqualStrings("world\r\nfoo", output); + + // Verify pin map + try testing.expectEqual(full_output.len, pin_map.items.len); + const trimmed_count = full_output.len - output.len; + + // "world" (5 chars) from first page + for (0..5) |i| { + const idx = trimmed_count + i; + try testing.expectEqual(first_node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[idx].x); + } + + // \r\n - these map to last node as they represent the transition to new page + try testing.expectEqual(last_node, pin_map.items[trimmed_count + 5].node); + try testing.expectEqual(last_node, pin_map.items[trimmed_count + 6].node); + + // "foo" (3 chars) from last page + for (0..3) |i| { + const idx = trimmed_count + 7 + i; + try testing.expectEqual(last_node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + } } test "PageList plain with start_x only" { @@ -2045,11 +3070,24 @@ test "PageList plain with start_x only" { const pages = &t.screen.pages; const node = pages.pages.first.?; + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: PageListFormatter = .init(pages, .plain); formatter.top_left = .{ .node = node, .y = 0, .x = 6 }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - try testing.expectEqualStrings("world", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("world", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + for (0..5) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + } } test "PageList plain with end_x only" { @@ -2073,11 +3111,37 @@ test "PageList plain with end_x only" { const pages = &t.screen.pages; const node = pages.pages.first.?; + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: PageListFormatter = .init(pages, .plain); formatter.bottom_right = .{ .node = node, .y = 1, .x = 2 }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - try testing.expectEqualStrings("hello world\r\ntes", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello world\r\ntes", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + + // "hello world" (11 chars) on y=0 + for (0..11) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + } + + // \r\n + try testing.expectEqual(node, pin_map.items[11].node); + try testing.expectEqual(node, pin_map.items[12].node); + + // "tes" (3 chars) on y=1 + for (0..3) |i| { + try testing.expectEqual(node, pin_map.items[13 + i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[13 + i].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[13 + i].y); + } } test "TerminalFormatter plain no selection" { @@ -2178,6 +3242,173 @@ test "TerminalFormatter with selection" { try testing.expectEqualStrings("line2", builder.writer.buffered()); } +test "TerminalFormatter plain with pin_map" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: TerminalFormatter = .init(&t, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello, world", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + for (0..output.len) |i| try testing.expectEqual( + Pin{ .node = node, .x = @intCast(i), .y = 0 }, + pin_map.items[i], + ); +} + +test "TerminalFormatter plain multiline with pin_map" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: TerminalFormatter = .init(&t, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\r\nworld", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + // "hello" (5 chars) + for (0..5) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + } + // "\r\n" maps to end of first line + try testing.expectEqual(node, pin_map.items[5].node); + try testing.expectEqual(node, pin_map.items[6].node); + // "world" (5 chars) + for (0..5) |i| { + const idx = 7 + i; + try testing.expectEqual(node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y); + } +} + +test "TerminalFormatter vt with palette and pin_map" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Modify some palette colors using VT sequences + try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + try s.nextSlice("test"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Verify pin map - palette bytes should be mapped to top left + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } +} + +test "TerminalFormatter with selection and pin_map" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("line1\r\nline2\r\nline3"); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: TerminalFormatter = .init(&t, .plain); + formatter.content = .{ .selection = .init( + t.screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + false, + ) }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("line2", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + // "line2" (5 chars) from row 1 + for (0..5) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[i].y); + } +} + test "Screen plain single line" { const testing = std.testing; const alloc = testing.allocator; @@ -2196,10 +3427,23 @@ test "Screen plain single line" { try s.nextSlice("hello, world"); - const formatter: ScreenFormatter = .init(&t.screen, .plain); + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(&t.screen, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - try testing.expectEqualStrings("hello, world", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello, world", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + for (0..output.len) |i| try testing.expectEqual( + Pin{ .node = node, .x = @intCast(i), .y = 0 }, + pin_map.items[i], + ); } test "Screen plain multiline" { @@ -2220,10 +3464,35 @@ test "Screen plain multiline" { try s.nextSlice("hello\r\nworld"); - const formatter: ScreenFormatter = .init(&t.screen, .plain); + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(&t.screen, .plain); + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello\r\nworld", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + // "hello" (5 chars) + for (0..5) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); + } + // "\r\n" maps to end of first line + try testing.expectEqual(node, pin_map.items[5].node); + try testing.expectEqual(node, pin_map.items[6].node); + // "world" (5 chars) + for (0..5) |i| { + const idx = 7 + i; + try testing.expectEqual(node, pin_map.items[idx].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y); + } } test "Screen plain with selection" { @@ -2244,15 +3513,30 @@ test "Screen plain with selection" { try s.nextSlice("line1\r\nline2\r\nline3"); + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: ScreenFormatter = .init(&t.screen, .plain); formatter.content = .{ .selection = .init( t.screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, t.screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, false, ) }; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); - try testing.expectEqualStrings("line2", builder.writer.buffered()); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("line2", output); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + // "line2" (5 chars) from row 1 + for (0..5) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[i].y); + } } test "Screen vt with cursor position" { @@ -2274,8 +3558,12 @@ test "Screen vt with cursor position" { // Position cursor at a specific location try s.nextSlice("hello\r\nworld"); + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: ScreenFormatter = .init(&t.screen, .vt); formatter.extra.cursor = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); @@ -2295,6 +3583,19 @@ test "Screen vt with cursor position" { // Verify cursor positions match try testing.expectEqual(t.screen.cursor.x, t2.screen.cursor.x); try testing.expectEqual(t.screen.cursor.y, t2.screen.cursor.y); + + // Verify pin map - the extras should be mapped to the last pin + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + const content_len = "hello\r\nworld".len; + // Content bytes map to their positions + for (0..content_len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } + // Extra bytes (cursor position) map to last content pin + for (content_len..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } } test "Screen vt with style" { @@ -2316,8 +3617,12 @@ test "Screen vt with style" { // Set some style attributes try s.nextSlice("\x1b[1;31mhello"); + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: ScreenFormatter = .init(&t.screen, .vt); formatter.extra.style = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); @@ -2336,6 +3641,13 @@ test "Screen vt with style" { // Verify styles match try testing.expect(t.screen.cursor.style.eql(t2.screen.cursor.style)); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } } test "Screen vt with hyperlink" { @@ -2357,8 +3669,12 @@ test "Screen vt with hyperlink" { // Set a hyperlink try s.nextSlice("\x1b]8;;http://example.com\x1b\\hello"); + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: ScreenFormatter = .init(&t.screen, .vt); formatter.extra.hyperlink = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); @@ -2385,6 +3701,13 @@ test "Screen vt with hyperlink" { const link2 = t2.screen.cursor.hyperlink.?; try testing.expectEqualStrings(link1.uri, link2.uri); } + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } } test "Screen vt with protection" { @@ -2406,8 +3729,12 @@ test "Screen vt with protection" { // Enable protection mode try s.nextSlice("\x1b[1\"qhello"); + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: ScreenFormatter = .init(&t.screen, .vt); formatter.extra.protection = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); @@ -2426,6 +3753,13 @@ test "Screen vt with protection" { // Verify protection state matches try testing.expectEqual(t.screen.cursor.protected, t2.screen.cursor.protected); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } } test "Screen vt with kitty keyboard" { @@ -2447,8 +3781,12 @@ test "Screen vt with kitty keyboard" { // Set kitty keyboard flags (disambiguate + report_events = 3) try s.nextSlice("\x1b[=3;1uhello"); + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: ScreenFormatter = .init(&t.screen, .vt); formatter.extra.kitty_keyboard = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); @@ -2469,6 +3807,13 @@ test "Screen vt with kitty keyboard" { const flags1 = t.screen.kitty_keyboard.current().int(); const flags2 = t2.screen.kitty_keyboard.current().int(); try testing.expectEqual(flags1, flags2); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } } test "Screen vt with charsets" { @@ -2490,8 +3835,12 @@ test "Screen vt with charsets" { // Set G0 to DEC special and shift to G1 try s.nextSlice("\x1b(0\x0ehello"); + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + var formatter: ScreenFormatter = .init(&t.screen, .vt); formatter.extra.charsets = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); @@ -2515,6 +3864,13 @@ test "Screen vt with charsets" { t.screen.charset.charsets.get(.G0), t2.screen.charset.charsets.get(.G0), ); + + // Verify pin map + try testing.expectEqual(output.len, pin_map.items.len); + const node = t.screen.pages.pages.first.?; + for (0..output.len) |i| { + try testing.expectEqual(node, pin_map.items[i].node); + } } test "Terminal vt with scrolling region" { From 028ce83d46576ff43968ddcbdd76096f5d0bc7a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 29 Oct 2025 10:16:38 -0700 Subject: [PATCH 216/702] terminal: selectionString now uses ScreenFormatter (#9391) This replaces the logic of Screen.selectionString with calls to ScreenFormatter. This means that all our various selection-based features like copying to clipboards now uses the new formatter. The formatter code is now user-facing. This forced us to pass all selectionString tests which revealed some edge cases that were not handled correctly before in the formatter! The formatter now handles: - Plain text now emits `\n` instead of `\r\n`. VT emits `\r\n` - Rectangular selections - Various wide character edge cases - Selection is now inclusive on the end, not exclusive --- src/config/Config.zig | 4 +- src/terminal/Screen.zig | 181 ++------- src/terminal/formatter.zig | 735 +++++++++++++++++++++++++++++-------- 3 files changed, 625 insertions(+), 295 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 8ba1e47db..a9aaf8f86 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2006,7 +2006,9 @@ keybind: Keybinds = .{}, @"clipboard-write": ClipboardAccess = .allow, /// Trims trailing whitespace on data that is copied to the clipboard. This does -/// not affect data sent to the clipboard via `clipboard-write`. +/// not affect data sent to the clipboard via `clipboard-write`. This only +/// applies to trailing whitespace on lines that have other characters. +/// Completely blank lines always have their whitespace trimmed. @"clipboard-trim-trailing-spaces": bool = true, /// Require confirmation before pasting text that appears unsafe. This helps diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 81d6d4ab6..486c4f384 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -13,6 +13,7 @@ const unicode = @import("../unicode/main.zig"); const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); const StringMap = @import("StringMap.zig"); +const ScreenFormatter = @import("formatter.zig").ScreenFormatter; const pagepkg = @import("page.zig"); const point = @import("point.zig"); const size = @import("size.zig"); @@ -2170,163 +2171,51 @@ pub const SelectionString = struct { /// Returns the raw text associated with a selection. This will unwrap /// soft-wrapped edges. The returned slice is owned by the caller and allocated /// using alloc, not the allocator associated with the screen (unless they match). +/// +/// For more flexibility, use a ScreenFormatter directly. pub fn selectionString( self: *Screen, alloc: Allocator, opts: SelectionString, ) ![:0]const u8 { - // Use an ArrayList so that we can grow the array as we go. We - // build an initial capacity of just our rows in our selection times - // columns. It can be more or less based on graphemes, newlines, etc. - var strbuilder: std.ArrayList(u8) = .empty; - defer strbuilder.deinit(alloc); + // We'll use this as our buffer to build our string. + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); - // If we're building a stringmap, create our builder for the pins. - const MapBuilder = std.ArrayList(Pin); - var mapbuilder: ?MapBuilder = if (opts.map != null) .empty else null; - defer if (mapbuilder) |*b| b.deinit(alloc); + // Create a formatter and use that to emit our text. + var formatter: ScreenFormatter = .init( + self, + .{ + .emit = .plain, + .unwrap = true, + .trim = opts.trim, + }, + ); + formatter.content = .{ .selection = opts.sel }; - const sel_ordered = opts.sel.ordered(self, .forward); - const sel_start: Pin = start: { - var start: Pin = sel_ordered.start(); - const cell = start.rowAndCell().cell; - if (cell.wide == .spacer_tail) start.x -= 1; - break :start start; - }; - const sel_end: Pin = end: { - var end: Pin = sel_ordered.end(); - const cell = end.rowAndCell().cell; - switch (cell.wide) { - .narrow, .wide => {}, - - // We can omit the tail - .spacer_tail => end.x -= 1, - - // With the head we want to include the wrapped wide character. - .spacer_head => if (end.down(1)) |p| { - end = p; - end.x = 0; - }, - } - break :end end; + // If we have a string map, we need to set that up. + var pins: std.ArrayList(Pin) = .empty; + defer pins.deinit(alloc); + if (opts.map != null) formatter.pin_map = .{ + .alloc = alloc, + .map = &pins, }; - var page_it = sel_start.pageIterator(.right_down, sel_end); - while (page_it.next()) |chunk| { - const rows = chunk.rows(); - for (rows, chunk.start.., 0..) |row, y, row_i| { - const cells_ptr = row.cells.ptr(chunk.node.data.memory); + // Emit + try formatter.format(&aw.writer); - const start_x = if ((row_i == 0 or sel_ordered.rectangle) and - sel_start.node == chunk.node) - sel_start.x - else - 0; - const end_x = if ((row_i == rows.len - 1 or sel_ordered.rectangle) and - sel_end.node == chunk.node) - sel_end.x + 1 - else - self.pages.cols; - - const cells = cells_ptr[start_x..end_x]; - for (cells, start_x..) |*cell, x| { - // Skip wide spacers - switch (cell.wide) { - .narrow, .wide => {}, - .spacer_head, .spacer_tail => continue, - } - - var buf: [4]u8 = undefined; - { - const raw: u21 = if (cell.hasText()) cell.content.codepoint else 0; - const char = if (raw > 0) raw else ' '; - const encode_len = try std.unicode.utf8Encode(char, &buf); - try strbuilder.appendSlice(alloc, buf[0..encode_len]); - if (mapbuilder) |*b| { - for (0..encode_len) |_| try b.append(alloc, .{ - .node = chunk.node, - .y = @intCast(y), - .x = @intCast(x), - }); - } - } - if (cell.hasGrapheme()) { - const cps = chunk.node.data.lookupGrapheme(cell).?; - for (cps) |cp| { - const encode_len = try std.unicode.utf8Encode(cp, &buf); - try strbuilder.appendSlice(alloc, buf[0..encode_len]); - if (mapbuilder) |*b| { - for (0..encode_len) |_| try b.append(alloc, .{ - .node = chunk.node, - .y = @intCast(y), - .x = @intCast(x), - }); - } - } - } - } - - const is_final_row = chunk.node == sel_end.node and y == sel_end.y; - - if (!is_final_row and - (!row.wrap or sel_ordered.rectangle)) - { - try strbuilder.append(alloc, '\n'); - if (mapbuilder) |*b| try b.append(alloc, .{ - .node = chunk.node, - .y = @intCast(y), - .x = chunk.node.data.size.cols - 1, - }); - } - } + // Build our final text and if we have a string map set that up. + const text = try aw.toOwnedSliceSentinel(0); + errdefer alloc.free(text); + if (opts.map) |map| { + map.* = .{ + .string = try alloc.dupeZ(u8, text), + .map = try pins.toOwnedSlice(alloc), + }; } + errdefer if (opts.map) |m| m.deinit(alloc); - if (comptime std.debug.runtime_safety) { - if (mapbuilder) |b| assert(strbuilder.items.len == b.items.len); - } - - // If we have a mapbuilder, we need to setup our string map. - if (mapbuilder) |*b| { - var strclone = try strbuilder.clone(alloc); - defer strclone.deinit(alloc); - const str = try strclone.toOwnedSliceSentinel(alloc, 0); - errdefer alloc.free(str); - const map = try b.toOwnedSlice(alloc); - errdefer alloc.free(map); - opts.map.?.* = .{ .string = str, .map = map }; - } - - // Remove any trailing spaces on lines. We could do optimize this by - // doing this in the loop above but this isn't very hot path code and - // this is simple. - if (opts.trim) { - var it = std.mem.tokenizeScalar(u8, strbuilder.items, '\n'); - - // Reset our items. We retain our capacity. Because we're only - // removing bytes, we know that the trimmed string must be no longer - // than the original string so we copy directly back into our - // allocated memory. - strbuilder.clearRetainingCapacity(); - while (it.next()) |line| { - const trimmed = std.mem.trimRight(u8, line, " \t"); - const i = strbuilder.items.len; - strbuilder.items.len += trimmed.len; - std.mem.copyForwards(u8, strbuilder.items[i..], trimmed); - try strbuilder.append(alloc, '\n'); - } - - // Remove all trailing newlines - for (0..strbuilder.items.len) |_| { - if (strbuilder.items[strbuilder.items.len - 1] != '\n') break; - strbuilder.items.len -= 1; - } - } - - // Get our final string - const string = try strbuilder.toOwnedSliceSentinel(alloc, 0); - errdefer alloc.free(string); - - return string; + return text; } pub const SelectLine = struct { @@ -8384,7 +8273,7 @@ test "Screen: selectionString trim empty line" { .trim = false, }); defer alloc.free(contents); - const expected = "1AB \n \n2EF"; + const expected = "1AB \n\n2EF"; try testing.expectEqualStrings(expected, contents); } } diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 136ff80bb..cd4b76340 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -16,12 +16,9 @@ const Row = @import("page.zig").Row; const Selection = @import("Selection.zig"); const Style = @import("style.zig").Style; -// TODO: -// - Rectangular selection - /// Formats available. pub const Format = enum { - /// Plain text + /// Plain text. plain, /// Include VT sequences to preserve colors, styles, URLs, etc. @@ -31,6 +28,9 @@ pub const Format = enum { /// vary based on the formatter and you should see the docs. For example, /// PageFormatter with VT will emit SGR sequences with palette indices, /// not the color itself. + /// + /// For VT, newlines will be emitted as `\r\n` so that the cursor properly + /// moves back to the beginning prior emitting follow-up lines. vt, pub fn styled(self: Format) bool { @@ -380,6 +380,7 @@ pub const ScreenFormatter = struct { none, /// Emit the content specified by the selection. Null for all. + /// The selection is inclusive on both ends. selection: ?Selection, }; @@ -471,6 +472,7 @@ pub const ScreenFormatter = struct { if (selection_) |sel| { list_formatter.top_left = sel.topLeft(self.screen); list_formatter.bottom_right = sel.bottomRight(self.screen); + list_formatter.rectangle = sel.rectangle; } try list_formatter.format(writer); }, @@ -626,6 +628,10 @@ pub const PageListFormatter = struct { top_left: ?PageList.Pin, bottom_right: ?PageList.Pin, + /// If true, the boundaries define a rectangle selection where start_x + /// and end_x apply to every row, not just the first and last. + rectangle: bool, + /// If non-null, then `map` will contain the Pin of every byte /// byte written to the writer offset by the byte index. It is the /// caller's responsibility to free the map. @@ -642,6 +648,7 @@ pub const PageListFormatter = struct { .opts = opts, .top_left = null, .bottom_right = null, + .rectangle = false, .pin_map = null, }; } @@ -660,17 +667,24 @@ pub const PageListFormatter = struct { var page_state: ?PageFormatter.TrailingState = null; var iter = tl.pageIterator(.right_down, br); while (iter.next()) |chunk| { + assert(chunk.start < chunk.end); + assert(chunk.end > 0); + var formatter: PageFormatter = .init(&chunk.node.data, self.opts); formatter.start_y = chunk.start; - formatter.end_y = chunk.end; + formatter.end_y = chunk.end - 1; formatter.trailing_state = page_state; + formatter.rectangle = self.rectangle; - // Apply start_x if this is the first chunk - if (chunk.node == tl.node) formatter.start_x = tl.x; - - // Apply end_x if this is the last chunk and it ends at br.y - if (chunk.node == br.node and - formatter.end_y == br.y + 1) formatter.end_x = br.x + 1; + // For rectangle selection, apply start_x and end_x to all chunks + if (self.rectangle) { + formatter.start_x = tl.x; + formatter.end_x = br.x; + } else { + // Otherwise only on the first/last, respectively. + if (chunk.node == tl.node) formatter.start_x = tl.x; + if (chunk.node == br.node) formatter.end_x = br.x; + } // If we're tracking pins, then we setup a point map for the // page formatter (cause it can't track pins). And then we convert @@ -714,13 +728,25 @@ pub const PageFormatter = struct { /// then it will be the full width. If end y is not given then it will be /// the full height. /// + /// The start and end are both inclusive, so equal values will still + /// return a non-empty result (i.e. a single cell or row). + /// /// The start x is considered the X in the first row and end X is /// X in the final row. This isn't a rectangle selection by default. + /// + /// If start X falls on the second column of a wide character, then + /// the entire character will be included (as if you specified the + /// previous column). start_x: size.CellCountInt, start_y: size.CellCountInt, end_x: ?size.CellCountInt, end_y: ?size.CellCountInt, + /// If true, the start x/y and end x/y define a rectangle selection. + /// In this case, the boundaries will apply to every row, not just + /// the first and last. + rectangle: bool, + /// If non-null, then `map` will contain the x/y coordinate of every /// byte written to the writer offset by the byte index. It is the /// caller's responsibility to free the map. @@ -756,6 +782,7 @@ pub const PageFormatter = struct { .start_y = 0, .end_x = null, .end_y = null, + .rectangle = false, .point_map = null, .trailing_state = null, }; @@ -785,29 +812,52 @@ pub const PageFormatter = struct { } } - // Setup our starting row and perform some validation for overflows. - const start_y: size.CellCountInt = self.start_y; - if (start_y >= self.page.size.rows) return .{ .rows = blank_rows, .cells = blank_cells }; - const end_y_unclamped: size.CellCountInt = self.end_y orelse self.page.size.rows; - if (start_y >= end_y_unclamped) return .{ .rows = blank_rows, .cells = blank_cells }; - const end_y = @min(end_y_unclamped, self.page.size.rows); - // Setup our starting column and perform some validation for overflows. // Note: start_x only applies to the first row, end_x only applies to the last row. const start_x: size.CellCountInt = self.start_x; if (start_x >= self.page.size.cols) return .{ .rows = blank_rows, .cells = blank_cells }; - const end_x_unclamped: size.CellCountInt = self.end_x orelse self.page.size.cols; - const end_x = @min(end_x_unclamped, self.page.size.cols); + const end_x_unclamped: size.CellCountInt = self.end_x orelse self.page.size.cols - 1; + var end_x = @min(end_x_unclamped, self.page.size.cols - 1); - // If we only have a single row, validate that start_x < end_x - if (start_y + 1 == end_y and start_x >= end_x) { + // Setup our starting row and perform some validation for overflows. + const start_y: size.CellCountInt = self.start_y; + if (start_y >= self.page.size.rows) return .{ .rows = blank_rows, .cells = blank_cells }; + const end_y_unclamped: size.CellCountInt = self.end_y orelse self.page.size.rows - 1; + if (start_y > end_y_unclamped) return .{ .rows = blank_rows, .cells = blank_cells }; + var end_y = @min(end_y_unclamped, self.page.size.rows - 1); + + // Edge case: if our end x/y falls on a spacer head AND we're unwrapping, + // then we move the x/y to the start of the next row (if available). + if (self.opts.unwrap and !self.rectangle) { + const final_row = self.page.getRow(end_y); + const cells = self.page.getCells(final_row); + switch (cells[end_x].wide) { + .spacer_head => { + // Move to next row if available + // + // TODO: if unavailable, we should add to our trailing state + // + // so the pagelist formatter can be aware and maybe add + // another page + if (end_y < self.page.size.rows - 1) { + end_y += 1; + end_x = 0; + } + }, + + else => {}, + } + } + + // If we only have a single row, validate that start_x <= end_x + if (start_y == end_y and start_x > end_x) { return .{ .rows = blank_rows, .cells = blank_cells }; } // Our style for non-plain formats var style: Style = .{}; - for (start_y..end_y) |y_usize| { + for (start_y..end_y + 1) |y_usize| { const y: size.CellCountInt = @intCast(y_usize); const row: *Row = self.page.getRow(y); const cells: []const Cell = self.page.getCells(row); @@ -816,11 +866,33 @@ pub const PageFormatter = struct { // - First row: start_x to end of row (or end_x if single row) // - Last row: start of row to end_x // - Middle rows: full width - const is_first_row = (y == start_y); - const is_last_row = (y == end_y - 1); - const row_start_x: size.CellCountInt = if (is_first_row) start_x else 0; - const row_end_x: size.CellCountInt = if (is_last_row) end_x else self.page.size.cols; - const cells_subset = cells[row_start_x..row_end_x]; + const cells_subset, const row_start_x = cells_subset: { + // The end is always straightforward + const row_end_x: size.CellCountInt = if (self.rectangle or y == end_y) + end_x + 1 + else + self.page.size.cols; + + // The first we have to check if our start X falls on the + // tail of a wide character. + const row_start_x: size.CellCountInt = if (start_x > 0 and + (self.rectangle or y == start_y)) + start_x: { + break :start_x switch (cells[start_x].wide) { + // Include the prior cell to get the full wide char + .spacer_tail => start_x - 1, + + // If we're a spacer head on our first row then we + // skip this whole row. + .spacer_head => continue, + + .narrow, .wide => start_x, + }; + } else 0; + + const subset = cells[row_start_x..row_end_x]; + break :cells_subset .{ subset, row_start_x }; + }; // If this row is blank, accumulate to avoid a bunch of extra // work later. If it isn't blank, make sure we dump all our @@ -831,7 +903,12 @@ pub const PageFormatter = struct { } if (blank_rows > 0) { - for (0..blank_rows) |_| try writer.writeAll("\r\n"); + const sequence: []const u8 = switch (self.opts.emit) { + .plain => "\n", + .vt => "\r\n", + }; + + for (0..blank_rows) |_| try writer.writeAll(sequence); // \r and \n map to the row that ends with this newline. // If we're continuing (trailing state) then this will be @@ -847,7 +924,7 @@ pub const PageFormatter = struct { map.map.appendNTimes( map.alloc, .{ .x = start.x, .y = start.y }, - 2, // \r and \n + sequence.len, ) catch return error.WriteFailed; // All others have x = 0 since they reference their prior @@ -857,7 +934,7 @@ pub const PageFormatter = struct { map.map.appendNTimes( map.alloc, .{ .x = 0, .y = start.y + y_offset }, - 2, // \r and \n + sequence.len, ) catch return error.WriteFailed; } } @@ -1049,6 +1126,214 @@ test "Page plain single line" { ); } +test "Page plain single wide char" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("1A⚡"); + + // Verify we have only a single page + const pages = &t.screen.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, .plain); + + // Test our point map. + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Full string + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("1A⚡", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (2..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[i], + ); + } + + // Wide only (from start) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_x = 2; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[i], + ); + } + + // Wide only (from tail) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_x = 3; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 2, .y = 0 }, + point_map.items[i], + ); + } +} + +test "Page plain single wide char 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 = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("1A⚡"); + + // Verify we have only a single page + const pages = &t.screen.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, .plain); + formatter.opts.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 }; + + // Full string + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("1A⚡", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (2..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } + + // Full string (ending on spacer head) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.end_x = 2; + formatter.end_y = 0; + defer { + formatter.end_x = null; + formatter.end_y = null; + } + + _ = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("1A⚡", output); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (2..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } + + // Wide only (from start) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_x = 2; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } + + // Wide only (from tail) + { + builder.clearRetainingCapacity(); + point_map.clearRetainingCapacity(); + + formatter.start_y = 1; + formatter.start_x = 1; + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("⚡", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells); + + // Verify our point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..output.len) |i| try testing.expectEqual( + Coordinate{ .x = 0, .y = 1 }, + point_map.items[i], + ); + } +} + test "Page plain multiline" { const testing = std.testing; const alloc = testing.allocator; @@ -1083,7 +1368,7 @@ test "Page plain multiline" { // Verify output const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); @@ -1093,11 +1378,64 @@ test "Page plain multiline" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[7 + i], + point_map.items[6 + i], + ); +} + +test "Page plain multiline rectangle" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + // Verify we have only a single page + const pages = &t.screen.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, .plain); + formatter.start_x = 1; + formatter.end_x = 3; + formatter.rectangle = true; + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("ell\norl", output); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + for (0..3) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 1), .y = 0 }, + point_map.items[i], + ); + try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // \n + for (0..3) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i + 1), .y = 1 }, + point_map.items[4 + i], ); } @@ -1135,7 +1473,7 @@ test "Page plain multi blank lines" { // Verify output const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\n\r\n\r\nworld", output); + try testing.expectEqualStrings("hello\n\n\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 3), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); @@ -1145,15 +1483,12 @@ test "Page plain multi blank lines" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r after row 0 - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n after row 0 - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[7]); // \r after blank row 1 - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[8]); // \n after blank row 1 - try testing.expectEqual(Coordinate{ .x = 0, .y = 2 }, point_map.items[9]); // \r after blank row 2 - try testing.expectEqual(Coordinate{ .x = 0, .y = 2 }, point_map.items[10]); // \n after blank row 2 + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n after row 0 + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[6]); // \n after blank row 1 + try testing.expectEqual(Coordinate{ .x = 0, .y = 2 }, point_map.items[7]); // \n after blank row 2 for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 3 }, - point_map.items[11 + i], + point_map.items[8 + i], ); } @@ -1193,7 +1528,7 @@ test "Page plain trailing blank lines" { // the page formatter can't see the cursor position. const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); @@ -1203,11 +1538,10 @@ test "Page plain trailing blank lines" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[7 + i], + point_map.items[6 + i], ); } @@ -1247,7 +1581,7 @@ test "Page plain trailing whitespace" { // the page formatter can't see the cursor position. const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); @@ -1257,11 +1591,10 @@ test "Page plain trailing whitespace" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[7 + i], + point_map.items[6 + i], ); } @@ -1304,7 +1637,7 @@ test "Page plain trailing whitespace no trim" { // the page formatter can't see the cursor position. const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello \r\nworld ", output); + try testing.expectEqualStrings("hello \nworld ", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 7), state.cells); @@ -1314,11 +1647,10 @@ test "Page plain trailing whitespace no trim" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[8]); // \r - try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[9]); // \n + try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[8]); // \n for (0..7) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[10 + i], + point_map.items[9 + i], ); } @@ -1354,19 +1686,17 @@ test "Page plain with prior trailing state rows" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\r\n\r\nhello", output); + try testing.expectEqualStrings("\n\nhello", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); // Verify point map try testing.expectEqual(output.len, point_map.items.len); - try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \r first blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[1]); // \n first blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[2]); // \r second blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[3]); // \n second blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \n first blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[1]); // \n second blank row for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 0 }, - point_map.items[4 + i], + point_map.items[2 + i], ); } @@ -1501,7 +1831,7 @@ test "Page plain soft-wrapped without unwrap" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); // Without unwrap, wrapped lines show as separate lines - try testing.expectEqualStrings("hello worl\r\nd test", output); + try testing.expectEqualStrings("hello worl\nd test", output); try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); @@ -1511,11 +1841,10 @@ test "Page plain soft-wrapped without unwrap" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \r - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[11]); // \n + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n for (0..6) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[12 + i], + point_map.items[11 + i], ); } @@ -1599,7 +1928,7 @@ test "Page plain soft-wrapped 3 lines without unwrap" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); // Without unwrap, wrapped lines show as separate lines - try testing.expectEqualStrings("hello worl\r\nd this is\r\na test", output); + try testing.expectEqualStrings("hello worl\nd this is\na test", output); try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); @@ -1609,17 +1938,15 @@ test "Page plain soft-wrapped 3 lines without unwrap" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \r - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[11]); // \n + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n for (0..9) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[12 + i], + point_map.items[11 + i], ); - try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[21]); // \r - try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[22]); // \n + try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[20]); // \n for (0..6) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 2 }, - point_map.items[23 + i], + point_map.items[21 + i], ); } @@ -1705,7 +2032,7 @@ test "Page plain start_y subset" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("world\r\ntest", output); + try testing.expectEqualStrings("world\ntest", output); try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); @@ -1715,11 +2042,10 @@ test "Page plain start_y subset" { Coordinate{ .x = @intCast(i), .y = 1 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \n for (0..4) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 2 }, - point_map.items[7 + i], + point_map.items[6 + i], ); } @@ -1745,7 +2071,7 @@ test "Page plain end_y subset" { const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); - formatter.end_y = 2; + formatter.end_y = 1; var point_map: std.ArrayList(Coordinate) = .empty; defer point_map.deinit(alloc); @@ -1753,7 +2079,7 @@ test "Page plain end_y subset" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); try testing.expectEqual(@as(usize, 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); @@ -1763,11 +2089,10 @@ test "Page plain end_y subset" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[7 + i], + point_map.items[6 + i], ); } @@ -1794,7 +2119,7 @@ test "Page plain start_y and end_y range" { var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; - formatter.end_y = 3; + formatter.end_y = 2; var point_map: std.ArrayList(Coordinate) = .empty; defer point_map.deinit(alloc); @@ -1802,7 +2127,7 @@ test "Page plain start_y and end_y range" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("world\r\ntest", output); + try testing.expectEqualStrings("world\ntest", output); try testing.expectEqual(@as(usize, 1), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); @@ -1812,11 +2137,10 @@ test "Page plain start_y and end_y range" { Coordinate{ .x = @intCast(i), .y = 1 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \n for (0..4) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 2 }, - point_map.items[7 + i], + point_map.items[6 + i], ); } @@ -2004,8 +2328,8 @@ test "Page plain end_x on last row only" { const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); - formatter.end_y = 3; - formatter.end_x = 6; + formatter.end_y = 2; + formatter.end_x = 4; var point_map: std.ArrayList(Coordinate) = .empty; defer point_map.deinit(alloc); @@ -2013,10 +2337,9 @@ test "Page plain end_x on last row only" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); - // First two rows: full width, last row: up to end_x=6 - try testing.expectEqualStrings("first line\r\nsecond line\r\nthird", output); + try testing.expectEqualStrings("first line\nsecond line\nthird", output); try testing.expectEqual(@as(usize, 1), state.rows); - try testing.expectEqual(@as(usize, 1), state.cells); + try testing.expectEqual(@as(usize, 0), state.cells); // Verify point map try testing.expectEqual(output.len, point_map.items.len); @@ -2024,17 +2347,15 @@ test "Page plain end_x on last row only" { Coordinate{ .x = @intCast(i), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \r - try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[11]); // \n + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n for (0..11) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[12 + i], + point_map.items[11 + i], ); - try testing.expectEqual(Coordinate{ .x = 10, .y = 1 }, point_map.items[23]); // \r - try testing.expectEqual(Coordinate{ .x = 10, .y = 1 }, point_map.items[24]); // \n + try testing.expectEqual(Coordinate{ .x = 10, .y = 1 }, point_map.items[22]); // \n for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 2 }, - point_map.items[25 + i], + point_map.items[23 + i], ); } @@ -2061,8 +2382,8 @@ test "Page plain start_x and end_x multiline" { var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; - formatter.end_y = 3; - formatter.end_x = 4; + formatter.end_y = 2; + formatter.end_x = 2; var point_map: std.ArrayList(Coordinate) = .empty; defer point_map.deinit(alloc); @@ -2072,10 +2393,10 @@ test "Page plain start_x and end_x multiline" { const output = builder.writer.buffered(); // First row: "world" (start_x=6 to end of row) // Second row: "test case" (full row) - // Third row: "foo " (start to end_x=4) - try testing.expectEqualStrings("world\r\ntest case\r\nfoo", output); + // Third row: "foo" (start to end_x=2, inclusive) + try testing.expectEqualStrings("world\ntest case\nfoo", output); try testing.expectEqual(@as(usize, 1), state.rows); - try testing.expectEqual(@as(usize, 1), state.cells); + try testing.expectEqual(@as(usize, 0), state.cells); // Verify point map try testing.expectEqual(output.len, point_map.items.len); @@ -2083,17 +2404,15 @@ test "Page plain start_x and end_x multiline" { Coordinate{ .x = @intCast(i + 6), .y = 0 }, point_map.items[i], ); - try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[5]); // \r - try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[6]); // \n + try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[5]); // \n for (0..9) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 1 }, - point_map.items[7 + i], + point_map.items[6 + i], ); - try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[16]); // \r - try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[17]); // \n + try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[15]); // \n for (0..3) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 2 }, - point_map.items[18 + i], + point_map.items[16 + i], ); } @@ -2200,7 +2519,7 @@ test "Page plain end_x less than start_x single row" { var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 10; - formatter.end_y = 1; + formatter.end_y = 0; formatter.end_x = 5; var point_map: std.ArrayList(Coordinate) = .empty; @@ -2338,19 +2657,17 @@ test "Page plain start_y and start_x zero uses trailing state" { const state = try formatter.formatWithState(&builder.writer); const output = builder.writer.buffered(); // SHOULD output the 2 newlines from trailing_state because both start_y and start_x are 0 - try testing.expectEqualStrings("\r\n\r\nhello", output); + try testing.expectEqualStrings("\n\nhello", output); try testing.expectEqual(@as(usize, page.size.rows), state.rows); try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); // Verify point map try testing.expectEqual(output.len, point_map.items.len); - try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \r first blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[1]); // \n first blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[2]); // \r second blank row - try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[3]); // \n second blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \n first blank row + try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[1]); // \n second blank row for (0..5) |i| try testing.expectEqual( Coordinate{ .x = @intCast(i), .y = 0 }, - point_map.items[4 + i], + point_map.items[2 + i], ); } @@ -2713,8 +3030,8 @@ test "PageList plain spanning two pages" { formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const full_output = builder.writer.buffered(); - const output = std.mem.trimStart(u8, full_output, "\r\n"); - try testing.expectEqualStrings("page one\r\npage two", output); + const output = std.mem.trimStart(u8, full_output, "\n"); + try testing.expectEqualStrings("page one\npage two", output); // Verify pin map try testing.expectEqual(full_output.len, pin_map.items.len); @@ -2734,13 +3051,12 @@ test "PageList plain spanning two pages" { try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); } - // \r\n - these map to last node as they represent the transition to new page + // \n - maps to last node as it represents the transition to new page try testing.expectEqual(last_node, pin_map.items[trimmed_count + 8].node); - try testing.expectEqual(last_node, pin_map.items[trimmed_count + 9].node); // "page two" (8 chars) maps to last node for (0..8) |i| { - const idx = trimmed_count + 10 + i; + const idx = trimmed_count + 9 + i; try testing.expectEqual(last_node, pin_map.items[idx].node); try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); } @@ -2780,8 +3096,8 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const full_output = builder.writer.buffered(); - const output = std.mem.trimStart(u8, full_output, "\r\n"); - try testing.expectEqualStrings("hello worl\r\nd test", output); + const output = std.mem.trimStart(u8, full_output, "\n"); + try testing.expectEqualStrings("hello worl\nd test", output); // Verify pin map try testing.expectEqual(full_output.len, pin_map.items.len); @@ -2800,13 +3116,12 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { try testing.expectEqual(first_node, pin_map.items[idx].node); } - // \r\n - these map to last node as they represent the transition to new page + // \n - maps to last node as it represents the transition to new page try testing.expectEqual(last_node, pin_map.items[trimmed_count + 10].node); - try testing.expectEqual(last_node, pin_map.items[trimmed_count + 11].node); // "d test" (6 chars) maps to last node for (0..6) |i| { - const idx = trimmed_count + 12 + i; + const idx = trimmed_count + 11 + i; try testing.expectEqual(last_node, pin_map.items[idx].node); } } @@ -2958,12 +3273,12 @@ test "PageList plain with x offset on single page" { var formatter: PageListFormatter = .init(pages, .plain); formatter.top_left = .{ .node = node, .y = 0, .x = 6 }; - formatter.bottom_right = .{ .node = node, .y = 2, .x = 3 }; + formatter.bottom_right = .{ .node = node, .y = 2, .x = 2 }; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("world\r\ntest case\r\nfoo", output); + try testing.expectEqualStrings("world\ntest case\nfoo", output); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); @@ -3018,13 +3333,13 @@ test "PageList plain with x offset spanning two pages" { var formatter: PageListFormatter = .init(pages, .plain); formatter.top_left = .{ .node = first_node, .y = first_node.data.size.rows - 1, .x = 6 }; - formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 3 }; + formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 2 }; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const full_output = builder.writer.buffered(); - const output = std.mem.trimStart(u8, full_output, "\r\n"); - try testing.expectEqualStrings("world\r\nfoo", output); + const output = std.mem.trimStart(u8, full_output, "\n"); + try testing.expectEqualStrings("world\nfoo", output); // Verify pin map try testing.expectEqual(full_output.len, pin_map.items.len); @@ -3037,13 +3352,12 @@ test "PageList plain with x offset spanning two pages" { try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[idx].x); } - // \r\n - these map to last node as they represent the transition to new page + // \n - maps to last node as it represents the transition to new page try testing.expectEqual(last_node, pin_map.items[trimmed_count + 5].node); - try testing.expectEqual(last_node, pin_map.items[trimmed_count + 6].node); // "foo" (3 chars) from last page for (0..3) |i| { - const idx = trimmed_count + 7 + i; + const idx = trimmed_count + 6 + i; try testing.expectEqual(last_node, pin_map.items[idx].node); try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); } @@ -3120,7 +3434,7 @@ test "PageList plain with end_x only" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello world\r\ntes", output); + try testing.expectEqualStrings("hello world\ntes", output); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); @@ -3132,18 +3446,145 @@ test "PageList plain with end_x only" { try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); } - // \r\n + // \n try testing.expectEqual(node, pin_map.items[11].node); - try testing.expectEqual(node, pin_map.items[12].node); // "tes" (3 chars) on y=1 for (0..3) |i| { - try testing.expectEqual(node, pin_map.items[13 + i].node); - try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[13 + i].x); - try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[13 + i].y); + try testing.expectEqual(node, pin_map.items[12 + i].node); + try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[12 + i].x); + try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[12 + i].y); } } +test "PageList plain rectangle basic" { + 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 = 30, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Lorem ipsum dolor\r\n"); + try s.nextSlice("sit amet, consectetur\r\n"); + try s.nextSlice("adipiscing elit, sed do\r\n"); + try s.nextSlice("eiusmod tempor incididunt\r\n"); + try s.nextSlice("ut labore et dolore"); + + const pages = &t.screen.pages; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?; + formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?; + formatter.rectangle = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const expected = + \\t ame + \\ipisc + \\usmod + ; + try testing.expectEqualStrings(expected, output); +} + +test "PageList plain rectangle with EOL" { + 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 = 30, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Lorem ipsum dolor\r\n"); + try s.nextSlice("sit amet, consectetur\r\n"); + try s.nextSlice("adipiscing elit, sed do\r\n"); + try s.nextSlice("eiusmod tempor incididunt\r\n"); + try s.nextSlice("ut labore et dolore"); + + const pages = &t.screen.pages; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?; + formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 26, .y = 4 } }).?; + formatter.rectangle = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const expected = + \\dolor + \\nsectetur + \\lit, sed do + \\or incididunt + \\ dolore + ; + try testing.expectEqualStrings(expected, output); +} + +test "PageList plain rectangle more complex with breaks" { + 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 = 30, + .rows = 8, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Lorem ipsum dolor\r\n"); + try s.nextSlice("sit amet, consectetur\r\n"); + try s.nextSlice("adipiscing elit, sed do\r\n"); + try s.nextSlice("eiusmod tempor incididunt\r\n"); + try s.nextSlice("ut labore et dolore\r\n"); + try s.nextSlice("\r\n"); + try s.nextSlice("magna aliqua. Ut enim\r\n"); + try s.nextSlice("ad minim veniam, quis"); + + const pages = &t.screen.pages; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = pages.pin(.{ .screen = .{ .x = 11, .y = 2 } }).?; + formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 26, .y = 7 } }).?; + formatter.rectangle = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const expected = + \\elit, sed do + \\por incididunt + \\t dolore + \\ + \\a. Ut enim + \\niam, quis + ; + try testing.expectEqualStrings(expected, output); +} + test "TerminalFormatter plain no selection" { const testing = std.testing; const alloc = testing.allocator; @@ -3165,7 +3606,7 @@ test "TerminalFormatter plain no selection" { const formatter: TerminalFormatter = .init(&t, .plain); try formatter.format(&builder.writer); - try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + try testing.expectEqualStrings("hello\nworld", builder.writer.buffered()); } test "TerminalFormatter vt with palette" { @@ -3305,7 +3746,7 @@ test "TerminalFormatter plain multiline with pin_map" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); @@ -3316,12 +3757,11 @@ test "TerminalFormatter plain multiline with pin_map" { try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); } - // "\r\n" maps to end of first line + // "\n" maps to end of first line try testing.expectEqual(node, pin_map.items[5].node); - try testing.expectEqual(node, pin_map.items[6].node); // "world" (5 chars) for (0..5) |i| { - const idx = 7 + i; + const idx = 6 + i; try testing.expectEqual(node, pin_map.items[idx].node); try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y); @@ -3472,7 +3912,7 @@ test "Screen plain multiline" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("hello\r\nworld", output); + try testing.expectEqualStrings("hello\nworld", output); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); @@ -3483,12 +3923,11 @@ test "Screen plain multiline" { try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x); try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y); } - // "\r\n" maps to end of first line + // "\n" maps to end of first line try testing.expectEqual(node, pin_map.items[5].node); - try testing.expectEqual(node, pin_map.items[6].node); // "world" (5 chars) for (0..5) |i| { - const idx = 7 + i; + const idx = 6 + i; try testing.expectEqual(node, pin_map.items[idx].node); try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x); try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y); From a4d54dca1c50ea1a347da796735aacb66d69eaa0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 29 Oct 2025 10:50:47 -0700 Subject: [PATCH 217/702] terminal: remove all legacy encodeUtf8 functions, replace with formatter (#9392) This removes all existing functionality that I know of that encodes a terminal, screen, pagelist, or page as plaintext and unifies all logic onto the formatter system. --- src/terminal/PageList.zig | 56 ++---------- src/terminal/Screen.zig | 113 +++++++---------------- src/terminal/page.zig | 187 -------------------------------------- src/terminal/search.zig | 56 ++++++------ 4 files changed, 69 insertions(+), 343 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 3aba29128..82c64591b 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3216,50 +3216,6 @@ pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { }; } -pub const EncodeUtf8Options = struct { - /// The start and end points of the dump, both inclusive. The x will - /// be ignored and the full row will always be dumped. - tl: Pin, - br: ?Pin = null, - - /// If true, this will unwrap soft-wrapped lines. If false, this will - /// dump the screen as it is visually seen in a rendered window. - unwrap: bool = true, - - /// See Page.EncodeUtf8Options. - cell_map: ?*Page.CellMap = null, -}; - -/// Encode the pagelist to utf8 to the given writer. -/// -/// The writer should be buffered; this function does not attempt to -/// efficiently write and often writes one byte at a time. -/// -/// Note: this is tested using Screen.dumpString. This is a function that -/// predates this and is a thin wrapper around it so the tests all live there. -pub fn encodeUtf8( - self: *const PageList, - writer: *std.Io.Writer, - opts: EncodeUtf8Options, -) anyerror!void { - // We don't currently use self at all. There is an argument that this - // function should live on Pin instead but there is some future we might - // need state on here so... letting it go. - _ = self; - - var page_opts: Page.EncodeUtf8Options = .{ - .unwrap = opts.unwrap, - .cell_map = opts.cell_map, - }; - var iter = opts.tl.pageIterator(.right_down, opts.br); - while (iter.next()) |chunk| { - const page: *const Page = &chunk.node.data; - page_opts.start_y = chunk.start; - page_opts.end_y = chunk.end; - page_opts.preceding = try page.encodeUtf8(writer, page_opts); - } -} - /// Log a debug diagram of the page list to the provided writer. /// /// EXAMPLE: @@ -3857,13 +3813,17 @@ pub fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin { }, .viewport => viewport: { - const tl = self.getTopLeft(.viewport); - break :viewport tl.down(self.rows - 1).?; + var br = self.getTopLeft(.viewport); + br = br.down(self.rows - 1).?; + br.x = br.node.data.size.cols - 1; + break :viewport br; }, .history => active: { - const tl = self.getTopLeft(.active); - break :active tl.up(1); + var br = self.getTopLeft(.active); + br = br.up(1) orelse return null; + br.x = br.node.data.size.cols - 1; + break :active br; }, }; } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 486c4f384..5b90bf41b 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2799,9 +2799,38 @@ pub fn promptPath( pub fn dumpString( self: *const Screen, writer: *std.Io.Writer, - opts: PageList.EncodeUtf8Options, -) anyerror!void { - try self.pages.encodeUtf8(writer, opts); + opts: struct { + /// The start and end points of the dump, both inclusive. The x will + /// be ignored and the full row will always be dumped. + tl: Pin, + br: ?Pin = null, + + /// If true, this will unwrap soft-wrapped lines. If false, this will + /// dump the screen as it is visually seen in a rendered window. + unwrap: bool = true, + }, +) std.Io.Writer.Error!void { + // Create a formatter and use that to emit our text. + var formatter: ScreenFormatter = .init(self, .{ + .emit = .plain, + .unwrap = opts.unwrap, + .trim = false, + }); + + // Set up the selection based on the pins + const tl = opts.tl; + const br = opts.br orelse self.pages.getBottomRight(.screen).?; + + formatter.content = .{ + .selection = Selection.init( + tl, + br, + false, // not rectangle + ), + }; + + // Emit + try formatter.format(writer); } /// You should use dumpString, this is a restricted version mostly for @@ -8916,81 +8945,3 @@ test "Screen: adjustCapacity cursor style exceeds style set capacity" { try testing.expect(s.cursor.style.default()); try testing.expectEqual(style.default_id, s.cursor.style_id); } - -test "Screen UTF8 cell map with newlines" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - try s.testWriteString("A\n\nB\n\nC"); - - var cell_map = Page.CellMap.init(alloc); - defer cell_map.deinit(); - var builder: std.Io.Writer.Allocating = .init(alloc); - defer builder.deinit(); - try s.dumpString(&builder.writer, .{ - .tl = s.pages.getTopLeft(.screen), - .br = s.pages.getBottomRight(.screen), - .cell_map = &cell_map, - }); - - try testing.expectEqual(7, builder.written().len); - try testing.expectEqualStrings("A\n\nB\n\nC", builder.written()); - try testing.expectEqual(builder.written().len, cell_map.map.items.len); - try testing.expectEqual(Page.CellMapEntry{ - .x = 0, - .y = 0, - }, cell_map.map.items[0]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 1, - .y = 0, - }, cell_map.map.items[1]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 0, - .y = 1, - }, cell_map.map.items[2]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 0, - .y = 2, - }, cell_map.map.items[3]); -} - -test "Screen UTF8 cell map with blank prefix" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - s.cursorAbsolute(2, 1); - try s.testWriteString("B"); - - var cell_map: Page.CellMap = .init(alloc); - defer cell_map.deinit(); - var builder: std.Io.Writer.Allocating = .init(alloc); - defer builder.deinit(); - try s.dumpString(&builder.writer, .{ - .tl = s.pages.getTopLeft(.screen), - .br = s.pages.getBottomRight(.screen), - .cell_map = &cell_map, - }); - - try testing.expectEqualStrings("\n B", builder.written()); - try testing.expectEqual(builder.written().len, cell_map.map.items.len); - try testing.expectEqual(Page.CellMapEntry{ - .x = 0, - .y = 0, - }, cell_map.map.items[0]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 0, - .y = 1, - }, cell_map.map.items[1]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 1, - .y = 1, - }, cell_map.map.items[2]); - try testing.expectEqual(Page.CellMapEntry{ - .x = 2, - .y = 1, - }, cell_map.map.items[3]); -} diff --git a/src/terminal/page.zig b/src/terminal/page.zig index e38e96e92..5c83fc7c8 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1501,193 +1501,6 @@ pub const Page = struct { return self.grapheme_map.map(self.memory).capacity(); } - /// Options for encoding the page as UTF-8. - pub const EncodeUtf8Options = struct { - /// The range of rows to encode. If end_y is null, then it will - /// encode to the end of the page. - start_y: size.CellCountInt = 0, - end_y: ?size.CellCountInt = null, - - /// If true, this will unwrap soft-wrapped lines. If false, this will - /// dump the screen as it is visually seen in a rendered window. - unwrap: bool = true, - - /// Preceding state from encoding the prior page. Used to preserve - /// blanks properly across multiple pages. - preceding: TrailingUtf8State = .{}, - - /// If non-null, this will be cleared and filled with the x/y - /// coordinates of each byte in the UTF-8 encoded output. - /// The index in the array is the byte offset in the output - /// where 0 is the cursor of the writer when the function is - /// called. - cell_map: ?*CellMap = null, - - /// Trailing state for UTF-8 encoding. - pub const TrailingUtf8State = struct { - rows: usize = 0, - cells: usize = 0, - }; - }; - - /// See cell_map - pub const CellMap = struct { - alloc: Allocator, - map: std.ArrayList(CellMapEntry), - - pub fn init(alloc: Allocator) CellMap { - return .{ - .alloc = alloc, - .map = .empty, - }; - } - - pub fn deinit(self: *CellMap) void { - self.map.deinit(self.alloc); - } - }; - - /// The x/y coordinate of a single cell in the cell map. - pub const CellMapEntry = struct { - y: size.CellCountInt, - x: size.CellCountInt, - }; - - /// Encode the page contents as UTF-8. - /// - /// If preceding is non-null, then it will be used to initialize our - /// blank rows/cells count so that we can accumulate blanks across - /// multiple pages. - /// - /// Note: Many tests for this function are done via Screen.dumpString - /// tests since that function is a thin wrapper around this one and - /// it makes it easier to test input contents. - pub fn encodeUtf8( - self: *const Page, - writer: *std.Io.Writer, - opts: EncodeUtf8Options, - ) anyerror!EncodeUtf8Options.TrailingUtf8State { - var blank_rows: usize = opts.preceding.rows; - var blank_cells: usize = opts.preceding.cells; - - const start_y: size.CellCountInt = opts.start_y; - const end_y: size.CellCountInt = opts.end_y orelse self.size.rows; - - // We can probably avoid this by doing the logic below in a different - // way. The reason this exists is so that when we end a non-blank - // line with a newline, we can correctly map the cell map over to - // the correct x value. - // - // For example "A\nB". The cell map for "\n" should be (1, 0). - // This is tested in Screen.zig so feel free to refactor this. - var last_x: size.CellCountInt = 0; - - for (start_y..end_y) |y_usize| { - const y: size.CellCountInt = @intCast(y_usize); - const row: *Row = self.getRow(y); - const cells: []const Cell = self.getCells(row); - - // If this row is blank, accumulate to avoid a bunch of extra - // work later. If it isn't blank, make sure we dump all our - // blanks. - if (!Cell.hasTextAny(cells)) { - blank_rows += 1; - continue; - } - for (1..blank_rows + 1) |i| { - try writer.writeByte('\n'); - - // This is tested in Screen.zig, i.e. one test is - // "cell map with newlines" - if (opts.cell_map) |cell_map| { - try cell_map.map.append(cell_map.alloc, .{ - .x = last_x, - .y = @intCast(y - blank_rows + i - 1), - }); - last_x = 0; - } - } - blank_rows = 0; - - // If we're not wrapped, we always add a newline so after - // the row is printed we can add a newline. - if (!row.wrap or !opts.unwrap) blank_rows += 1; - - // If the row doesn't continue a wrap then we need to reset - // our blank cell count. - if (!row.wrap_continuation or !opts.unwrap) blank_cells = 0; - - // Go through each cell and print it - for (cells, 0..) |*cell, x_usize| { - const x: size.CellCountInt = @intCast(x_usize); - - // Skip spacers - switch (cell.wide) { - .narrow, .wide => {}, - .spacer_head, .spacer_tail => continue, - } - - // If we have a zero value, then we accumulate a counter. We - // only want to turn zero values into spaces if we have a non-zero - // char sometime later. - if (!cell.hasText()) { - blank_cells += 1; - continue; - } - if (blank_cells > 0) { - try writer.splatByteAll(' ', blank_cells); - if (opts.cell_map) |cell_map| { - for (0..blank_cells) |i| try cell_map.map.append(cell_map.alloc, .{ - .x = @intCast(x - blank_cells + i), - .y = y, - }); - } - - blank_cells = 0; - } - - switch (cell.content_tag) { - .codepoint => { - try writer.print("{u}", .{cell.content.codepoint}); - if (opts.cell_map) |cell_map| { - last_x = x + 1; - try cell_map.map.append(cell_map.alloc, .{ - .x = x, - .y = y, - }); - } - }, - - .codepoint_grapheme => { - try writer.print("{u}", .{cell.content.codepoint}); - if (opts.cell_map) |cell_map| { - last_x = x + 1; - try cell_map.map.append(cell_map.alloc, .{ - .x = x, - .y = y, - }); - } - - for (self.lookupGrapheme(cell).?) |cp| { - try writer.print("{u}", .{cp}); - if (opts.cell_map) |cell_map| try cell_map.map.append(cell_map.alloc, .{ - .x = x, - .y = y, - }); - } - }, - - // Unreachable since we do hasText() above - .bg_color_palette, - .bg_color_rgb, - => unreachable, - } - } - } - - return .{ .rows = blank_rows, .cells = blank_cells }; - } - /// Returns the bitset for the dirty bits on this page. /// /// The returned value is a DynamicBitSetUnmanaged but it is NOT diff --git a/src/terminal/search.zig b/src/terminal/search.zig index d9f6c5663..932ab5a35 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -32,6 +32,7 @@ const PageList = terminal.PageList; const Pin = PageList.Pin; const Selection = terminal.Selection; const Screen = terminal.Screen; +const PageFormatter = @import("formatter.zig").PageFormatter; /// Searches for a term in a PageList structure. /// @@ -147,10 +148,10 @@ const SlidingWindow = struct { const MetaBuf = CircBuf(Meta, undefined); const Meta = struct { node: *PageList.List.Node, - cell_map: Page.CellMap, + cell_map: std.ArrayList(point.Coordinate), - pub fn deinit(self: *Meta) void { - self.cell_map.deinit(); + pub fn deinit(self: *Meta, alloc: Allocator) void { + self.cell_map.deinit(alloc); } }; @@ -181,14 +182,14 @@ const SlidingWindow = struct { self.data.deinit(self.alloc); var meta_it = self.meta.iterator(.forward); - while (meta_it.next()) |meta| meta.deinit(); + 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(); + while (meta_it.next()) |meta| meta.deinit(self.alloc); self.meta.clear(); self.data.clear(); self.data_offset = 0; @@ -266,15 +267,15 @@ const SlidingWindow = struct { var saved: usize = 0; while (meta_it.next()) |meta| { const needed = self.needle.len - 1 - saved; - if (meta.cell_map.map.items.len >= needed) { + 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.map.items.len - needed; + self.data_offset = meta.cell_map.items.len - needed; break; } - saved += meta.cell_map.map.items.len; + 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 @@ -296,8 +297,8 @@ const SlidingWindow = struct { var prune_data_len: usize = 0; for (0..prune_count) |_| { const meta = meta_it.next().?; - prune_data_len += meta.cell_map.map.items.len; - meta.deinit(); + prune_data_len += meta.cell_map.items.len; + meta.deinit(self.alloc); } self.meta.deleteOldest(prune_count); self.data.deleteOldest(prune_data_len); @@ -364,7 +365,7 @@ const SlidingWindow = struct { // match. const meta_count = tl_meta_idx; meta_it.reset(); - for (0..meta_count) |_| meta_it.next().?.deinit(); + 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); @@ -396,19 +397,19 @@ const SlidingWindow = struct { // 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.map.items.len) { + 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.map.items.len; + offset.* += meta.cell_map.items.len; continue; } // We found the meta that contains the start of the match. - const map = meta.cell_map.map.items[meta_i]; + const map = meta.cell_map.items[meta_i]; return .{ .node = meta.node, - .y = map.y, + .y = @intCast(map.y), .x = map.x, }; } @@ -428,12 +429,9 @@ const SlidingWindow = struct { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, - .cell_map = .{ - .alloc = self.alloc, - .map = .empty, - }, + .cell_map = .empty, }; - errdefer meta.deinit(); + 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. @@ -443,16 +441,20 @@ const SlidingWindow = struct { defer encoded.deinit(); // Encode the page into the buffer. - const page: *const Page = &meta.node.data; - _ = page.encodeUtf8( - &encoded.writer, - .{ .cell_map = &meta.cell_map }, - ) catch { + 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.map.items.len == encoded.written().len); + assert(meta.cell_map.items.len == encoded.written().len); // Ensure our buffers are big enough to store what we need. try self.data.ensureUnusedCapacity(self.alloc, encoded.written().len); @@ -476,7 +478,7 @@ const SlidingWindow = struct { // 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.map.items.len; + 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. From e70ca0b9b52326967525e4cc905a0d1c99b0f351 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Wed, 29 Oct 2025 20:29:53 -0700 Subject: [PATCH 218/702] Don't encode option as alt in modify other keys 2 (#9406) There have been frequent reports of key encoding issues in vim and tmux with version 1.2.3 on macOS: #9340, #9361, #9401, https://discord.com/channels/1005603569187160125/1432413679806320772. I think I found the culprit: the option modifier is always passed as alt to the core, regardless of `macos-option-as-alt`. Since #9289, this means that a key event where option was used (as option) for translation is encoded as if it also has the alt modifier. For example, consider the many European keyboard layouts where option+8 sends `[`. If `macos-option-as-alt = true`, Ghostty correctly intercepts the option and encodes option+8 as alt+8 instead (that is, `^[[27;3;56~`). But if `macos-option-as-alt = false`, Ghostty first allows option to be used for translation, obtaining `[`, and then encodes the key event as alt+[ (that is, `^[[27;3;91~`), rather than just `[`. Tweaking the test case from #9289, here's a quick way to see this: set `macos-option-as-alt = left`, run ``` printf '\033[>4;2m' cat ``` choose a European keyboard layout (e.g., Norwegian), and hit both left-option+8 and right-option+8. The former inserts `^[[27;3;56~` in all well-behaved terminals. The latter inserts `[` in other terminals, but `^[[27;3;91~` in Ghostty. Basically, while modify other keys 2 does require encoding consumed modifiers, the option key is not one of the supported modifiers, and should not be included (as alt or anything else) when `macos-option-as-alt = false`. This PR removes alts that were actually options when using modify other keys 2. --- src/input/key_encode.zig | 47 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index f411deb19..f3dfee0b6 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -412,8 +412,20 @@ fn legacy( if (it.nextCodepoint() != null) break :modify_other; // The mods we encode for this are just the binding mods (shift, ctrl, - // super, alt). - const mods = event.mods.binding(); + // super, alt unless it is actually option). + const mods = mods: { + var mods_binding = event.mods.binding(); + if (comptime builtin.target.os.tag.isDarwin()) alt: { + switch (opts.macos_option_as_alt) { + .false => {}, + .true => break :alt, + .left => if (event.mods.sides.alt == .left) break :alt, + .right => if (event.mods.sides.alt == .right) break :alt, + } + mods_binding.alt = false; + } + break :mods mods_binding; + }; // This copies xterm's `ModifyOtherKeys` function that returns // whether modify other keys should be encoded for the given @@ -1988,6 +2000,37 @@ test "legacy: ctrl+shift+char with modify other state 2 and consumed mods" { try testing.expectEqualStrings("\x1b[27;6;72~", writer.buffered()); } +test "legacy: alt+digit with modify other state 2" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .digit_8, + .mods = .{ .alt = true }, + .consumed_mods = .{}, + .utf8 = "8", + }, .{ + .modify_other_keys_state_2 = true, + .macos_option_as_alt = .true, + }); + try testing.expectEqualStrings("\x1b[27;3;56~", writer.buffered()); +} + +test "legacy: alt+digit with modify other state 2 and macos-option-as-alt = false" { + if (comptime builtin.os.tag != .macos) return error.SkipZigTest; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try legacy(&writer, .{ + .key = .digit_8, + .mods = .{ .alt = true }, + .consumed_mods = .{ .alt = true }, + .utf8 = "[", // common translation of option+8 with European keyboard layouts + }, .{ + .modify_other_keys_state_2 = true, + .macos_option_as_alt = .false, + }); + try testing.expectEqualStrings("[", writer.buffered()); +} + test "legacy: fixterm awkward letters" { var buf: [128]u8 = undefined; { From c0e483c49e9bce963e27178407425ba6a12c4705 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 29 Oct 2025 20:55:52 -0700 Subject: [PATCH 219/702] terminal: HTML formatting (#9402) This adds HTML formatting capabilities to the formatter package. HTML is emitted as inline styles. For palette indexes, direct RGB is emitted if we have access to a palette; otherwise, we fall back to CSS variables. This isn't exposed to end users yet, but will enable copy as html features. This is available in libghostty. Fixes #9395 **AI disclosure:** I used AI (Amp) to help me write tests, but the implementation was done manually. I reviewed everything. --- src/lib_vt.zig | 1 + src/terminal/formatter.zig | 594 ++++++++++++++++++++++++++++++++++--- src/terminal/style.zig | 255 +++++++++++++++- 3 files changed, 809 insertions(+), 41 deletions(-) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index aa37c6110..e95eee5f4 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -25,6 +25,7 @@ pub const osc = terminal.osc; pub const point = terminal.point; pub const color = terminal.color; pub const device_status = terminal.device_status; +pub const formatter = terminal.formatter; pub const kitty = terminal.kitty; pub const modes = terminal.modes; pub const page = terminal.page; diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index cd4b76340..246624d5b 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1,6 +1,7 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const color = @import("color.zig"); const size = @import("size.zig"); const charsets = @import("charsets.zig"); const kitty = @import("kitty.zig"); @@ -33,10 +34,27 @@ pub const Format = enum { /// moves back to the beginning prior emitting follow-up lines. vt, + /// HTML output. + /// + /// This will emit inline styles for as much styling as possible, + /// in the interest of simplicity and ease of editing. This isn't meant + /// to build the most beautiful or efficient HTML, but rather to be + /// stylistically correct. + /// + /// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette + /// indices use CSS variables (var(--vt-palette-N)). The palette colors are + /// emitted by TerminalFormatter.Extra.palette as a "); + }, } // If we have a pin_map, add the bytes we wrote to map. @@ -461,7 +504,7 @@ pub const ScreenFormatter = struct { pub fn format( self: ScreenFormatter, writer: *std.Io.Writer, - ) !void { + ) std.Io.Writer.Error!void { switch (self.content) { .none => {}, @@ -484,6 +527,10 @@ pub const ScreenFormatter = struct { switch (self.opts.emit) { .plain => return, .vt => if (!self.extra.isSet()) return, + + // HTML doesn't preserve any screen state because it has + // nothing to do with rendering. + .html => return, } // Emit current SGR style state @@ -656,7 +703,7 @@ pub const PageListFormatter = struct { pub fn format( self: PageListFormatter, writer: *std.Io.Writer, - ) !void { + ) std.Io.Writer.Error!void { const tl: PageList.Pin = self.top_left orelse self.list.getTopLeft(.screen); const br: PageList.Pin = self.bottom_right orelse self.list.getBottomRight(.screen).?; @@ -791,14 +838,14 @@ pub const PageFormatter = struct { pub fn format( self: PageFormatter, writer: *std.Io.Writer, - ) !void { + ) std.Io.Writer.Error!void { _ = try self.formatWithState(writer); } pub fn formatWithState( self: PageFormatter, writer: *std.Io.Writer, - ) !TrailingState { + ) std.Io.Writer.Error!TrailingState { var blank_rows: usize = 0; var blank_cells: usize = 0; @@ -854,6 +901,17 @@ pub const PageFormatter = struct { return .{ .rows = blank_rows, .cells = blank_cells }; } + // Wrap HTML output in monospace font styling + if (self.opts.emit == .html) { + const monospace = "
"; + try writer.writeAll(monospace); + if (self.point_map) |*map| map.map.appendNTimes( + map.alloc, + .{ .x = 0, .y = 0 }, + monospace.len, + ) catch return error.WriteFailed; + } + // Our style for non-plain formats var style: Style = .{}; @@ -904,8 +962,19 @@ pub const PageFormatter = struct { if (blank_rows > 0) { const sequence: []const u8 = switch (self.opts.emit) { + // Plaintext just uses standard newlines because newlines + // on their own usually move the cursor back in anywhere + // you type plaintext. .plain => "\n", + + // VT uses \r\n because in a raw pty, \n alone doesn't + // guarantee moving the cursor back to column 0. \r + // makes it work for sure. .vt => "\r\n", + + // HTML uses just \n because HTML rendering will move + // the cursor back. + .html => "\n", }; for (0..blank_rows) |_| try writer.writeAll(sequence); @@ -1012,6 +1081,16 @@ pub const PageFormatter = struct { // We combine codepoint and graphemes because both have // shared style handling. We use comptime to dup it. inline .codepoint, .codepoint_grapheme => |tag| { + // Handle closing our styling if we go back to unstyled + // content. + if (self.opts.emit.styled() and + !cell.hasStyling() and + !style.default()) + { + try self.formatStyleClose(writer); + style = .{}; + } + // If we're emitting styling and we have styles, then // we need to load the style and emit any sequences // as necessary. @@ -1026,15 +1105,28 @@ pub const PageFormatter = struct { // emitted style, don't bloat the output. if (cell_style.eql(style)) break :style; + // We need to emit a closing tag if the style + // was non-default before, which means we set + // styles once. + const closing = !style.default(); + // New style, emit it. style = cell_style.*; - try writer.print("{f}", .{style.formatterVt()}); + try self.formatStyleOpen( + writer, + &style, + closing, + ); // If we have a point map, we map the style to // this cell. if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); - try discarding.writer.print("{f}", .{style.formatterVt()}); + try self.formatStyleOpen( + &discarding.writer, + &style, + closing, + ); for (0..discarding.count) |_| map.map.append(map.alloc, .{ .x = x, .y = y, @@ -1042,24 +1134,13 @@ pub const PageFormatter = struct { } } - try writer.print("{u}", .{cell.content.codepoint}); - if (comptime tag == .codepoint_grapheme) { - for (self.page.lookupGrapheme(cell).?) |cp| { - try writer.print("{u}", .{cp}); - } - } + try self.writeCell(tag, writer, cell); // If we have a point map, all codepoints map to this // cell. if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); - try discarding.writer.print("{u}", .{cell.content.codepoint}); - if (comptime tag == .codepoint_grapheme) { - for (self.page.lookupGrapheme(cell).?) |cp| { - try writer.print("{u}", .{cp}); - } - } - + try self.writeCell(tag, &discarding.writer, cell); for (0..discarding.count) |_| map.map.append(map.alloc, .{ .x = x, .y = y, @@ -1075,8 +1156,117 @@ pub const PageFormatter = struct { } } + // If the style is non-default, we need to close our style tag. + if (!style.default()) try self.formatStyleClose(writer); + + // Close the monospace wrapper for HTML output + if (self.opts.emit == .html) { + const closing = "
"; + try writer.writeAll(closing); + if (self.point_map) |*map| { + map.map.ensureUnusedCapacity( + map.alloc, + closing.len, + ) catch return error.WriteFailed; + map.map.appendNTimesAssumeCapacity( + map.map.items[map.map.items.len - 1], + closing.len, + ); + } + } + return .{ .rows = blank_rows, .cells = blank_cells }; } + + fn writeCell( + self: PageFormatter, + comptime tag: Cell.ContentTag, + writer: *std.Io.Writer, + cell: *const Cell, + ) !void { + try self.writeCodepoint(writer, cell.content.codepoint); + if (comptime tag == .codepoint_grapheme) { + for (self.page.lookupGrapheme(cell).?) |cp| { + try self.writeCodepoint(writer, cp); + } + } + } + + fn writeCodepoint( + self: PageFormatter, + writer: *std.Io.Writer, + codepoint: u21, + ) !void { + switch (self.opts.emit) { + .plain, .vt => try writer.print("{u}", .{codepoint}), + .html => { + switch (codepoint) { + '<' => try writer.writeAll("<"), + '>' => try writer.writeAll(">"), + '&' => try writer.writeAll("&"), + '"' => try writer.writeAll("""), + '\'' => try writer.writeAll("'"), + else => try writer.print("{u}", .{codepoint}), + } + }, + } + } + + fn formatStyleOpen( + self: PageFormatter, + writer: *std.Io.Writer, + style: *const Style, + closing: bool, + ) std.Io.Writer.Error!void { + switch (self.opts.emit) { + .plain => unreachable, + + // Note: we don't use closing on purpose because VT sequences + // always reset the prior style. Our formatter always emits a + // \x1b[0m before emitting a new style if necessary. + .vt => { + var formatter = style.formatterVt(); + formatter.palette = self.opts.palette; + try writer.print("{f}", .{formatter}); + }, + + // We use `display: inline` so that the div doesn't impact + // layout since we're primarily using it as a CSS wrapper. + .html => { + if (closing) try writer.writeAll("
"); + var formatter = style.formatterHtml(); + formatter.palette = self.opts.palette; + try writer.print( + "
", + .{formatter}, + ); + }, + } + } + + fn formatStyleClose( + self: PageFormatter, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const str: []const u8 = switch (self.opts.emit) { + .plain => return, + .vt => "\x1b[0m", + .html => "
", + }; + + try writer.writeAll(str); + if (self.point_map) |*m| { + assert(m.map.items.len > 0); + m.map.ensureUnusedCapacity( + m.alloc, + str.len, + ) catch return error.WriteFailed; + m.map.appendNTimesAssumeCapacity( + m.map.items[m.map.items.len - 1], + str.len, + ); + } + } }; test "Page plain single line" { @@ -2785,7 +2975,7 @@ test "Page VT single line with bold" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", output); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello\x1b[0m", output); // Verify point map - style sequences should point to first character they style try testing.expectEqual(output.len, point_map.items.len); @@ -2831,7 +3021,7 @@ test "Page VT multiple styles" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld", output); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld\x1b[0m", output); // Verify point map matches output length try testing.expectEqual(output.len, point_map.items.len); @@ -2866,7 +3056,7 @@ test "Page VT with foreground color" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred", output); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred\x1b[0m", output); // Verify point map - style sequences should point to first character they style try testing.expectEqual(output.len, point_map.items.len); @@ -2912,7 +3102,7 @@ test "Page VT multi-line with styles" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\r\n\x1b[0m\x1b[3msecond", output); + try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\r\n\x1b[0m\x1b[3msecond\x1b[0m", output); // Verify point map matches output length try testing.expectEqual(output.len, point_map.items.len); @@ -2947,7 +3137,7 @@ test "Page VT duplicate style not emitted twice" { try formatter.format(&builder.writer); const output = builder.writer.buffered(); - try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", output); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello\x1b[0m", output); // Verify point map matches output length try testing.expectEqual(output.len, point_map.items.len); @@ -3229,7 +3419,7 @@ test "PageList VT spanning two pages" { try formatter.format(&builder.writer); const full_output = builder.writer.buffered(); const output = std.mem.trimStart(u8, full_output, "\r\n"); - try testing.expectEqualStrings("\x1b[0m\x1b[1mpage one\r\n\x1b[0m\x1b[1mpage two", output); + try testing.expectEqualStrings("\x1b[0m\x1b[1mpage one\x1b[0m\r\n\x1b[0m\x1b[1mpage two\x1b[0m", output); // Verify pin map try testing.expectEqual(full_output.len, pin_map.items.len); @@ -4536,3 +4726,341 @@ test "Terminal vt with pwd" { // Verify pwd matches try testing.expectEqualStrings(t.pwd.items, t2.pwd.items); } + +test "Page html with multiple styles" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set bold, then italic, then reset + try s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
" ++ + "
bold
" ++ + "
italic
" ++ + "normal" ++ + "
", + output, + ); +} + +test "Page html plain text" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Plain text without styles should be wrapped in monospace div + try testing.expectEqualStrings( + "
hello, world
", + output, + ); +} + +test "Page html with colors" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set red foreground, blue background + try s.nextSlice("\x1b[31;44mcolored"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
" ++ + "
colored
" ++ + "
", + output, + ); +} + +test "TerminalFormatter html with palette" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Modify some palette colors + try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); + try s.nextSlice("test"); + + var formatter: TerminalFormatter = .init(&t, .{ .emit = .html }); + formatter.extra.palette = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Verify palette CSS variables are emitted + try testing.expect(std.mem.indexOf(u8, output, "") != null); + try testing.expect(std.mem.indexOf(u8, output, "test") != null); +} + +test "Page html with escaping" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("&\"'text"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
<tag>&"'text
", + output, + ); + + // Verify point map length matches output + try testing.expectEqual(output.len, point_map.items.len); + + // Opening wrapper div + const wrapper_start = "
"; + const wrapper_start_len = wrapper_start.len; + for (0..wrapper_start_len) |i| try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[i]); + + // Verify each character maps correctly, accounting for escaping + const offset = wrapper_start_len; + // < (4 bytes: <) -> x=0 + for (0..4) |i| try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[offset + i]); + // t (1 byte) -> x=1 + try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[offset + 4]); + // a (1 byte) -> x=2 + try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[offset + 5]); + // g (1 byte) -> x=3 + try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[offset + 6]); + // > (4 bytes: >) -> x=4 + for (0..4) |i| try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[offset + 7 + i]); + // & (5 bytes: &) -> x=5 + for (0..5) |i| try testing.expectEqual(Coordinate{ .x = 5, .y = 0 }, point_map.items[offset + 11 + i]); + // " (6 bytes: ") -> x=6 + for (0..6) |i| try testing.expectEqual(Coordinate{ .x = 6, .y = 0 }, point_map.items[offset + 16 + i]); + // ' (5 bytes: ') -> x=7 + for (0..5) |i| try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[offset + 22 + i]); + // t (1 byte) -> x=8 + try testing.expectEqual(Coordinate{ .x = 8, .y = 0 }, point_map.items[offset + 27]); + // e (1 byte) -> x=9 + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[offset + 28]); + // x (1 byte) -> x=10 + try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[offset + 29]); + // t (1 byte) -> x=11 + try testing.expectEqual(Coordinate{ .x = 11, .y = 0 }, point_map.items[offset + 30]); +} + +test "Page VT with palette option emits RGB" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set a custom palette color and use it + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b[31mred"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Without palette option - should emit palette index + { + builder.clearRetainingCapacity(); + var formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred\x1b[0m", output); + } + + // With palette option - should emit RGB directly + { + builder.clearRetainingCapacity(); + var opts: Options = .vt; + opts.palette = &t.color_palette.colors; + var formatter: PageFormatter = .init(page, opts); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("\x1b[0m\x1b[38;2;171;205;239mred\x1b[0m", output); + } +} + +test "Page html with palette option emits RGB" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set a custom palette color and use it + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b[31mred"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Without palette option - should emit CSS variable + { + builder.clearRetainingCapacity(); + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings( + "
" ++ + "
red
" ++ + "
", + output, + ); + } + + // With palette option - should emit RGB directly + { + builder.clearRetainingCapacity(); + var opts: Options = .{ .emit = .html }; + opts.palette = &t.color_palette.colors; + var formatter: PageFormatter = .init(page, opts); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings( + "
" ++ + "
red
" ++ + "
", + output, + ); + } +} + +test "Page VT style reset properly closes styles" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set bold, then reset with SGR 0 + try s.nextSlice("\x1b[1mbold\x1b[0mnormal"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + builder.clearRetainingCapacity(); + var formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // The reset should properly close the bold style + try testing.expectEqualStrings("\x1b[0m\x1b[1mbold\x1b[0mnormal", output); +} diff --git a/src/terminal/style.zig b/src/terminal/style.zig index fea4666b8..d7e6b03ab 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -306,9 +306,24 @@ pub const Style = struct { return .{ .style = self }; } + /// Returns a formatter that renders this style as inline CSS properties, + /// to be used with `{f}`. The output is a valid CSS style string suitable + /// for use in a `style` attribute (e.g., "color: rgb(255, 0, 0); font-weight: bold;"). + /// + /// Palette colors are emitted as CSS variables like `var(--vt-palette-N)`. + pub fn formatterHtml(self: *const Style) HtmlFormatter { + return .{ .style = self }; + } + const VTFormatter = struct { style: *const Style, + /// If set, palette colors will be emitted as RGB values instead of + /// palette indices. This is useful when you want to capture the + /// exact colors at formatting time rather than relying on the + /// terminal's palette. + palette: ?*const color.Palette = null, + pub fn format( self: VTFormatter, writer: *std.Io.Writer, @@ -337,30 +352,110 @@ pub const Style = struct { } // Various RGB colors. - try formatColor(writer, 38, self.style.fg_color); - try formatColor(writer, 48, self.style.bg_color); - try formatColor(writer, 58, self.style.underline_color); + try self.formatColor(writer, 38, self.style.fg_color); + try self.formatColor(writer, 48, self.style.bg_color); + try self.formatColor(writer, 58, self.style.underline_color); } fn formatColor( + self: VTFormatter, writer: *std.Io.Writer, prefix: u8, value: Color, ) !void { switch (value) { .none => {}, - .palette => |idx| try writer.print( - "\x1b[{d};5;{}m", - .{ prefix, idx }, - ), + .palette => |idx| { + if (self.palette) |p| { + const rgb = p[idx]; + try writer.print( + "\x1b[{d};2;{d};{d};{d}m", + .{ prefix, rgb.r, rgb.g, rgb.b }, + ); + } else { + try writer.print( + "\x1b[{d};5;{d}m", + .{ prefix, idx }, + ); + } + }, .rgb => |rgb| try writer.print( - "\x1b[{d};2;{};{};{}m", + "\x1b[{d};2;{d};{d};{d}m", .{ prefix, rgb.r, rgb.g, rgb.b }, ), } } }; + const HtmlFormatter = struct { + style: *const Style, + + /// If set, palette colors will be emitted as RGB values instead of + /// CSS variables. This is useful when you want to capture the exact + /// colors at formatting time rather than relying on CSS variables. + palette: ?*const color.Palette = null, + + pub fn format( + self: HtmlFormatter, + writer: *std.Io.Writer, + ) !void { + // Colors + try self.formatColor(writer, "color", self.style.fg_color); + try self.formatColor(writer, "background-color", self.style.bg_color); + try self.formatColor(writer, "text-decoration-color", self.style.underline_color); + + // Text decoration line + const has_line = self.style.flags.underline != .none or + self.style.flags.strikethrough or + self.style.flags.overline or + self.style.flags.blink; + if (has_line) { + try writer.writeAll("text-decoration-line:"); + if (self.style.flags.underline != .none) try writer.writeAll(" underline"); + if (self.style.flags.strikethrough) try writer.writeAll(" line-through"); + if (self.style.flags.overline) try writer.writeAll(" overline"); + if (self.style.flags.blink) try writer.writeAll(" blink"); + try writer.writeAll(";"); + } + + // Text decoration style + switch (self.style.flags.underline) { + .none => {}, + .single => try writer.writeAll("text-decoration-style: solid;"), + .double => try writer.writeAll("text-decoration-style: double;"), + .curly => try writer.writeAll("text-decoration-style: wavy;"), + .dotted => try writer.writeAll("text-decoration-style: dotted;"), + .dashed => try writer.writeAll("text-decoration-style: dashed;"), + } + + if (self.style.flags.bold) try writer.writeAll("font-weight: bold;"); + if (self.style.flags.italic) try writer.writeAll("font-style: italic;"); + if (self.style.flags.faint) try writer.writeAll("opacity: 0.5;"); + if (self.style.flags.invisible) try writer.writeAll("visibility: hidden;"); + if (self.style.flags.inverse) try writer.writeAll("filter: invert(100%);"); + } + + fn formatColor( + self: HtmlFormatter, + writer: *std.Io.Writer, + property: []const u8, + c: Color, + ) !void { + switch (c) { + .none => {}, + .palette => |idx| { + if (self.palette) |p| { + const rgb = p[idx]; + try writer.print("{s}: rgb({d}, {d}, {d});", .{ property, rgb.r, rgb.g, rgb.b }); + } else { + try writer.print("{s}: var(--vt-palette-{d});", .{ property, idx }); + } + }, + .rgb => |rgb| try writer.print("{s}: rgb({d}, {d}, {d});", .{ property, rgb.r, rgb.g, rgb.b }), + } + } + }; + /// `PackedStyle` represents the same data as `Style` but without padding, /// which is necessary for hashing via re-interpretation of the underlying /// bytes. @@ -772,6 +867,39 @@ test "Style VT formatting all colors palette" { ); } +test "Style VT formatting palette with palette set emits rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .palette = 1 } }; + var formatter = style.formatterVt(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings("\x1b[0m\x1b[38;2;204;102;102m", builder.writer.buffered()); +} + +test "Style VT formatting all palette colors with palette set" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .palette = 1 }, + .bg_color = .{ .palette = 2 }, + .underline_color = .{ .palette = 3 }, + }; + var formatter = style.formatterVt(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[38;2;204;102;102m\x1b[48;2;181;189;104m\x1b[58;2;240;198;116m", + builder.writer.buffered(), + ); +} + test "Set basic usage" { const testing = std.testing; const alloc = testing.allocator; @@ -831,3 +959,114 @@ test "Set capacities" { // We want to support at least this many styles without overflowing. _ = Set.Layout.init(16384); } + +test "Style HTML formatting basic bold" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .bold = true } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + try testing.expectEqualStrings("font-weight: bold;", builder.writer.buffered()); +} + +test "Style HTML formatting fg color rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + try testing.expectEqualStrings("color: rgb(255, 128, 64);", builder.writer.buffered()); +} + +test "Style HTML formatting bg color palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .palette = 7 } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + try testing.expectEqualStrings("background-color: var(--vt-palette-7);", builder.writer.buffered()); +} + +test "Style HTML formatting combined colors and flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .rgb = .{ .r = 255, .g = 0, .b = 0 } }, + .bg_color = .{ .rgb = .{ .r = 0, .g = 0, .b = 255 } }, + .flags = .{ .bold = true, .italic = true }, + }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + const result = builder.writer.buffered(); + try testing.expect(std.mem.indexOf(u8, result, "color: rgb(255, 0, 0);") != null); + try testing.expect(std.mem.indexOf(u8, result, "background-color: rgb(0, 0, 255);") != null); + try testing.expect(std.mem.indexOf(u8, result, "font-weight: bold;") != null); + try testing.expect(std.mem.indexOf(u8, result, "font-style: italic;") != null); +} + +test "Style HTML formatting single decoration line" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .single } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + const result = builder.writer.buffered(); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-line: underline;") != null); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-style: solid;") != null); +} + +test "Style HTML formatting multiple decoration lines" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .curly, .strikethrough = true, .overline = true } }; + try builder.writer.print("{f}", .{style.formatterHtml()}); + const result = builder.writer.buffered(); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-line: underline line-through overline;") != null); + try testing.expect(std.mem.indexOf(u8, result, "text-decoration-style: wavy;") != null); +} + +test "Style HTML formatting palette with palette set emits rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .palette = 7 } }; + var formatter = style.formatterHtml(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings("background-color: rgb(197, 200, 198);", builder.writer.buffered()); +} + +test "Style HTML formatting all palette colors with palette set" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .palette = 1 }, + .bg_color = .{ .palette = 2 }, + .underline_color = .{ .palette = 3 }, + }; + var formatter = style.formatterHtml(); + formatter.palette = &color.default; + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings( + "color: rgb(204, 102, 102);background-color: rgb(181, 189, 104);text-decoration-color: rgb(240, 198, 116);", + builder.writer.buffered(), + ); +} From 4a88976ef97ec074ebd33fb0de529b955e7e38e6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 29 Oct 2025 21:28:52 -0700 Subject: [PATCH 220/702] example/zig-formatter: show how to use formatters from libghostty (#9407) --- .github/workflows/test.yml | 10 ++++++- example/zig-formatter/README.md | 24 +++++++++++++++++ example/zig-formatter/build.zig | 39 +++++++++++++++++++++++++++ example/zig-formatter/build.zig.zon | 13 +++++++++ example/zig-formatter/src/main.zig | 41 +++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 example/zig-formatter/README.md create mode 100644 example/zig-formatter/build.zig create mode 100644 example/zig-formatter/build.zig.zon create mode 100644 example/zig-formatter/src/main.zig diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fae9fa4c5..2136c80ea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -95,7 +95,15 @@ jobs: fail-fast: false matrix: dir: - [c-vt, c-vt-key-encode, c-vt-paste, c-vt-sgr, zig-vt, zig-vt-stream] + [ + c-vt, + c-vt-key-encode, + c-vt-paste, + c-vt-sgr, + zig-formatter, + zig-vt, + zig-vt-stream, + ] name: Example ${{ matrix.dir }} runs-on: namespace-profile-ghostty-sm needs: test diff --git a/example/zig-formatter/README.md b/example/zig-formatter/README.md new file mode 100644 index 000000000..777fa5d7f --- /dev/null +++ b/example/zig-formatter/README.md @@ -0,0 +1,24 @@ +# Example: stdin to HTML using `vtStream` and `TerminalFormatter` + +This example demonstrates how to read VT sequences from stdin, parse them +using `vtStream`, and output styled HTML using `TerminalFormatter`. The +purpose of this example is primarily to show how to use formatters with +terminals. + +Requires the Zig version stated in the `build.zig.zon` file. + +## Usage + +Basic usage: + +```shell-session +echo -e "Hello \033[1;32mGreen\033[0m World" | zig build run +``` + +This will output HTML with inline styles and CSS palette variables. + +You can also pipe complex terminal output: + +```shell-session +ls --color=always | zig build run > output.html +``` diff --git a/example/zig-formatter/build.zig b/example/zig-formatter/build.zig new file mode 100644 index 000000000..54cdb3ee0 --- /dev/null +++ b/example/zig-formatter/build.zig @@ -0,0 +1,39 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + const test_step = b.step("test", "Run unit tests"); + + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + if (b.lazyDependency("ghostty", .{})) |dep| { + exe_mod.addImport( + "ghostty-vt", + dep.module("ghostty-vt"), + ); + } + + const exe = b.addExecutable(.{ + .name = "zig_formatter", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_module = exe_mod, + }); + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/example/zig-formatter/build.zig.zon b/example/zig-formatter/build.zig.zon new file mode 100644 index 000000000..9388a248f --- /dev/null +++ b/example/zig-formatter/build.zig.zon @@ -0,0 +1,13 @@ +.{ + .name = .zig_formatter, + .version = "0.0.0", + .fingerprint = 0x578de530797eafe6, + .dependencies = .{ + .ghostty = .{ .path = "../../" }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/zig-formatter/src/main.zig b/example/zig-formatter/src/main.zig new file mode 100644 index 000000000..87a8e4915 --- /dev/null +++ b/example/zig-formatter/src/main.zig @@ -0,0 +1,41 @@ +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); + +pub fn main() !void { + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + // Create a terminal + var t: ghostty_vt.Terminal = try .init(alloc, .{ .cols = 150, .rows = 80 }); + defer t.deinit(alloc); + + // Create a read-only VT stream for parsing terminal sequences + var stream = t.vtStream(); + defer stream.deinit(); + + // Read from stdin + const stdin = std.fs.File.stdin(); + var buf: [4096]u8 = undefined; + while (true) { + const n = try stdin.readAll(&buf); + if (n == 0) break; + + // Replace \n with \r\n + for (buf[0..n]) |byte| { + if (byte == '\n') try stream.next('\r'); + try stream.next(byte); + } + } + + // Use TerminalFormatter to emit HTML + const formatter: ghostty_vt.formatter.TerminalFormatter = .init(&t, .{ + .emit = .html, + .palette = &t.color_palette.colors, + }); + + // Write to stdout + var stdout_writer = std.fs.File.stdout().writer(&buf); + const stdout = &stdout_writer.interface; + try stdout.print("{f}", .{formatter}); +} From 5edf9aff50adea714dce65811b6de4b3af4df3f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:29:05 -0700 Subject: [PATCH 221/702] build(deps): bump cachix/install-nix-action from 31.8.1 to 31.8.2 (#9405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.8.1 to 31.8.2.
Release notes

Sourced from cachix/install-nix-action's releases.

v31.8.2

What's Changed

Full Changelog: https://github.com/cachix/install-nix-action/compare/v31.8.1...v31.8.2

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=cachix/install-nix-action&package-manager=github_actions&previous-version=31.8.1&new-version=31.8.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 f78e1c143..7525e2470 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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 4fa001a64..489b15324 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 with: nix_path: nixpkgs=channel:nixos-unstable diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index b90083407..d5b1e20f5 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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 2136c80ea..5bf10a145 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + - uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 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 0d2d114c8..0ae7733a0 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@fd24c48048070c1be9acd18c9d369a83f0fe94d7 # v31.8.1 + uses: cachix/install-nix-action@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 From 4818c2b896b6af1189c2b9e0ca4164e3ad56c8ad Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 30 Oct 2025 00:29:40 -0400 Subject: [PATCH 222/702] cli: make the entire +ssh-cache cache path (#9403) std.fs.makeDirAbsolute() only creates the last directory. We instead need Dir.makePath() to make the entire path, including intermediate directories. This fixes the problem where a missing $XDG_STATE_HOME directory (e.g. ~/.local/state/) would prevent our ssh cache file from being created. Fixes #9393 --- src/cli/ssh-cache/DiskCache.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index 8e23b30cf..fe043569f 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -70,7 +70,7 @@ pub fn add( // Create cache directory if needed if (std.fs.path.dirname(self.path)) |dir| { - std.fs.makeDirAbsolute(dir) catch |err| switch (err) { + std.fs.cwd().makePath(dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; From c7d5d1b9fcddf7f3afaf0d95c2abf247080c521c Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 30 Oct 2025 05:30:03 +0100 Subject: [PATCH 223/702] macOS: make text editor in clipboard confirmation non focusable (#9400) With its being `focusable`(default), the first responder became the text editor instead of the paste button. This fixes the issue where one can't confirm with the keyboard. This doesn't affect its selection. --- .../ClipboardConfirmation/ClipboardConfirmationView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 086dab793..6423e3cf6 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -52,6 +52,7 @@ struct ClipboardConfirmationView: View { } TextEditor(text: .constant(contents)) + .focusable(false) .font(.system(.body, design: .monospaced)) HStack { From cabca0aca8cfec5386ac52fac8a422da750fb3ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 08:56:07 -0700 Subject: [PATCH 224/702] terminal: unify palette functionality into shared type DynamicPalette --- src/inspector/cell.zig | 4 +- src/inspector/cursor.zig | 4 +- src/renderer/generic.zig | 2 +- src/terminal/Terminal.zig | 34 +++-- src/terminal/color.zig | 208 +++++++++++++++++++++++++++++++ src/terminal/formatter.zig | 38 +++--- src/terminal/stream_readonly.zig | 52 +++----- src/termio/Termio.zig | 15 +-- src/termio/stream_handler.zig | 29 ++--- 9 files changed, 288 insertions(+), 98 deletions(-) diff --git a/src/inspector/cell.zig b/src/inspector/cell.zig index 9a3112bdd..b2dc59fef 100644 --- a/src/inspector/cell.zig +++ b/src/inspector/cell.zig @@ -130,7 +130,7 @@ pub const Cell = struct { switch (self.style.fg_color) { .none => cimgui.c.igText("default"), .palette => |idx| { - const rgb = t.color_palette.colors[idx]; + const rgb = t.colors.palette.current[idx]; cimgui.c.igValue_Int("Palette", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @@ -169,7 +169,7 @@ pub const Cell = struct { switch (self.style.bg_color) { .none => cimgui.c.igText("default"), .palette => |idx| { - const rgb = t.color_palette.colors[idx]; + const rgb = t.colors.palette.current[idx]; cimgui.c.igValue_Int("Palette", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig index be1cd63fe..37ec412e9 100644 --- a/src/inspector/cursor.zig +++ b/src/inspector/cursor.zig @@ -51,7 +51,7 @@ pub fn renderInTable( switch (cursor.style.fg_color) { .none => cimgui.c.igText("default"), .palette => |idx| { - const rgb = t.color_palette.colors[idx]; + const rgb = t.colors.palette.current[idx]; cimgui.c.igValue_Int("Palette", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, @@ -90,7 +90,7 @@ pub fn renderInTable( switch (cursor.style.bg_color) { .none => cimgui.c.igText("default"), .palette => |idx| { - const rgb = t.color_palette.colors[idx]; + const rgb = t.colors.palette.current[idx]; cimgui.c.igValue_Int("Palette", idx); var color: [3]f32 = .{ @as(f32, @floatFromInt(rgb.r)) / 255, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 9e13d0b41..9d4e14ea7 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1258,7 +1258,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .mouse = state.mouse, .preedit = preedit, .cursor_style = cursor_style, - .color_palette = state.terminal.color_palette.colors, + .color_palette = state.terminal.colors.palette.current, .scrollbar = scrollbar, .full_rebuild = full_rebuild, }; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2201a324c..64cda5ee3 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -73,17 +73,8 @@ scrolling_region: ScrollingRegion, /// The last reported pwd, if any. pwd: std.ArrayList(u8), -/// The default color palette. This is only modified by changing the config file -/// and is used to reset the palette when receiving an OSC 104 command. -default_palette: color.Palette = color.default, - -/// The color palette to use. The mask indicates which palette indices have been -/// modified with OSC 4 -color_palette: struct { - const Mask = std.StaticBitSet(@typeInfo(color.Palette).array.len); - colors: color.Palette = color.default, - mask: Mask = .initEmpty(), -} = .{}, +/// The color state for this terminal. +colors: Colors, /// The previous printed character. This is used for the repeat previous /// char CSI (ESC [ b). @@ -134,6 +125,23 @@ flags: packed struct { dirty: Dirty = .{}, } = .{}, +/// The various color configurations a terminal maintains and that can +/// be set dynamically via OSC, with defaults usually coming from a +/// configuration. +pub const Colors = struct { + background: color.DynamicRGB, + foreground: color.DynamicRGB, + cursor: color.DynamicRGB, + palette: color.DynamicPalette, + + pub const default: Colors = .{ + .background = .unset, + .foreground = .unset, + .cursor = .unset, + .palette = .default, + }; +}; + /// This is a set of dirty flags the renderer can use to determine /// what parts of the screen need to be redrawn. It is up to the renderer /// to clear these flags. @@ -199,6 +207,7 @@ pub const Options = struct { cols: size.CellCountInt, rows: size.CellCountInt, max_scrollback: usize = 10_000, + colors: Colors = .default, /// The default mode state. When the terminal gets a reset, it /// will revert back to this state. @@ -212,7 +221,7 @@ pub fn init( ) !Terminal { const cols = opts.cols; const rows = opts.rows; - return Terminal{ + return .{ .cols = cols, .rows = rows, .active_screen = .primary, @@ -226,6 +235,7 @@ pub fn init( .right = cols - 1, }, .pwd = .empty, + .colors = opts.colors, .modes = .{ .values = opts.default_modes, .default = opts.default_modes, diff --git a/src/terminal/color.zig b/src/terminal/color.zig index b71279dbb..e4b71fe63 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -1,3 +1,5 @@ +const colorpkg = @This(); + const std = @import("std"); const assert = std.debug.assert; const x11_color = @import("x11_color.zig"); @@ -45,6 +47,97 @@ pub const default: Palette = default: { /// Palette is the 256 color palette. pub const Palette = [256]RGB; +/// A palette that can have its colors changed and reset. Purposely built +/// for terminal color operations. +pub const DynamicPalette = struct { + /// The current palette including any user modifications. + current: Palette, + + /// The original/default palette values. + original: Palette, + + /// A bitset where each bit represents whether the corresponding + /// palette index has been modified from its default value. + mask: Mask, + + const Mask = std.StaticBitSet(@typeInfo(Palette).array.len); + + pub const default: DynamicPalette = .init(colorpkg.default); + + /// Initialize a dynamic palette with a default palette. + pub fn init(def: Palette) DynamicPalette { + return .{ + .current = def, + .original = def, + .mask = .initEmpty(), + }; + } + + /// Set a custom color at the given palette index. + pub fn set(self: *DynamicPalette, idx: u8, color: RGB) void { + self.current[idx] = color; + self.mask.set(idx); + } + + /// Reset the color at the given palette index to its original value. + pub fn reset(self: *DynamicPalette, idx: u8) void { + self.current[idx] = self.original[idx]; + self.mask.unset(idx); + } + + /// Reset all colors to their original values. + pub fn resetAll(self: *DynamicPalette) void { + self.* = .init(self.original); + } + + /// Change the default palette, but preserve the changed values. + pub fn changeDefault(self: *DynamicPalette, def: Palette) void { + self.original = def; + + // Fast path, the palette is usually not changed. + if (self.mask.count() == 0) { + self.current = self.original; + return; + } + + // There are usually less set than unset, so iterate over the changed + // values and override them. + var current = def; + var it = self.mask.iterator(.{}); + while (it.next()) |idx| current[idx] = self.current[idx]; + self.current = current; + } +}; + +/// RGB value that can be changed and reset. This can also be totally unset +/// in every way, in which case the caller can determine their own ultimate +/// default. +pub const DynamicRGB = struct { + override: ?RGB, + default: ?RGB, + + pub const unset: DynamicRGB = .{ .override = null, .default = null }; + + pub fn init(def: RGB) DynamicRGB { + return .{ + .override = null, + .default = def, + }; + } + + pub fn get(self: *const DynamicRGB) ?RGB { + return self.override orelse self.default; + } + + pub fn set(self: *DynamicRGB, color: RGB) void { + self.current = color; + } + + pub fn reset(self: *DynamicRGB) void { + self.current = self.default; + } +}; + /// Color names in the standard 8 or 16 color palette. pub const Name = enum(u8) { black = 0, @@ -456,3 +549,118 @@ test "RGB.parse" { try testing.expectError(error.InvalidFormat, RGB.parse("#fffff")); try testing.expectError(error.InvalidFormat, RGB.parse("#gggggg")); } + +test "DynamicPalette: init" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + try testing.expectEqual(default, p.current); + try testing.expectEqual(default, p.original); + try testing.expectEqual(@as(usize, 0), p.mask.count()); +} + +test "DynamicPalette: set" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + const new_color = RGB{ .r = 255, .g = 0, .b = 0 }; + + p.set(0, new_color); + try testing.expectEqual(new_color, p.current[0]); + try testing.expect(p.mask.isSet(0)); + try testing.expectEqual(@as(usize, 1), p.mask.count()); + + try testing.expectEqual(default[0], p.original[0]); +} + +test "DynamicPalette: reset" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + const new_color = RGB{ .r = 255, .g = 0, .b = 0 }; + + p.set(0, new_color); + try testing.expect(p.mask.isSet(0)); + + p.reset(0); + try testing.expectEqual(default[0], p.current[0]); + try testing.expect(!p.mask.isSet(0)); + try testing.expectEqual(@as(usize, 0), p.mask.count()); +} + +test "DynamicPalette: resetAll" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + const new_color = RGB{ .r = 255, .g = 0, .b = 0 }; + + p.set(0, new_color); + p.set(5, new_color); + p.set(10, new_color); + try testing.expectEqual(@as(usize, 3), p.mask.count()); + + p.resetAll(); + try testing.expectEqual(default, p.current); + try testing.expectEqual(default, p.original); + try testing.expectEqual(@as(usize, 0), p.mask.count()); +} + +test "DynamicPalette: changeDefault with no changes" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + var new_palette = default; + new_palette[0] = RGB{ .r = 100, .g = 100, .b = 100 }; + + p.changeDefault(new_palette); + try testing.expectEqual(new_palette, p.original); + try testing.expectEqual(new_palette, p.current); + try testing.expectEqual(@as(usize, 0), p.mask.count()); +} + +test "DynamicPalette: changeDefault preserves changes" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + const custom_color = RGB{ .r = 255, .g = 0, .b = 0 }; + + p.set(5, custom_color); + try testing.expect(p.mask.isSet(5)); + + var new_palette = default; + new_palette[0] = RGB{ .r = 100, .g = 100, .b = 100 }; + new_palette[5] = RGB{ .r = 50, .g = 50, .b = 50 }; + + p.changeDefault(new_palette); + + try testing.expectEqual(new_palette, p.original); + try testing.expectEqual(new_palette[0], p.current[0]); + try testing.expectEqual(custom_color, p.current[5]); + try testing.expect(p.mask.isSet(5)); + try testing.expectEqual(@as(usize, 1), p.mask.count()); +} + +test "DynamicPalette: changeDefault with multiple changes" { + const testing = std.testing; + + var p: DynamicPalette = .init(default); + const red = RGB{ .r = 255, .g = 0, .b = 0 }; + const green = RGB{ .r = 0, .g = 255, .b = 0 }; + const blue = RGB{ .r = 0, .g = 0, .b = 255 }; + + p.set(1, red); + p.set(2, green); + p.set(3, blue); + + var new_palette = default; + new_palette[0] = RGB{ .r = 50, .g = 50, .b = 50 }; + new_palette[1] = RGB{ .r = 60, .g = 60, .b = 60 }; + + p.changeDefault(new_palette); + + try testing.expectEqual(new_palette[0], p.current[0]); + try testing.expectEqual(red, p.current[1]); + try testing.expectEqual(green, p.current[2]); + try testing.expectEqual(blue, p.current[3]); + try testing.expectEqual(@as(usize, 3), p.mask.count()); +} diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 246624d5b..20dcf9a89 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -225,24 +225,24 @@ pub const TerminalFormatter = struct { .plain => break :palette, .vt => { - for (self.terminal.color_palette.colors, 0..) |rgb, i| { - try writer.print( - "\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", - .{ i, rgb.r, rgb.g, rgb.b }, - ); - } + for (self.terminal.colors.palette.current, 0..) |rgb, i| { + try writer.print( + "\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", + .{ i, rgb.r, rgb.g, rgb.b }, + ); + } }, // For HTML, we emit CSS to setup our palette variables. .html => { - try writer.writeAll(""); + try writer.writeAll(""); }, } @@ -3839,9 +3839,9 @@ test "TerminalFormatter vt with palette" { try s2.nextSlice(output); // Verify the palettes match - try testing.expectEqual(t.color_palette.colors[0], t2.color_palette.colors[0]); - try testing.expectEqual(t.color_palette.colors[1], t2.color_palette.colors[1]); - try testing.expectEqual(t.color_palette.colors[255], t2.color_palette.colors[255]); + try testing.expectEqual(t.colors.palette.current[0], t2.colors.palette.current[0]); + try testing.expectEqual(t.colors.palette.current[1], t2.colors.palette.current[1]); + try testing.expectEqual(t.colors.palette.current[255], t2.colors.palette.current[255]); } test "TerminalFormatter with selection" { @@ -4972,7 +4972,7 @@ test "Page VT with palette option emits RGB" { { builder.clearRetainingCapacity(); var opts: Options = .vt; - opts.palette = &t.color_palette.colors; + opts.palette = &t.colors.palette.current; var formatter: PageFormatter = .init(page, opts); try formatter.format(&builder.writer); const output = builder.writer.buffered(); @@ -5021,7 +5021,7 @@ test "Page html with palette option emits RGB" { { builder.clearRetainingCapacity(); var opts: Options = .{ .emit = .html }; - opts.palette = &t.color_palette.colors; + opts.palette = &t.colors.palette.current; var formatter: PageFormatter = .init(page, opts); try formatter.format(&builder.writer); const output = builder.writer.buffered(); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 86a525284..f73d21dce 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -305,10 +305,7 @@ pub const Handler = struct { switch (req.*) { .set => |set| { switch (set.target) { - .palette => |i| { - self.terminal.color_palette.colors[i] = set.color; - self.terminal.color_palette.mask.set(i); - }, + .palette => |i| self.terminal.colors.palette.set(i, set.color), .dynamic, .special, => {}, @@ -316,24 +313,13 @@ pub const Handler = struct { }, .reset => |target| switch (target) { - .palette => |i| { - const mask = &self.terminal.color_palette.mask; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); - }, + .palette => |i| self.terminal.colors.palette.reset(i), .dynamic, .special, => {}, }, - .reset_palette => { - const mask = &self.terminal.color_palette.mask; - var mask_iterator = mask.iterator(.{}); - while (mask_iterator.next()) |i| { - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - } - mask.* = .initEmpty(); - }, + .reset_palette => self.terminal.colors.palette.resetAll(), .query, .reset_special, @@ -599,19 +585,19 @@ test "OSC 4 set and reset palette" { defer s.deinit(); // Save default color - const default_color_0 = t.default_palette[0]; + const default_color_0 = t.colors.palette.original[0]; // Set color 0 to red try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); - try testing.expectEqual(@as(u8, 0xff), t.color_palette.colors[0].r); - try testing.expectEqual(@as(u8, 0x00), t.color_palette.colors[0].g); - try testing.expectEqual(@as(u8, 0x00), t.color_palette.colors[0].b); - try testing.expect(t.color_palette.mask.isSet(0)); + try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[0].r); + try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].g); + try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[0].b); + try testing.expect(t.colors.palette.mask.isSet(0)); // Reset color 0 try s.nextSlice("\x1b]104;0\x1b\\"); - try testing.expectEqual(default_color_0, t.color_palette.colors[0]); - try testing.expect(!t.color_palette.mask.isSet(0)); + try testing.expectEqual(default_color_0, t.colors.palette.current[0]); + try testing.expect(!t.colors.palette.mask.isSet(0)); } test "OSC 104 reset all palette colors" { @@ -625,16 +611,16 @@ test "OSC 104 reset all palette colors" { try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); try s.nextSlice("\x1b]4;1;rgb:00/ff/00\x1b\\"); try s.nextSlice("\x1b]4;2;rgb:00/00/ff\x1b\\"); - try testing.expect(t.color_palette.mask.isSet(0)); - try testing.expect(t.color_palette.mask.isSet(1)); - try testing.expect(t.color_palette.mask.isSet(2)); + try testing.expect(t.colors.palette.mask.isSet(0)); + try testing.expect(t.colors.palette.mask.isSet(1)); + try testing.expect(t.colors.palette.mask.isSet(2)); // Reset all palette colors try s.nextSlice("\x1b]104\x1b\\"); - try testing.expectEqual(t.default_palette[0], t.color_palette.colors[0]); - try testing.expectEqual(t.default_palette[1], t.color_palette.colors[1]); - try testing.expectEqual(t.default_palette[2], t.color_palette.colors[2]); - try testing.expect(!t.color_palette.mask.isSet(0)); - try testing.expect(!t.color_palette.mask.isSet(1)); - try testing.expect(!t.color_palette.mask.isSet(2)); + try testing.expectEqual(t.colors.palette.original[0], t.colors.palette.current[0]); + try testing.expectEqual(t.colors.palette.original[1], t.colors.palette.current[1]); + try testing.expectEqual(t.colors.palette.original[2], t.colors.palette.current[2]); + try testing.expect(!t.colors.palette.mask.isSet(0)); + try testing.expect(!t.colors.palette.mask.isSet(1)); + try testing.expect(!t.colors.palette.mask.isSet(2)); } diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 01a8ef312..f5e0af221 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -234,8 +234,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { }; }); errdefer term.deinit(alloc); - term.default_palette = opts.config.palette; - term.color_palette.colors = opts.config.palette; + term.colors.palette.changeDefault(opts.config.palette); // Set the image size limits try term.screen.kitty_images.setLimit( @@ -451,16 +450,8 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi // Update the default palette. Note this will only apply to new colors drawn // since we decode all palette colors to RGB on usage. - self.terminal.default_palette = config.palette; - - // Update the active palette, except for any colors that were modified with - // OSC 4 - for (0..config.palette.len) |i| { - if (!self.terminal.color_palette.mask.isSet(i)) { - self.terminal.color_palette.colors[i] = config.palette[i]; - self.terminal.flags.dirty.palette = true; - } - } + self.terminal.colors.palette.changeDefault(config.palette); + self.terminal.flags.dirty.palette = true; // Set the image size limits try self.terminal.screen.kitty_images.setLimit( diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 7f241f42c..8f3f845d6 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -1133,8 +1133,7 @@ pub const StreamHandler = struct { switch (set.target) { .palette => |i| { self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = set.color; - self.terminal.color_palette.mask.set(i); + self.terminal.colors.palette.set(i, set.color); }, .dynamic => |dynamic| switch (dynamic) { .foreground => { @@ -1178,15 +1177,13 @@ pub const StreamHandler = struct { .reset => |target| switch (target) { .palette => |i| { - const mask = &self.terminal.color_palette.mask; self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; - mask.unset(i); + self.terminal.colors.palette.reset(i); self.surfaceMessageWriter(.{ .color_change = .{ .target = target, - .color = self.terminal.color_palette.colors[i], + .color = self.terminal.colors.palette.current[i], }, }); }, @@ -1242,15 +1239,15 @@ pub const StreamHandler = struct { }, .reset_palette => { - const mask = &self.terminal.color_palette.mask; - var mask_iterator = mask.iterator(.{}); - while (mask_iterator.next()) |i| { + const mask = &self.terminal.colors.palette.mask; + var mask_it = mask.iterator(.{}); + while (mask_it.next()) |i| { self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + self.terminal.colors.palette.reset(@intCast(i)); self.surfaceMessageWriter(.{ .color_change = .{ .target = .{ .palette = @intCast(i) }, - .color = self.terminal.color_palette.colors[i], + .color = self.terminal.colors.palette.current[i], }, }); } @@ -1266,7 +1263,7 @@ pub const StreamHandler = struct { if (self.osc_color_report_format == .none) break :report; const color = switch (kind) { - .palette => |i| self.terminal.color_palette.colors[i], + .palette => |i| self.terminal.colors.palette.current[i], .dynamic => |dynamic| switch (dynamic) { .foreground => self.foreground_color orelse self.default_foreground_color, .background => self.background_color orelse self.default_background_color, @@ -1399,7 +1396,7 @@ pub const StreamHandler = struct { if (stream.written().len == 0) try writer.writeAll("\x1b]21"); const color: terminal.color.RGB = switch (key) { - .palette => |palette| self.terminal.color_palette.colors[palette], + .palette => |palette| self.terminal.colors.palette.current[palette], .special => |special| switch (special) { .foreground => self.foreground_color orelse self.default_foreground_color, .background => self.background_color orelse self.default_background_color, @@ -1422,8 +1419,7 @@ pub const StreamHandler = struct { .set => |v| switch (v.key) { .palette => |palette| { self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[palette] = v.color; - self.terminal.color_palette.mask.unset(palette); + self.terminal.colors.palette.set(palette, v.color); }, .special => |special| { @@ -1457,8 +1453,7 @@ pub const StreamHandler = struct { .reset => |key| switch (key) { .palette => |palette| { self.terminal.flags.dirty.palette = true; - self.terminal.color_palette.colors[palette] = self.terminal.default_palette[palette]; - self.terminal.color_palette.mask.unset(palette); + self.terminal.colors.palette.reset(palette); }, .special => |special| { From 77343bb06e65c7d7015e367ef4461f2bece80c4b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 09:26:58 -0700 Subject: [PATCH 225/702] terminal: move color state fully into the terminal for fg/bg/cursor --- src/config/Config.zig | 7 ++ src/terminal/color.zig | 4 +- src/termio/Termio.zig | 69 +++++++++---------- src/termio/stream_handler.zig | 126 ++++++++++++++-------------------- 4 files changed, 92 insertions(+), 114 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index a9aaf8f86..78ea19aef 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4960,6 +4960,13 @@ pub const TerminalColor = union(enum) { return .{ .color = try Color.parseCLI(input) }; } + pub fn toTerminalRGB(self: TerminalColor) ?terminal.color.RGB { + return switch (self) { + .color => |v| v.toTerminalRGB(), + .@"cell-foreground", .@"cell-background" => null, + }; + } + /// Used by Formatter pub fn formatEntry(self: TerminalColor, formatter: formatterpkg.EntryFormatter) !void { switch (self) { diff --git a/src/terminal/color.zig b/src/terminal/color.zig index e4b71fe63..4492d65ae 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -130,11 +130,11 @@ pub const DynamicRGB = struct { } pub fn set(self: *DynamicRGB, color: RGB) void { - self.current = color; + self.override = color; } pub fn reset(self: *DynamicRGB) void { - self.current = self.default; + self.override = self.default; } }; diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index f5e0af221..1e181a137 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -231,10 +231,19 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .rows = grid_size.rows, .max_scrollback = opts.full_config.@"scrollback-limit", .default_modes = default_modes, + .colors = .{ + .background = .init(opts.config.background.toTerminalRGB()), + .foreground = .init(opts.config.foreground.toTerminalRGB()), + .cursor = cursor: { + const color = opts.config.cursor_color orelse break :cursor .unset; + const rgb = color.toTerminalRGB() orelse break :cursor .unset; + break :cursor .init(rgb); + }, + .palette = .init(opts.config.palette), + }, }; }); errdefer term.deinit(alloc); - term.colors.palette.changeDefault(opts.config.palette); // Set the image size limits try term.screen.kitty_images.setLimit( @@ -261,39 +270,20 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { // Create our stream handler. This points to memory in self so it // isn't safe to use until self.* is set. - const handler: StreamHandler = handler: { - const default_cursor_color: ?terminalpkg.color.RGB = color: { - if (opts.config.cursor_color) |color| switch (color) { - .color => break :color color.color.toTerminalRGB(), - .@"cell-foreground", - .@"cell-background", - => {}, - }; - - break :color null; - }; - - break :handler .{ - .alloc = alloc, - .termio_mailbox = &self.mailbox, - .surface_mailbox = opts.surface_mailbox, - .renderer_state = opts.renderer_state, - .renderer_wakeup = opts.renderer_wakeup, - .renderer_mailbox = opts.renderer_mailbox, - .size = &self.size, - .terminal = &self.terminal, - .osc_color_report_format = opts.config.osc_color_report_format, - .clipboard_write = opts.config.clipboard_write, - .enquiry_response = opts.config.enquiry_response, - .default_foreground_color = opts.config.foreground.toTerminalRGB(), - .default_background_color = opts.config.background.toTerminalRGB(), - .default_cursor_style = opts.config.cursor_style, - .default_cursor_blink = opts.config.cursor_blink, - .default_cursor_color = default_cursor_color, - .cursor_color = null, - .foreground_color = null, - .background_color = null, - }; + const handler: StreamHandler = .{ + .alloc = alloc, + .termio_mailbox = &self.mailbox, + .surface_mailbox = opts.surface_mailbox, + .renderer_state = opts.renderer_state, + .renderer_wakeup = opts.renderer_wakeup, + .renderer_mailbox = opts.renderer_mailbox, + .size = &self.size, + .terminal = &self.terminal, + .osc_color_report_format = opts.config.osc_color_report_format, + .clipboard_write = opts.config.clipboard_write, + .enquiry_response = opts.config.enquiry_response, + .default_cursor_style = opts.config.cursor_style, + .default_cursor_blink = opts.config.cursor_blink, }; const thread_enter_state = try ThreadEnterState.create( @@ -448,11 +438,18 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi // - command, working-directory: we never restart the underlying // process so we don't care or need to know about these. - // Update the default palette. Note this will only apply to new colors drawn - // since we decode all palette colors to RGB on usage. + // Update the default palette. self.terminal.colors.palette.changeDefault(config.palette); self.terminal.flags.dirty.palette = true; + // Update all our other colors + self.terminal.colors.background.default = config.background.toTerminalRGB(); + self.terminal.colors.foreground.default = config.foreground.toTerminalRGB(); + self.terminal.colors.cursor.default = cursor: { + const color = config.cursor_color orelse break :cursor null; + break :cursor color.toTerminalRGB() orelse break :cursor null; + }; + // Set the image size limits try self.terminal.screen.kitty_images.setLimit( self.alloc, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8f3f845d6..551145cfb 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -45,22 +45,6 @@ pub const StreamHandler = struct { default_cursor: bool = true, default_cursor_style: terminal.CursorStyle, default_cursor_blink: ?bool, - default_cursor_color: ?terminal.color.RGB, - - /// Actual cursor color. This can be changed with OSC 12. If unset, falls - /// back to the default cursor color. - cursor_color: ?terminal.color.RGB, - - /// The default foreground and background color are those set by the user's - /// config file. - default_foreground_color: terminal.color.RGB, - default_background_color: terminal.color.RGB, - - /// The foreground and background color as set by an OSC 10 or OSC 11 - /// sequence. If unset then the respective color falls back to the default - /// value. - foreground_color: ?terminal.color.RGB, - background_color: ?terminal.color.RGB, /// The response to use for ENQ requests. The memory is owned by /// whoever owns StreamHandler. @@ -114,20 +98,8 @@ pub const StreamHandler = struct { self.osc_color_report_format = config.osc_color_report_format; self.clipboard_write = config.clipboard_write; self.enquiry_response = config.enquiry_response; - self.default_foreground_color = config.foreground.toTerminalRGB(); - self.default_background_color = config.background.toTerminalRGB(); self.default_cursor_style = config.cursor_style; self.default_cursor_blink = config.cursor_blink; - self.default_cursor_color = color: { - if (config.cursor_color) |color| switch (color) { - .color => break :color color.color.toTerminalRGB(), - .@"cell-foreground", - .@"cell-background", - => {}, - }; - - break :color null; - }; // If our cursor is the default, then we update it immediately. if (self.default_cursor) self.setCursorStyle(.default) catch |err| { @@ -1137,19 +1109,19 @@ pub const StreamHandler = struct { }, .dynamic => |dynamic| switch (dynamic) { .foreground => { - self.foreground_color = set.color; + self.terminal.colors.foreground.set(set.color); self.rendererMessageWriter(.{ .foreground_color = set.color, }); }, .background => { - self.background_color = set.color; + self.terminal.colors.background.set(set.color); self.rendererMessageWriter(.{ .background_color = set.color, }); }, .cursor => { - self.cursor_color = set.color; + self.terminal.colors.cursor.set(set.color); self.rendererMessageWriter(.{ .cursor_color = set.color, }); @@ -1189,38 +1161,42 @@ pub const StreamHandler = struct { }, .dynamic => |dynamic| switch (dynamic) { .foreground => { - self.foreground_color = null; + self.terminal.colors.foreground.reset(); self.rendererMessageWriter(.{ - .foreground_color = self.foreground_color, + .foreground_color = null, }); - self.surfaceMessageWriter(.{ .color_change = .{ - .target = target, - .color = self.default_foreground_color, - } }); - }, - .background => { - self.background_color = null; - self.rendererMessageWriter(.{ - .background_color = self.background_color, - }); - - self.surfaceMessageWriter(.{ .color_change = .{ - .target = target, - .color = self.default_background_color, - } }); - }, - .cursor => { - self.cursor_color = null; - - self.rendererMessageWriter(.{ - .cursor_color = self.cursor_color, - }); - - if (self.default_cursor_color) |color| { + if (self.terminal.colors.foreground.default) |c| { self.surfaceMessageWriter(.{ .color_change = .{ .target = target, - .color = color, + .color = c, + } }); + } + }, + .background => { + self.terminal.colors.background.reset(); + self.rendererMessageWriter(.{ + .background_color = null, + }); + + if (self.terminal.colors.background.default) |c| { + self.surfaceMessageWriter(.{ .color_change = .{ + .target = target, + .color = c, + } }); + } + }, + .cursor => { + self.terminal.colors.cursor.reset(); + + self.rendererMessageWriter(.{ + .cursor_color = null, + }); + + if (self.terminal.colors.cursor.default) |c| { + self.surfaceMessageWriter(.{ .color_change = .{ + .target = target, + .color = c, } }); } }, @@ -1265,12 +1241,10 @@ pub const StreamHandler = struct { const color = switch (kind) { .palette => |i| self.terminal.colors.palette.current[i], .dynamic => |dynamic| switch (dynamic) { - .foreground => self.foreground_color orelse self.default_foreground_color, - .background => self.background_color orelse self.default_background_color, - .cursor => self.cursor_color orelse - self.default_cursor_color orelse - self.foreground_color orelse - self.default_foreground_color, + .foreground => self.terminal.colors.foreground.get().?, + .background => self.terminal.colors.background.get().?, + .cursor => self.terminal.colors.cursor.get() orelse + self.terminal.colors.foreground.get().?, .pointer_foreground, .pointer_background, .tektronix_foreground, @@ -1398,9 +1372,9 @@ pub const StreamHandler = struct { const color: terminal.color.RGB = switch (key) { .palette => |palette| self.terminal.colors.palette.current[palette], .special => |special| switch (special) { - .foreground => self.foreground_color orelse self.default_foreground_color, - .background => self.background_color orelse self.default_background_color, - .cursor => self.cursor_color orelse self.default_cursor_color, + .foreground => self.terminal.colors.foreground.get(), + .background => self.terminal.colors.background.get(), + .cursor => self.terminal.colors.cursor.get(), else => { log.warn("ignoring unsupported kitty color protocol key: {f}", .{key}); continue; @@ -1425,15 +1399,15 @@ pub const StreamHandler = struct { .special => |special| { const msg: renderer.Message = switch (special) { .foreground => msg: { - self.foreground_color = v.color; + self.terminal.colors.foreground.set(v.color); break :msg .{ .foreground_color = v.color }; }, .background => msg: { - self.background_color = v.color; + self.terminal.colors.background.set(v.color); break :msg .{ .background_color = v.color }; }, .cursor => msg: { - self.cursor_color = v.color; + self.terminal.colors.cursor.set(v.color); break :msg .{ .cursor_color = v.color }; }, else => { @@ -1459,16 +1433,16 @@ pub const StreamHandler = struct { .special => |special| { const msg: renderer.Message = switch (special) { .foreground => msg: { - self.foreground_color = null; - break :msg .{ .foreground_color = self.foreground_color }; + self.terminal.colors.foreground.reset(); + break :msg .{ .foreground_color = null }; }, .background => msg: { - self.background_color = null; - break :msg .{ .background_color = self.background_color }; + self.terminal.colors.background.reset(); + break :msg .{ .background_color = null }; }, .cursor => msg: { - self.cursor_color = null; - break :msg .{ .cursor_color = self.cursor_color }; + self.terminal.colors.cursor.reset(); + break :msg .{ .cursor_color = null }; }, else => { log.warn( From 2daecd94a5a9b3fd67a5267f91f3a17b0e3a3818 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 09:52:36 -0700 Subject: [PATCH 226/702] renderer: use terminal color state, remove color messages --- src/renderer/Thread.zig | 15 ---- src/renderer/generic.zig | 135 ++++++++++++---------------------- src/renderer/message.zig | 10 --- src/termio/stream_handler.zig | 115 +++++++---------------------- 4 files changed, 75 insertions(+), 200 deletions(-) diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 210c2e337..fd9d0f51a 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -437,21 +437,6 @@ fn drainMailbox(self: *Thread) !void { grid.set.deref(grid.old_key); }, - .foreground_color => |color| { - self.renderer.foreground_color = color; - self.renderer.markDirty(); - }, - - .background_color => |color| { - self.renderer.background_color = color; - self.renderer.markDirty(); - }, - - .cursor_color => |color| { - self.renderer.cursor_color = color; - self.renderer.markDirty(); - }, - .resize => |v| self.renderer.setScreenSize(v), .change_config => |config| { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 9d4e14ea7..0b4c55896 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -120,30 +120,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { scrollbar: terminal.Scrollbar, scrollbar_dirty: bool, - /// The foreground color set by an OSC 10 sequence. If unset then - /// default_foreground_color is used. - foreground_color: ?terminal.color.RGB, - - /// Foreground color set in the user's config file. - default_foreground_color: terminal.color.RGB, - - /// The background color set by an OSC 11 sequence. If unset then - /// default_background_color is used. - background_color: ?terminal.color.RGB, - - /// Background color set in the user's config file. - default_background_color: terminal.color.RGB, - - /// The cursor color set by an OSC 12 sequence. If unset then - /// default_cursor_color is used. - cursor_color: ?terminal.color.RGB, - - /// Default cursor color when no color is set explicitly by an OSC 12 command. - /// This is cursor color as set in the user's config, if any. If no cursor color - /// is set in the user's config, then the cursor color is determined by the - /// current foreground color. - default_cursor_color: ?configpkg.Config.TerminalColor, - /// 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. @@ -691,12 +667,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .focused = true, .scrollbar = .zero, .scrollbar_dirty = false, - .foreground_color = null, - .default_foreground_color = options.config.foreground, - .background_color = null, - .default_background_color = options.config.background, - .cursor_color = null, - .default_cursor_color = options.config.cursor_color, // Render state .cells = .{}, @@ -1094,10 +1064,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Data we extract out of the critical area. const Critical = struct { bg: terminal.color.RGB, + fg: terminal.color.RGB, screen: terminal.Screen, screen_type: terminal.ScreenType, 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, @@ -1132,36 +1104,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // cross-thread mailbox message within the IO path. const scrollbar = state.terminal.screen.pages.scrollbar(); - // Swap bg/fg if the terminal is reversed - const bg = self.background_color orelse self.default_background_color; - const fg = self.foreground_color orelse self.default_foreground_color; - defer { - if (self.background_color) |*c| { - c.* = bg; - } else { - self.default_background_color = bg; - } - - if (self.foreground_color) |*c| { - c.* = fg; - } else { - self.default_foreground_color = fg; - } - } - - if (state.terminal.modes.get(.reverse_colors)) { - if (self.background_color) |*c| { - c.* = fg; - } else { - self.default_background_color = fg; - } - - if (self.foreground_color) |*c| { - c.* = bg; - } else { - self.default_foreground_color = bg; - } - } + // 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 }; + }; // Get the viewport pin so that we can compare it to the current. const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?; @@ -1252,11 +1204,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.cells_viewport = viewport_pin; break :critical .{ - .bg = self.background_color orelse self.default_background_color, + .bg = bg, + .fg = fg, .screen = screen_copy, .screen_type = state.terminal.active_screen, .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, @@ -1277,6 +1231,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 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, @@ -2104,11 +2061,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.uniforms.bools.use_linear_blending = config.blending.isLinear(); self.uniforms.bools.use_linear_correction = config.blending == .@"linear-corrected"; - // Set our new colors - self.default_background_color = config.background; - self.default_foreground_color = config.foreground; - self.default_cursor_color = config.cursor_color; - const bg_image_config_changed = self.config.bg_image_fit != config.bg_image_fit or self.config.bg_image_position != config.bg_image_position or @@ -2370,6 +2322,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 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 { self.draw_mutex.lock(); defer self.draw_mutex.unlock(); @@ -2503,12 +2458,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .extend => if (y == 0) { self.uniforms.padding_extend.up = !row.neverExtendBg( color_palette, - self.background_color orelse self.default_background_color, + background, ); } else if (y == self.cells.size.rows - 1) { self.uniforms.padding_extend.down = !row.neverExtendBg( color_palette, - self.background_color orelse self.default_background_color, + background, ); }, } @@ -2629,7 +2584,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // configuration, inversions, selections, etc. const bg_style = style.bg(cell, color_palette); const fg_style = style.fg(.{ - .default = self.foreground_color orelse self.default_foreground_color, + .default = foreground, .palette = color_palette, .bold = self.config.bold_color, }); @@ -2649,7 +2604,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If no configuration, then our selection background // is our foreground color. - break :bg self.foreground_color orelse self.default_foreground_color; + break :bg foreground; } // Not selected @@ -2671,9 +2626,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 - self.background_color orelse - self.default_background_color; + const final_bg = bg_style orelse background; // Whether we need to use the bg color as our fg color: // - Cell is selected, inverted, and set to cell-foreground @@ -2689,7 +2642,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; } - break :fg self.background_color orelse self.default_background_color; + break :fg background; } break :fg if (style.flags.inverse) @@ -2703,7 +2656,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Set the cell's background color. { - const rgb = bg orelse self.background_color orelse self.default_background_color; + const rgb = bg orelse background; // Determine our background alpha. If we have transparency configured // then this is dynamic depending on some situations. This is all @@ -2888,24 +2841,24 @@ 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 (self.cursor_color) |v| break :cursor_color v; + if (terminal_cursor_color) |v| break :cursor_color v; // Use our configured color if specified - if (self.default_cursor_color) |v| switch (v) { + if (self.config.cursor_color) |v| switch (v) { .color => |color| break :cursor_color color.toTerminalRGB(), inline .@"cell-foreground", .@"cell-background", => |_, tag| { const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); const fg_style = sty.fg(.{ - .default = self.foreground_color orelse self.default_foreground_color, + .default = foreground, .palette = color_palette, .bold = self.config.bold_color, }); const bg_style = sty.bg( screen.cursor.page_cell, color_palette, - ) orelse self.background_color orelse self.default_background_color; + ) orelse background; break :cursor_color switch (tag) { .color => unreachable, @@ -2915,7 +2868,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }, }; - break :cursor_color self.foreground_color orelse self.default_foreground_color; + break :cursor_color foreground; }; self.addCursor(screen, style, cursor_color); @@ -2950,11 +2903,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); const fg_style = sty.fg(.{ - .default = self.foreground_color orelse self.default_foreground_color, + .default = foreground, .palette = color_palette, .bold = self.config.bold_color, }); - const bg_style = sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color; + const bg_style = sty.bg( + screen.cursor.page_cell, + color_palette, + ) orelse background; break :blk switch (txt) { // If the cell is reversed, use the opposite cell color instead. @@ -2962,7 +2918,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .@"cell-background" => if (sty.flags.inverse) fg_style else bg_style, else => unreachable, }; - } else self.background_color orelse self.default_background_color; + } else background; self.uniforms.cursor_color = .{ uniform_color.r, @@ -2978,7 +2934,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const range = preedit_range.?; var x = range.x[0]; for (preedit_v.codepoints[range.cp_offset..]) |cp| { - self.addPreeditCell(cp, .{ .x = x, .y = range.y }) catch |err| { + self.addPreeditCell( + cp, + .{ .x = x, .y = range.y }, + background, + foreground, + ) catch |err| { log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{ x, range.y, @@ -3253,10 +3214,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, cp: renderer.State.Preedit.Codepoint, coord: terminal.Coordinate, + screen_bg: terminal.color.RGB, + screen_fg: terminal.color.RGB, ) !void { // Preedit is rendered inverted - const bg = self.foreground_color orelse self.default_foreground_color; - const fg = self.background_color orelse self.default_background_color; + const bg = screen_fg; + const fg = screen_bg; // Render the glyph for our preedit text const render_ = self.font_grid.renderCodepoint( diff --git a/src/renderer/message.zig b/src/renderer/message.zig index d6255661f..e33922ae2 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -42,16 +42,6 @@ pub const Message = union(enum) { old_key: font.SharedGridSet.Key, }, - /// Change the foreground color as set by an OSC 10 command, if any. - foreground_color: ?terminal.color.RGB, - - /// Change the background color as set by an OSC 11 command, if any. - background_color: ?terminal.color.RGB, - - /// Change the cursor color. This can be done separately from changing the - /// config file in response to an OSC 12 command. - cursor_color: ?terminal.color.RGB, - /// Changes the size. The screen size might change, padding, grid, etc. resize: renderer.Size, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 551145cfb..0131ff2e1 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -196,7 +196,6 @@ pub const StreamHandler = struct { .erase_display_above => self.terminal.eraseDisplay(.above, value), .erase_display_complete => { try self.terminal.scrollViewport(.{ .bottom = {} }); - try self.queueRender(); self.terminal.eraseDisplay(.complete, value); }, .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), @@ -569,10 +568,7 @@ pub const StreamHandler = struct { .autorepeat => {}, // Schedule a render since we changed colors - .reverse_colors => { - self.terminal.flags.dirty.reverse_colors = true; - try self.queueRender(); - }, + .reverse_colors => self.terminal.flags.dirty.reverse_colors = true, // Origin resets cursor pos. This is called whether or not // we're enabling or disabling origin mode and whether or @@ -588,17 +584,14 @@ pub const StreamHandler = struct { .alt_screen_legacy => { self.terminal.switchScreenMode(.@"47", enabled); - try self.queueRender(); }, .alt_screen => { self.terminal.switchScreenMode(.@"1047", enabled); - try self.queueRender(); }, .alt_screen_save_cursor_clear_enter => { self.terminal.switchScreenMode(.@"1049", enabled); - try self.queueRender(); }, // Mode 1048 is xterm's conditional save cursor depending @@ -634,7 +627,6 @@ pub const StreamHandler = struct { // forever. .synchronized_output => { if (enabled) self.messageWriter(.{ .start_synchronized_output = {} }); - try self.queueRender(); }, .linefeed => { @@ -1108,24 +1100,9 @@ pub const StreamHandler = struct { self.terminal.colors.palette.set(i, set.color); }, .dynamic => |dynamic| switch (dynamic) { - .foreground => { - self.terminal.colors.foreground.set(set.color); - self.rendererMessageWriter(.{ - .foreground_color = set.color, - }); - }, - .background => { - self.terminal.colors.background.set(set.color); - self.rendererMessageWriter(.{ - .background_color = set.color, - }); - }, - .cursor => { - self.terminal.colors.cursor.set(set.color); - self.rendererMessageWriter(.{ - .cursor_color = set.color, - }); - }, + .foreground => self.terminal.colors.foreground.set(set.color), + .background => self.terminal.colors.background.set(set.color), + .cursor => self.terminal.colors.cursor.set(set.color), .pointer_foreground, .pointer_background, .tektronix_foreground, @@ -1162,9 +1139,6 @@ pub const StreamHandler = struct { .dynamic => |dynamic| switch (dynamic) { .foreground => { self.terminal.colors.foreground.reset(); - self.rendererMessageWriter(.{ - .foreground_color = null, - }); if (self.terminal.colors.foreground.default) |c| { self.surfaceMessageWriter(.{ .color_change = .{ @@ -1175,9 +1149,6 @@ pub const StreamHandler = struct { }, .background => { self.terminal.colors.background.reset(); - self.rendererMessageWriter(.{ - .background_color = null, - }); if (self.terminal.colors.background.default) |c| { self.surfaceMessageWriter(.{ .color_change = .{ @@ -1189,10 +1160,6 @@ pub const StreamHandler = struct { .cursor => { self.terminal.colors.cursor.reset(); - self.rendererMessageWriter(.{ - .cursor_color = null, - }); - if (self.terminal.colors.cursor.default) |c| { self.surfaceMessageWriter(.{ .color_change = .{ .target = target, @@ -1396,32 +1363,17 @@ pub const StreamHandler = struct { self.terminal.colors.palette.set(palette, v.color); }, - .special => |special| { - const msg: renderer.Message = switch (special) { - .foreground => msg: { - self.terminal.colors.foreground.set(v.color); - break :msg .{ .foreground_color = v.color }; - }, - .background => msg: { - self.terminal.colors.background.set(v.color); - break :msg .{ .background_color = v.color }; - }, - .cursor => msg: { - self.terminal.colors.cursor.set(v.color); - break :msg .{ .cursor_color = v.color }; - }, - else => { - log.warn( - "ignoring unsupported kitty color protocol key: {f}", - .{v.key}, - ); - continue; - }, - }; - - // See messageWriter which has similar logic and - // explains why we may have to do this. - self.rendererMessageWriter(msg); + .special => |special| switch (special) { + .foreground => self.terminal.colors.foreground.set(v.color), + .background => self.terminal.colors.background.set(v.color), + .cursor => self.terminal.colors.cursor.set(v.color), + else => { + log.warn( + "ignoring unsupported kitty color protocol key: {f}", + .{v.key}, + ); + continue; + }, }, }, .reset => |key| switch (key) { @@ -1430,32 +1382,17 @@ pub const StreamHandler = struct { self.terminal.colors.palette.reset(palette); }, - .special => |special| { - const msg: renderer.Message = switch (special) { - .foreground => msg: { - self.terminal.colors.foreground.reset(); - break :msg .{ .foreground_color = null }; - }, - .background => msg: { - self.terminal.colors.background.reset(); - break :msg .{ .background_color = null }; - }, - .cursor => msg: { - self.terminal.colors.cursor.reset(); - break :msg .{ .cursor_color = null }; - }, - else => { - log.warn( - "ignoring unsupported kitty color protocol key: {f}", - .{key}, - ); - continue; - }, - }; - - // See messageWriter which has similar logic and - // explains why we may have to do this. - self.rendererMessageWriter(msg); + .special => |special| switch (special) { + .foreground => self.terminal.colors.foreground.reset(), + .background => self.terminal.colors.background.reset(), + .cursor => self.terminal.colors.cursor.reset(), + else => { + log.warn( + "ignoring unsupported kitty color protocol key: {f}", + .{key}, + ); + continue; + }, }, }, } From 27a98123a0ffaf589e8fd91940dca687c3b0f813 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 09:58:27 -0700 Subject: [PATCH 227/702] terminal: readonly stream can update more colors now --- src/terminal/stream_readonly.zig | 270 +++++++++++++++++++++++++++++-- 1 file changed, 260 insertions(+), 10 deletions(-) diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index f73d21dce..e762fdf86 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -161,6 +161,7 @@ pub const Handler = struct { .end_of_command => self.terminal.screen.cursor.page_row.semantic_prompt = .input, .mouse_shape => self.terminal.mouse_shape = value, .color_operation => try self.colorOperation(value.op, &value.requests), + .kitty_color_report => try self.kittyColorOperation(value), // No supported DCS commands have any terminal-modifying effects, // but they may in the future. For now we just ignore it. @@ -186,7 +187,6 @@ pub const Handler = struct { .device_attributes, .device_status, .kitty_keyboard_query, - .kitty_color_report, .window_title, .report_pwd, .show_desktop_notification, @@ -305,21 +305,57 @@ pub const Handler = struct { switch (req.*) { .set => |set| { switch (set.target) { - .palette => |i| self.terminal.colors.palette.set(i, set.color), - .dynamic, - .special, - => {}, + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.set(i, set.color); + }, + .dynamic => |dynamic| switch (dynamic) { + .foreground => self.terminal.colors.foreground.set(set.color), + .background => self.terminal.colors.background.set(set.color), + .cursor => self.terminal.colors.cursor.set(set.color), + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => {}, + }, + .special => {}, } }, .reset => |target| switch (target) { - .palette => |i| self.terminal.colors.palette.reset(i), - .dynamic, - .special, - => {}, + .palette => |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(i); + }, + .dynamic => |dynamic| switch (dynamic) { + .foreground => self.terminal.colors.foreground.reset(), + .background => self.terminal.colors.background.reset(), + .cursor => self.terminal.colors.cursor.reset(), + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => {}, + }, + .special => {}, }, - .reset_palette => self.terminal.colors.palette.resetAll(), + .reset_palette => { + const mask = &self.terminal.colors.palette.mask; + var mask_it = mask.iterator(.{}); + while (mask_it.next()) |i| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(@intCast(i)); + } + mask.* = .initEmpty(); + }, .query, .reset_special, @@ -327,6 +363,41 @@ pub const Handler = struct { } } } + + fn kittyColorOperation( + self: *Handler, + request: @import("kitty/color.zig").OSC, + ) !void { + for (request.list.items) |item| { + switch (item) { + .set => |v| switch (v.key) { + .palette => |palette| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.set(palette, v.color); + }, + .special => |special| switch (special) { + .foreground => self.terminal.colors.foreground.set(v.color), + .background => self.terminal.colors.background.set(v.color), + .cursor => self.terminal.colors.cursor.set(v.color), + else => {}, + }, + }, + .reset => |key| switch (key) { + .palette => |palette| { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(palette); + }, + .special => |special| switch (special) { + .foreground => self.terminal.colors.foreground.reset(), + .background => self.terminal.colors.background.reset(), + .cursor => self.terminal.colors.cursor.reset(), + else => {}, + }, + }, + .query => {}, + } + } + } }; test "basic print" { @@ -624,3 +695,182 @@ test "OSC 104 reset all palette colors" { try testing.expect(!t.colors.palette.mask.isSet(1)); try testing.expect(!t.colors.palette.mask.isSet(2)); } + +test "OSC 10 set and reset foreground color" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Initially unset + try testing.expect(t.colors.foreground.get() == null); + + // Set foreground to red + try s.nextSlice("\x1b]10;rgb:ff/00/00\x1b\\"); + const fg = t.colors.foreground.get().?; + try testing.expectEqual(@as(u8, 0xff), fg.r); + try testing.expectEqual(@as(u8, 0x00), fg.g); + try testing.expectEqual(@as(u8, 0x00), fg.b); + + // Reset foreground + try s.nextSlice("\x1b]110\x1b\\"); + try testing.expect(t.colors.foreground.get() == null); +} + +test "OSC 11 set and reset background color" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set background to green + try s.nextSlice("\x1b]11;rgb:00/ff/00\x1b\\"); + const bg = t.colors.background.get().?; + try testing.expectEqual(@as(u8, 0x00), bg.r); + try testing.expectEqual(@as(u8, 0xff), bg.g); + try testing.expectEqual(@as(u8, 0x00), bg.b); + + // Reset background + try s.nextSlice("\x1b]111\x1b\\"); + try testing.expect(t.colors.background.get() == null); +} + +test "OSC 12 set and reset cursor color" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set cursor to blue + try s.nextSlice("\x1b]12;rgb:00/00/ff\x1b\\"); + const cursor = t.colors.cursor.get().?; + try testing.expectEqual(@as(u8, 0x00), cursor.r); + try testing.expectEqual(@as(u8, 0x00), cursor.g); + try testing.expectEqual(@as(u8, 0xff), cursor.b); + + // Reset cursor + try s.nextSlice("\x1b]112\x1b\\"); + // After reset, cursor might be null (using default) +} + +test "kitty color protocol set palette" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set palette color 5 to magenta using kitty protocol + try s.nextSlice("\x1b]21;5=rgb:ff/00/ff\x1b\\"); + try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].r); + try testing.expectEqual(@as(u8, 0x00), t.colors.palette.current[5].g); + try testing.expectEqual(@as(u8, 0xff), t.colors.palette.current[5].b); + try testing.expect(t.colors.palette.mask.isSet(5)); + try testing.expect(t.flags.dirty.palette); +} + +test "kitty color protocol reset palette" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set and then reset palette color + const original = t.colors.palette.original[7]; + try s.nextSlice("\x1b]21;7=rgb:aa/bb/cc\x1b\\"); + try testing.expect(t.colors.palette.mask.isSet(7)); + + try s.nextSlice("\x1b]21;7=\x1b\\"); + try testing.expectEqual(original, t.colors.palette.current[7]); + try testing.expect(!t.colors.palette.mask.isSet(7)); +} + +test "kitty color protocol set foreground" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set foreground using kitty protocol + try s.nextSlice("\x1b]21;foreground=rgb:12/34/56\x1b\\"); + const fg = t.colors.foreground.get().?; + try testing.expectEqual(@as(u8, 0x12), fg.r); + try testing.expectEqual(@as(u8, 0x34), fg.g); + try testing.expectEqual(@as(u8, 0x56), fg.b); +} + +test "kitty color protocol set background" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set background using kitty protocol + try s.nextSlice("\x1b]21;background=rgb:78/9a/bc\x1b\\"); + const bg = t.colors.background.get().?; + try testing.expectEqual(@as(u8, 0x78), bg.r); + try testing.expectEqual(@as(u8, 0x9a), bg.g); + try testing.expectEqual(@as(u8, 0xbc), bg.b); +} + +test "kitty color protocol set cursor" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set cursor using kitty protocol + try s.nextSlice("\x1b]21;cursor=rgb:de/f0/12\x1b\\"); + const cursor = t.colors.cursor.get().?; + try testing.expectEqual(@as(u8, 0xde), cursor.r); + try testing.expectEqual(@as(u8, 0xf0), cursor.g); + try testing.expectEqual(@as(u8, 0x12), cursor.b); +} + +test "kitty color protocol reset foreground" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set and reset foreground + try s.nextSlice("\x1b]21;foreground=rgb:11/22/33\x1b\\"); + try testing.expect(t.colors.foreground.get() != null); + + try s.nextSlice("\x1b]21;foreground=\x1b\\"); + // After reset, should be unset + try testing.expect(t.colors.foreground.get() == null); +} + +test "palette dirty flag set on color change" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Clear dirty flag + t.flags.dirty.palette = false; + + // Setting palette color should set dirty flag + try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + try testing.expect(t.flags.dirty.palette); + + // Clear and test reset + t.flags.dirty.palette = false; + try s.nextSlice("\x1b]104;0\x1b\\"); + try testing.expect(t.flags.dirty.palette); + + // Clear and test kitty protocol + t.flags.dirty.palette = false; + try s.nextSlice("\x1b]21;1=rgb:00/ff/00\x1b\\"); + try testing.expect(t.flags.dirty.palette); +} From 450155f15062df3b8b3e27e579ac7eb4f50cf2cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 10:06:12 -0700 Subject: [PATCH 228/702] zig fmt --- src/terminal/formatter.zig | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 20dcf9a89..70cdd347b 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -225,24 +225,24 @@ pub const TerminalFormatter = struct { .plain => break :palette, .vt => { - for (self.terminal.colors.palette.current, 0..) |rgb, i| { - try writer.print( - "\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", - .{ i, rgb.r, rgb.g, rgb.b }, - ); - } + for (self.terminal.colors.palette.current, 0..) |rgb, i| { + try writer.print( + "\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", + .{ i, rgb.r, rgb.g, rgb.b }, + ); + } }, // For HTML, we emit CSS to setup our palette variables. .html => { - try writer.writeAll(""); + try writer.writeAll(""); }, } From 799e4bca505142d71864b61e40385e916d80e9bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 10:07:42 -0700 Subject: [PATCH 229/702] example/zig-formatter: fix build for new palette API --- example/zig-formatter/src/main.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/zig-formatter/src/main.zig b/example/zig-formatter/src/main.zig index 87a8e4915..085b6d116 100644 --- a/example/zig-formatter/src/main.zig +++ b/example/zig-formatter/src/main.zig @@ -31,7 +31,7 @@ pub fn main() !void { // Use TerminalFormatter to emit HTML const formatter: ghostty_vt.formatter.TerminalFormatter = .init(&t, .{ .emit = .html, - .palette = &t.color_palette.colors, + .palette = &t.colors.palette.current, }); // Write to stdout From 83a4f32a149485e453c17113ef2a129898e3a948 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 10:20:37 -0700 Subject: [PATCH 230/702] terminal: formatter improvements for color handling --- example/zig-formatter/src/main.zig | 1 + src/terminal/formatter.zig | 145 +++++++++++++++++++++++++++-- src/terminal/stream_readonly.zig | 16 ++-- 3 files changed, 147 insertions(+), 15 deletions(-) diff --git a/example/zig-formatter/src/main.zig b/example/zig-formatter/src/main.zig index 085b6d116..ad101dbf1 100644 --- a/example/zig-formatter/src/main.zig +++ b/example/zig-formatter/src/main.zig @@ -38,4 +38,5 @@ pub fn main() !void { var stdout_writer = std.fs.File.stdout().writer(&buf); const stdout = &stdout_writer.interface; try stdout.print("{f}", .{formatter}); + try stdout.flush(); } diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 70cdd347b..baa6b61c1 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -74,6 +74,11 @@ pub const Options = struct { /// is currently only space characters (0x20). trim: bool = true, + /// Set a background and foreground color to use for the "screen". + /// For styled formats, this will emit the proper sequences or styles. + background: ?color.RGB = null, + foreground: ?color.RGB = null, + /// If set, then styled formats in `emit` will use this palette to /// emit colors directly as RGB. If this is null, styled formats will /// still work but will use deferred palette styling (e.g. CSS variables @@ -902,14 +907,66 @@ pub const PageFormatter = struct { } // Wrap HTML output in monospace font styling - if (self.opts.emit == .html) { - const monospace = "
"; - try writer.writeAll(monospace); - if (self.point_map) |*map| map.map.appendNTimes( - map.alloc, - .{ .x = 0, .y = 0 }, - monospace.len, - ) catch return error.WriteFailed; + switch (self.opts.emit) { + .plain => {}, + + .html => { + // Setup our div. We use a buffer here that should always + // fit the stuff we need, in order to make counting bytes easier. + var buf: [1024]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const buf_writer = stream.writer(); + + // Monospace and whitespace preserving + buf_writer.writeAll("
2}{x:0>2}{x:0>2};", + .{ bg.r, bg.g, bg.b }, + ) catch return error.WriteFailed; + if (self.opts.foreground) |fg| buf_writer.print( + "color: #{x:0>2}{x:0>2}{x:0>2};", + .{ fg.r, fg.g, fg.b }, + ) catch return error.WriteFailed; + + buf_writer.writeAll("\">") catch return error.WriteFailed; + + const header = stream.getWritten(); + try writer.writeAll(header); + if (self.point_map) |*map| map.map.appendNTimes( + map.alloc, + .{ .x = 0, .y = 0 }, + header.len, + ) catch return error.WriteFailed; + }, + + .vt => { + // OSC 10 sets foreground color, OSC 11 sets background color + var buf: [512]u8 = undefined; + var stream = std.io.fixedBufferStream(&buf); + const buf_writer = stream.writer(); + if (self.opts.foreground) |fg| { + buf_writer.print( + "\x1b]10;rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", + .{ fg.r, fg.g, fg.b }, + ) catch return error.WriteFailed; + } + if (self.opts.background) |bg| { + buf_writer.print( + "\x1b]11;rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", + .{ bg.r, bg.g, bg.b }, + ) catch return error.WriteFailed; + } + + const header = stream.getWritten(); + try writer.writeAll(header); + if (self.point_map) |*map| map.map.appendNTimes( + map.alloc, + .{ .x = 0, .y = 0 }, + header.len, + ) catch return error.WriteFailed; + }, } // Our style for non-plain formats @@ -3073,6 +3130,43 @@ test "Page VT with foreground color" { ); } +test "Page VT with background and foreground colors" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .{ + .emit = .vt, + .background = .{ .r = 0x12, .g = 0x34, .b = 0x56 }, + .foreground = .{ .r = 0xab, .g = 0xcd, .b = 0xef }, + }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Should emit OSC 10 for foreground, OSC 11 for background, then the text + try testing.expectEqualStrings( + "\x1b]10;rgb:ab/cd/ef\x1b\\\x1b]11;rgb:12/34/56\x1b\\hello", + output, + ); +} + test "Page VT multi-line with styles" { const testing = std.testing; const alloc = testing.allocator; @@ -4866,6 +4960,41 @@ test "TerminalFormatter html with palette" { try testing.expect(std.mem.indexOf(u8, output, "test") != null); } +test "Page html with background and foreground colors" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ + .emit = .html, + .background = .{ .r = 0x12, .g = 0x34, .b = 0x56 }, + .foreground = .{ .r = 0xab, .g = 0xcd, .b = 0xef }, + }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + try testing.expectEqualStrings( + "
hello
", + output, + ); +} + test "Page html with escaping" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index e762fdf86..907c48762 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -2,8 +2,10 @@ const std = @import("std"); const testing = std.testing; const stream = @import("stream.zig"); const Action = stream.Action; -const CursorStyle = @import("Screen.zig").CursorStyle; -const Mode = @import("modes.zig").Mode; +const Screen = @import("Screen.zig"); +const modes = @import("modes.zig"); +const osc_color = @import("osc/color.zig"); +const kitty_color = @import("kitty/color.zig"); const Terminal = @import("Terminal.zig"); /// This is a Stream implementation that processes actions against @@ -76,7 +78,7 @@ pub const Handler = struct { .default, .steady_block, .steady_bar, .steady_underline => false, .blinking_block, .blinking_bar, .blinking_underline => true, }; - const style: CursorStyle = switch (value) { + const style: Screen.CursorStyle = switch (value) { .default, .blinking_block, .steady_block => .block, .blinking_bar, .steady_bar => .bar, .blinking_underline, .steady_underline => .underline, @@ -214,7 +216,7 @@ pub const Handler = struct { } } - fn setMode(self: *Handler, mode: Mode, enabled: bool) !void { + fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { // Set the mode on the terminal self.terminal.modes.set(mode, enabled); @@ -294,8 +296,8 @@ pub const Handler = struct { fn colorOperation( self: *Handler, - op: @import("osc/color.zig").Operation, - requests: *const @import("osc/color.zig").List, + op: osc_color.Operation, + requests: *const osc_color.List, ) !void { _ = op; if (requests.count() == 0) return; @@ -366,7 +368,7 @@ pub const Handler = struct { fn kittyColorOperation( self: *Handler, - request: @import("kitty/color.zig").OSC, + request: kitty_color.OSC, ) !void { for (request.list.items) |item| { switch (item) { From 77b038dcb6c239a2885ebcaeeec9042ad1eb47e3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 13:14:02 -0700 Subject: [PATCH 231/702] update README --- README.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7124400fd..961968097 100644 --- a/README.md +++ b/README.md @@ -144,16 +144,23 @@ In addition to being a standalone terminal emulator, Ghostty is a C-compatible library for embedding a fast, feature-rich terminal emulator in any 3rd party project. This library is called `libghostty`. -This goal is not hypothetical! The macOS app is a `libghostty` consumer. +Due to the scope of this project, we're breaking libghostty down into +separate actually libraries, starting with `libghostty-vt`. The goal of +this project is to focus on parsing terminal sequences and maintaining +terminal state. This is covered in more detail in this +[blog post](https://mitchellh.com/writing/libghostty-is-coming). + +`libghostty-vt` is already available and usable today for Zig and C and +is compatible for macOS, Linux, Windows, and WebAssembly. At the time of +writing this, the API isn't stable yet and we haven't tagged an official +release, but the core logic is well proven (since Ghostty uses it) and +we're working hard on it now. + +The ultimate goal is not hypothetical! The macOS app is a `libghostty` consumer. The macOS app is a native Swift app developed in Xcode and `main()` is within Swift. The Swift app links to `libghostty` and uses the C API to render terminals. -This step encompasses expanding `libghostty` support to more platforms -and more use cases. At the time of writing this, `libghostty` is very -Mac-centric -- particularly around rendering -- and we have work to do to -expand this to other platforms. - ## Crash Reports Ghostty has a built-in crash reporter that will generate and save crash From df037d75a6ee5f3d9b59916dc08723032fa8e578 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 13:30:37 -0700 Subject: [PATCH 232/702] copy_to_clipboard format types --- src/config/Config.zig | 8 ++++---- src/input/Binding.zig | 37 ++++++++++++++++++++++++++++++++++++- src/input/command.zig | 10 +++++++--- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 78ea19aef..f8322d5fc 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5651,12 +5651,12 @@ pub const Keybinds = struct { try self.set.put( alloc, .{ .key = .{ .physical = .copy } }, - .{ .copy_to_clipboard = {} }, + .{ .copy_to_clipboard = .mixed }, ); try self.set.put( alloc, .{ .key = .{ .physical = .paste } }, - .{ .paste_from_clipboard = {} }, + .paste_from_clipboard, ); // On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an @@ -5669,7 +5669,7 @@ pub const Keybinds = struct { try self.set.put( alloc, .{ .key = .{ .physical = .insert }, .mods = .{ .ctrl = true } }, - .{ .copy_to_clipboard = {} }, + .{ .copy_to_clipboard = .mixed }, ); try self.set.put( alloc, @@ -5688,7 +5688,7 @@ pub const Keybinds = struct { try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'c' }, .mods = mods }, - .{ .copy_to_clipboard = {} }, + .{ .copy_to_clipboard = .mixed }, .{ .performable = true }, ); try self.set.put( diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 9bdd858c1..26278f386 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -296,7 +296,7 @@ pub const Action = union(enum) { reset, /// Copy the selected text to the clipboard. - copy_to_clipboard, + copy_to_clipboard: CopyToClipboard, /// Paste the contents of the default clipboard. paste_from_clipboard, @@ -889,6 +889,19 @@ pub const Action = union(enum) { u16, }; + pub const CopyToClipboard = enum { + plain, + vt, + html, + + /// This type will mix multiple distinct types with a set content-type + /// such as text/html for html, so that the OS/application can choose + /// what is best when pasting. + mixed, + + pub const default: CopyToClipboard = .mixed; + }; + pub const WriteScreenAction = enum { copy, paste, @@ -3239,6 +3252,28 @@ test "parse: set_font_size" { } } +test "parse: copy to clipboard default" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=copy_to_clipboard"); + try testing.expect(binding.action == .copy_to_clipboard); + try testing.expectEqual(Action.CopyToClipboard.mixed, binding.action.copy_to_clipboard); + } +} + +test "parse: copy to clipboard explicit" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=copy_to_clipboard:html"); + try testing.expect(binding.action == .copy_to_clipboard); + try testing.expectEqual(Action.CopyToClipboard.html, binding.action.copy_to_clipboard); + } +} + test "action: format" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/input/command.zig b/src/input/command.zig index 8216d107a..651ba2b30 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -121,11 +121,15 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Reset the terminal to a clean state.", }}, - .copy_to_clipboard => comptime &.{.{ - .action = .copy_to_clipboard, + .copy_to_clipboard => comptime &.{ .{ + .action = .{ .copy_to_clipboard = .mixed }, .title = "Copy to Clipboard", .description = "Copy the selected text to the clipboard.", - }}, + }, .{ + .action = .{ .copy_to_clipboard = .html }, + .title = "Copy HTML to Clipboard", + .description = "Copy the selected text as HTML to the clipboard.", + } }, .copy_url_to_clipboard => comptime &.{.{ .action = .copy_url_to_clipboard, From 0f1c46e4a446a2eff1cab107b991d1fdd73ab96f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 13:49:14 -0700 Subject: [PATCH 233/702] macos: support setting multiple clipboard content types --- include/ghostty.h | 8 +++- macos/Sources/Ghostty/Ghostty.App.swift | 45 +++++++++++++++---- macos/Sources/Ghostty/Package.swift | 17 +++++++ .../Extensions/NSPasteboard+Extension.swift | 25 +++++++++++ macos/Tests/NSPasteboardTests.swift | 33 ++++++++++++++ src/apprt/embedded.zig | 20 ++++++++- src/apprt/structs.zig | 5 +++ 7 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 macos/Tests/NSPasteboardTests.swift diff --git a/include/ghostty.h b/include/ghostty.h index acb6988d6..6c3f5af64 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -45,6 +45,11 @@ typedef enum { GHOSTTY_CLIPBOARD_SELECTION, } ghostty_clipboard_e; +typedef struct { + const char *mime; + const char *data; +} ghostty_clipboard_content_s; + typedef enum { GHOSTTY_CLIPBOARD_REQUEST_PASTE, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, @@ -855,8 +860,9 @@ typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, - const char*, ghostty_clipboard_e, + const ghostty_clipboard_content_s*, + size_t, bool); typedef void (*ghostty_runtime_close_surface_cb)(void*, bool); typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t, diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 690caac34..9806efbe4 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -61,7 +61,8 @@ extension Ghostty { action_cb: { app, target, action in App.action(app!, target: target, action: action) }, read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) }, - write_clipboard_cb: { userdata, str, loc, confirm in App.writeClipboard(userdata, string: str, location: loc, confirm: confirm) }, + write_clipboard_cb: { userdata, loc, content, len, confirm in + App.writeClipboard(userdata, location: loc, content: content, len: len, confirm: confirm) }, close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) } ) @@ -276,8 +277,9 @@ extension Ghostty { static func writeClipboard( _ userdata: UnsafeMutableRawPointer?, - string: UnsafePointer?, location: ghostty_clipboard_e, + content: UnsafePointer?, + len: Int, confirm: Bool ) {} @@ -364,23 +366,48 @@ extension Ghostty { } } - static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, location: ghostty_clipboard_e, confirm: Bool) { + static func writeClipboard( + _ userdata: UnsafeMutableRawPointer?, + location: ghostty_clipboard_e, + content: UnsafePointer?, + len: Int, + confirm: Bool + ) { let surface = self.surfaceUserdata(from: userdata) - - guard let pasteboard = NSPasteboard.ghostty(location) else { return } - guard let valueStr = String(cString: string!, encoding: .utf8) else { return } + guard let content = content, len > 0 else { return } + + // Convert the C array to Swift array + let contentArray = (0.. ClipboardContent? { + guard let mimePtr = content.mime, + let dataPtr = content.data else { + return nil + } + + return ClipboardContent( + mime: String(cString: mimePtr), + data: String(cString: dataPtr) + ) + } + } /// macos-icon enum MacOSIcon: String { diff --git a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift index 11815fbc8..a036f02b4 100644 --- a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift @@ -1,5 +1,30 @@ import AppKit import GhosttyKit +import UniformTypeIdentifiers + +extension NSPasteboard.PasteboardType { + /// Initialize a pasteboard type from a MIME type string + init?(mimeType: String) { + // Explicit mappings for common MIME types + switch mimeType { + case "text/plain": + self = .string + return + default: + break + } + + // Try to get UTType from MIME type + guard let utType = UTType(mimeType: mimeType) else { + // Fallback: use the MIME type directly as identifier + self.init(mimeType) + return + } + + // Use the UTType's identifier + self.init(utType.identifier) + } +} extension NSPasteboard { /// The pasteboard to used for Ghostty selection. diff --git a/macos/Tests/NSPasteboardTests.swift b/macos/Tests/NSPasteboardTests.swift new file mode 100644 index 000000000..d956ce733 --- /dev/null +++ b/macos/Tests/NSPasteboardTests.swift @@ -0,0 +1,33 @@ +// +// NSPasteboardTests.swift +// GhosttyTests +// +// Tests for NSPasteboard.PasteboardType MIME type conversion. +// + +import Testing +import AppKit +@testable import Ghostty + +struct NSPasteboardTypeExtensionTests { + /// Test text/plain MIME type converts to .string + @Test func testTextPlainMimeType() async throws { + let pasteboardType = NSPasteboard.PasteboardType(mimeType: "text/plain") + #expect(pasteboardType != nil) + #expect(pasteboardType == .string) + } + + /// Test text/html MIME type converts to .html + @Test func testTextHtmlMimeType() async throws { + let pasteboardType = NSPasteboard.PasteboardType(mimeType: "text/html") + #expect(pasteboardType != nil) + #expect(pasteboardType == .html) + } + + /// Test image/png MIME type + @Test func testImagePngMimeType() async throws { + let pasteboardType = NSPasteboard.PasteboardType(mimeType: "image/png") + #expect(pasteboardType != nil) + #expect(pasteboardType == .png) + } +} diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 617557995..90248cec7 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -66,7 +66,13 @@ pub const App = struct { ) callconv(.c) void, /// Write the clipboard value. - write_clipboard: *const fn (SurfaceUD, [*:0]const u8, c_int, bool) callconv(.c) void, + write_clipboard: *const fn ( + SurfaceUD, + c_int, + [*]const CAPI.ClipboardContent, + usize, + bool, + ) callconv(.c) void, /// Close the current surface given by this function. close_surface: ?*const fn (SurfaceUD, bool) callconv(.c) void = null, @@ -707,8 +713,12 @@ pub const Surface = struct { ) !void { self.app.opts.write_clipboard( self.userdata, - val.ptr, @intCast(@intFromEnum(clipboard_type)), + &.{.{ + .mime = "text/plain", + .data = val.ptr, + }}, + 1, confirm, ); } @@ -1211,6 +1221,12 @@ pub const CAPI = struct { cell_height_px: u32, }; + // ghostty_clipboard_content_s + const ClipboardContent = extern struct { + mime: [*:0]const u8, + data: [*:0]const u8, + }; + // ghostty_text_s const Text = extern struct { tl_px_x: f64, diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 89b8c2235..bf14b65a9 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -54,6 +54,11 @@ pub const Clipboard = enum(Backing) { }; }; +pub const ClipboardContent = struct { + mime: [:0]const u8, + data: [:0]const u8, +}; + pub const ClipboardRequestType = enum(u8) { paste, osc_52_read, From 26bdb12f646d7bb36e248094fb3cec5729000d86 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 14:05:01 -0700 Subject: [PATCH 234/702] core: update Surface to use setClipboard --- src/Surface.zig | 43 +++++++++++++++++++++++++++--------------- src/apprt.zig | 1 + src/apprt/embedded.zig | 21 ++++++++++++++------- 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index c9c40f466..dc57108e6 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1945,7 +1945,10 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) // them to confirm the clipboard access. Each app runtime handles this // differently. const confirm = self.config.clipboard_write == .ask; - self.rt_surface.setClipboardString(buf, loc, confirm) catch |err| { + self.rt_surface.setClipboard(loc, &.{.{ + .mime = "text/plain", + .data = buf, + }}, confirm) catch |err| { log.err("error setting clipboard string err={}", .{err}); return; }; @@ -1965,11 +1968,10 @@ fn copySelectionToClipboards( }; defer self.alloc.free(buf); - for (clipboards) |clipboard| self.rt_surface.setClipboardString( - buf, - clipboard, - false, - ) catch |err| { + for (clipboards) |clipboard| self.rt_surface.setClipboard(clipboard, &.{.{ + .mime = "text/plain", + .data = buf, + }}, false) catch |err| { log.err( "error setting clipboard string clipboard={} err={}", .{ clipboard, err }, @@ -4670,7 +4672,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; defer self.alloc.free(buf); - self.rt_surface.setClipboardString(buf, .standard, false) catch |err| { + self.rt_surface.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = buf, + }}, false) catch |err| { log.err("error setting clipboard string err={}", .{err}); return true; }; @@ -4721,7 +4726,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; defer self.alloc.free(url_text); - self.rt_surface.setClipboardString(url_text, .standard, false) catch |err| { + self.rt_surface.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = url_text, + }}, false) catch |err| { log.err("error copying url to clipboard err={}", .{err}); return false; }; @@ -4736,7 +4744,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const title = self.rt_surface.getTitle() orelse return false; if (title.len == 0) return false; - self.rt_surface.setClipboardString(title, .standard, false) catch |err| { + self.rt_surface.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = title, + }}, false) catch |err| { log.err("error copying title to clipboard err={}", .{err}); return true; }; @@ -5273,7 +5284,10 @@ fn writeScreenFile( .copy => { const pathZ = try self.alloc.dupeZ(u8, path); defer self.alloc.free(pathZ); - try self.rt_surface.setClipboardString(pathZ, .standard, false); + try self.rt_surface.setClipboard(.standard, &.{.{ + .mime = "text/plain", + .data = pathZ, + }}, false); }, .open => try self.openUrl(.{ .kind = .text, .url = path }), .paste => self.io.queueMessage(try termio.Message.writeReq( @@ -5313,11 +5327,10 @@ pub fn completeClipboardRequest( confirmed, ), - .osc_52_write => |clipboard| try self.rt_surface.setClipboardString( - data, - clipboard, - !confirmed, - ), + .osc_52_write => |clipboard| try self.rt_surface.setClipboard(clipboard, &.{.{ + .mime = "text/plain", + .data = data, + }}, !confirmed), } } diff --git a/src/apprt.zig b/src/apprt.zig index 947f29050..dbd62fbfb 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -28,6 +28,7 @@ pub const Target = action.Target; pub const ContentScale = structs.ContentScale; pub const Clipboard = structs.Clipboard; +pub const ClipboardContent = structs.ClipboardContent; pub const ClipboardRequest = structs.ClipboardRequest; pub const ClipboardRequestType = structs.ClipboardRequestType; pub const ColorScheme = structs.ColorScheme; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 90248cec7..1faa0b9c6 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -705,20 +705,27 @@ pub const Surface = struct { alloc.destroy(state); } - pub fn setClipboardString( + pub fn setClipboard( self: *const Surface, - val: [:0]const u8, clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, confirm: bool, ) !void { + const alloc = self.app.core_app.alloc; + const array = try alloc.alloc(CAPI.ClipboardContent, contents.len); + defer alloc.free(array); + for (contents, 0..) |content, i| { + array[i] = .{ + .mime = content.mime, + .data = content.data, + }; + } + self.app.opts.write_clipboard( self.userdata, @intCast(@intFromEnum(clipboard_type)), - &.{.{ - .mime = "text/plain", - .data = val.ptr, - }}, - 1, + array.ptr, + array.len, confirm, ); } From 9a198b47a0be72ae4183055b6f3855e054721c50 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 14:09:39 -0700 Subject: [PATCH 235/702] apprt/gtk: support new set clipboard API --- src/apprt/gtk/Surface.zig | 8 ++++---- src/apprt/gtk/class/surface.zig | 15 +++++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index ac82f941b..009ce018d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -80,15 +80,15 @@ pub fn clipboardRequest( ); } -pub fn setClipboardString( +pub fn setClipboard( self: *Self, - val: [:0]const u8, clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, confirm: bool, ) !void { - self.surface.setClipboardString( - val, + self.surface.setClipboard( clipboard_type, + contents, confirm, ); } diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 646ad5dbd..3f6d64652 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1553,16 +1553,16 @@ pub const Surface = extern struct { ); } - pub fn setClipboardString( + pub fn setClipboard( self: *Self, - val: [:0]const u8, clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, confirm: bool, ) void { Clipboard.set( self, - val, clipboard_type, + contents, confirm, ); } @@ -3334,12 +3334,19 @@ const Clipboard = struct { /// Set the clipboard contents. pub fn set( self: *Surface, - val: [:0]const u8, clipboard_type: apprt.Clipboard, + contents: []const apprt.ClipboardContent, confirm: bool, ) void { const priv = self.private(); + // For GTK, we only support text/plain type to set strings currently. + const val: [:0]const u8 = for (contents) |content| { + if (std.mem.eql(u8, content.mime, "text/plain")) { + break content.data; + } + } else return; + // If no confirmation is necessary, set the clipboard. if (!confirm) { const clipboard = get( From f3352dd90b4b3affc4d1e31ee5aab933ba43649f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 14:24:44 -0700 Subject: [PATCH 236/702] core: copy the proper format to the clipboard as configured --- src/Surface.zig | 158 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 122 insertions(+), 36 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index dc57108e6..6d5c9b683 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1958,20 +1958,103 @@ fn copySelectionToClipboards( self: *Surface, sel: terminal.Selection, clipboards: []const apprt.Clipboard, -) void { - const buf = self.io.terminal.screen.selectionString(self.alloc, .{ - .sel = sel, - .trim = self.config.clipboard_trim_trailing_spaces, - }) catch |err| { - log.err("error reading selection string err={}", .{err}); - return; - }; - defer self.alloc.free(buf); + format: input.Binding.Action.CopyToClipboard, +) !void { + // Create an arena to simplify memory management here. + var arena = ArenaAllocator.init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); - for (clipboards) |clipboard| self.rt_surface.setClipboard(clipboard, &.{.{ - .mime = "text/plain", - .data = buf, - }}, false) catch |err| { + // The options we'll use for all formatting. We'll just override the + // emit format. + const opts: terminal.formatter.Options = .{ + .emit = .plain, // We'll override this below + .unwrap = true, + .trim = self.config.clipboard_trim_trailing_spaces, + .background = self.io.terminal.colors.background.get(), + .foreground = self.io.terminal.colors.foreground.get(), + .palette = &self.io.terminal.colors.palette.current, + }; + + const ScreenFormatter = terminal.formatter.ScreenFormatter; + var aw: std.Io.Writer.Allocating = .init(alloc); + var contents: std.ArrayList(apprt.ClipboardContent) = .empty; + switch (format) { + .plain => { + var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + try contents.append(alloc, .{ + .mime = "text/plain", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + + .vt => { + var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts: { + var copy = opts; + copy.emit = .vt; + break :opts copy; + }); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + try contents.append(alloc, .{ + .mime = "text/plain", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + + .html => { + var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts: { + var copy = opts; + copy.emit = .html; + break :opts copy; + }); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + try contents.append(alloc, .{ + .mime = "text/html", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + + .mixed => { + var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + try contents.append(alloc, .{ + .mime = "text/plain", + .data = try aw.toOwnedSliceSentinel(0), + }); + + assert(aw.written().len == 0); + formatter = .init(&self.io.terminal.screen, opts: { + var copy = opts; + copy.emit = .html; + + // We purposely don't emit background/foreground for mixed + // mode because the HTML contents is often used for rich text + // input and with trimmed spaces it looks pretty bad. + copy.background = null; + copy.foreground = null; + + break :opts copy; + }); + formatter.content = .{ .selection = sel }; + try formatter.format(&aw.writer); + try contents.append(alloc, .{ + .mime = "text/html", + .data = try aw.toOwnedSliceSentinel(0), + }); + }, + } + + assert(contents.items.len > 0); + for (clipboards) |clipboard| self.rt_surface.setClipboard( + clipboard, + contents.items, + false, + ) catch |err| { log.err( "error setting clipboard string clipboard={} err={}", .{ clipboard, err }, @@ -2000,9 +2083,11 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { .false => unreachable, // handled above with an early exit // Both standard and selection clipboards are set. - .clipboard => { - self.copySelectionToClipboards(sel, &.{ .standard, .selection }); - }, + .clipboard => try self.copySelectionToClipboards( + sel, + &.{ .standard, .selection }, + .mixed, + ), // The selection clipboard is set if supported, otherwise the standard. .true => { @@ -2010,7 +2095,11 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { .selection else .standard; - self.copySelectionToClipboards(sel, &.{clipboard}); + try self.copySelectionToClipboards( + sel, + &.{clipboard}, + .mixed, + ); }, } } @@ -3748,14 +3837,22 @@ pub fn mouseButtonCallback( }, .copy => { if (self.io.terminal.screen.selection) |sel| { - self.copySelectionToClipboards(sel, &.{.standard}); + try self.copySelectionToClipboards( + sel, + &.{.standard}, + .mixed, + ); } try self.setSelection(null); try self.queueRender(); }, .@"copy-or-paste" => if (self.io.terminal.screen.selection) |sel| { - self.copySelectionToClipboards(sel, &.{.standard}); + try self.copySelectionToClipboards( + sel, + &.{.standard}, + .mixed, + ); try self.setSelection(null); try self.queueRender(); } else { @@ -4659,26 +4756,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, - .copy_to_clipboard => { + .copy_to_clipboard => |format| { // We can read from the renderer state without holding // the lock because only we will write to this field. if (self.io.terminal.screen.selection) |sel| { - const buf = self.io.terminal.screen.selectionString(self.alloc, .{ - .sel = sel, - .trim = self.config.clipboard_trim_trailing_spaces, - }) catch |err| { - log.err("error reading selection string err={}", .{err}); - return true; - }; - defer self.alloc.free(buf); - - self.rt_surface.setClipboard(.standard, &.{.{ - .mime = "text/plain", - .data = buf, - }}, false) catch |err| { - log.err("error setting clipboard string err={}", .{err}); - return true; - }; + try self.copySelectionToClipboards( + sel, + &.{.standard}, + format, + ); // Clear the selection if configured to do so. if (self.config.selection_clear_on_copy) { From 54fe54fe376102060b25ad32badd9ed8fedd7c54 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 14:39:03 -0700 Subject: [PATCH 237/702] apprt/gtk: fix build errors --- src/apprt/gtk/class/application.zig | 2 +- src/apprt/gtk/class/window.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index ceea6fee5..2f0a7c5c3 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1111,7 +1111,7 @@ pub const Application = extern struct { self.syncActionAccelerator("win.split-down", .{ .new_split = .down }); self.syncActionAccelerator("win.split-left", .{ .new_split = .left }); self.syncActionAccelerator("win.split-up", .{ .new_split = .up }); - self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} }); + self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = .mixed }); self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); self.syncActionAccelerator("win.reset", .{ .reset = {} }); self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 4febebfc6..8c79d6b75 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -1801,7 +1801,7 @@ pub const Window = extern struct { _: ?*glib.Variant, self: *Window, ) callconv(.c) void { - self.performBindingAction(.copy_to_clipboard); + self.performBindingAction(.{ .copy_to_clipboard = .mixed }); } fn actionPaste( From 5c1f036613522406687ba77771a617d6fc239b18 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 30 Oct 2025 15:16:14 -0700 Subject: [PATCH 238/702] macos: assert only one text-plain gets written to clipboard --- macos/Sources/Ghostty/Ghostty.App.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9806efbe4..074b0f6d5 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -383,6 +383,11 @@ extension Ghostty { } guard !contentArray.isEmpty else { return } + // Assert there is only one text/plain entry. For security reasons we need + // to guarantee this for now since our confirmation dialog only shows one. + assert(contentArray.filter({ $0.mime == "text/plain" }).count <= 1, + "clipboard contents should have at most one text/plain entry") + if !confirm { // Declare all types let types = contentArray.compactMap { item in From 951374cd1ca76a2c6ad38d8e20e6872093feee24 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Fri, 31 Oct 2025 00:24:12 -0700 Subject: [PATCH 239/702] Fix documentView padding calculations --- macos/Sources/Ghostty/SurfaceScrollView.swift | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 2a9e49d9a..e2c6946c7 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -19,6 +19,7 @@ class SurfaceScrollView: NSView { private var observers: [NSObjectProtocol] = [] private var cancellables: Set = [] private var isLiveScrolling = false + private var currentScrollbar: Ghostty.Action.Scrollbar? = nil /// The last row position sent via scroll_to_row action. Used to avoid /// sending redundant actions when the user drags the scrollbar but stays @@ -174,19 +175,11 @@ class SurfaceScrollView: NSView { } } - // Keep document width synchronized with content width, and - // recalculate the height of the document view to account for the - // change in padding around the cell grid due to the resize. - var documentHeight = documentView.frame.height - let cellHeight = surfaceView.cellSize.height - if cellHeight > 0 { - let oldPadding = fmod(documentHeight, cellHeight) - let newPadding = fmod(contentSize.height, cellHeight) - documentHeight += newPadding - oldPadding - } + // Keep document width synchronized with content width, and update the + // document height as appropriate for the current surface size. documentView.setFrameSize(CGSize( width: contentSize.width, - height: documentHeight + height: documentHeight(currentScrollbar), )) // Inform the actual pty of our size change. This doesn't change the actual view @@ -262,21 +255,18 @@ class SurfaceScrollView: NSView { guard let scrollbar = notification.userInfo?[SwiftUI.Notification.Name.ScrollbarKey] as? Ghostty.Action.Scrollbar else { return } + currentScrollbar = scrollbar // Convert row units to pixels using cell height, ignore zero height. let cellHeight = surfaceView.cellSize.height guard cellHeight > 0 else { return } - // The full document height must include the vertical padding around the cell - // grid, otherwise the content view ends up misaligned with the surface. - let documentGridHeight = CGFloat(scrollbar.total) * cellHeight - let padding = fmod(scrollView.contentSize.height, cellHeight) - let documentHeight = documentGridHeight + padding - // Our width should be the content width to account for visible scrollers. // We don't do horizontal scrolling in terminals. - let newSize = CGSize(width: scrollView.contentSize.width, height: documentHeight) - documentView.setFrameSize(newSize) + documentView.setFrameSize(CGSize( + width: scrollView.contentSize.width, + height: documentHeight(scrollbar), + )) // Only update our actual scroll position if we're not actively scrolling. if !isLiveScrolling { @@ -292,4 +282,19 @@ class SurfaceScrollView: NSView { // Always update our scrolled view with the latest dimensions scrollView.reflectScrolledClipView(scrollView.contentView) } + + /// Calculate the appropriate document view height given a scrollbar state + private func documentHeight(_ scrollbar: Ghostty.Action.Scrollbar?) -> CGFloat { + let contentHeight = scrollView.contentSize.height + let cellHeight = surfaceView.cellSize.height + if cellHeight > 0, let scrollbar = scrollbar { + // The document view must have the same vertical padding around the + // scrollback grid as the content view has around the terminal grid + // otherwise the content view loses alignment with the surface. + let documentGridHeight = CGFloat(scrollbar.total) * cellHeight + let padding = contentHeight - (CGFloat(scrollbar.len) * cellHeight) + return documentGridHeight + padding + } + return contentHeight + } } From 05d2f881b6cdd2edf0e6d0b1c63dfaa01c6f30fb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Oct 2025 08:15:20 -0700 Subject: [PATCH 240/702] terminal: emit non-ASCII characters as Unicode codepoints for HTML Fixes #9426 Since we can't set the meta charset tag since we emit partial HTML, we use codepoint entities like `{` for non-ASCII characters to ensure proper rendering. --- src/terminal/formatter.zig | 111 ++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index baa6b61c1..ddb6d5334 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1263,7 +1263,18 @@ pub const PageFormatter = struct { '&' => try writer.writeAll("&"), '"' => try writer.writeAll("""), '\'' => try writer.writeAll("'"), - else => try writer.print("{u}", .{codepoint}), + else => { + // For HTML, emit ASCII (< 0x80) directly, but encode + // all non-ASCII as numeric entities to avoid encoding + // detection issues (fixes #9426). We can't set the + // meta tag because we emit partial HTML so this ensures + // proper unicode handling. + if (codepoint < 0x80) { + try writer.print("{u}", .{codepoint}); + } else { + try writer.print("&#{d};", .{codepoint}); + } + }, } }, } @@ -5065,6 +5076,104 @@ test "Page html with escaping" { try testing.expectEqual(Coordinate{ .x = 11, .y = 0 }, point_map.items[offset + 30]); } +test "Page html with unicode as numeric entities" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Box drawing characters that caused issue #9426 + try s.nextSlice("╰─ ❯"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Expected: box drawing chars as numeric entities + // ╰ = U+2570 = 9584, ─ = U+2500 = 9472, ❯ = U+276F = 10095 + try testing.expectEqualStrings( + "
╰─ ❯
", + output, + ); +} + +test "Page html ascii characters unchanged" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // ASCII should be emitted directly + try testing.expectEqualStrings( + "
hello world
", + output, + ); +} + +test "Page html mixed ascii and unicode" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("test ╰─❯ ok"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Mix of ASCII and Unicode entities + try testing.expectEqualStrings( + "
test ╰─❯ ok
", + output, + ); +} + test "Page VT with palette option emits RGB" { const testing = std.testing; const alloc = testing.allocator; From 24b97784328fb2a2e6b79fbf9ed0caa52071bcb8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Oct 2025 08:19:35 -0700 Subject: [PATCH 241/702] input: add more copy formatted options to the command palette --- src/input/command.zig | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/input/command.zig b/src/input/command.zig index 651ba2b30..b97b98cca 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -124,10 +124,18 @@ fn actionCommands(action: Action.Key) []const Command { .copy_to_clipboard => comptime &.{ .{ .action = .{ .copy_to_clipboard = .mixed }, .title = "Copy to Clipboard", - .description = "Copy the selected text to the clipboard.", + .description = "Copy the selected text to the clipboard in both plain and styled formats.", + }, .{ + .action = .{ .copy_to_clipboard = .plain }, + .title = "Copy Selection as Plain Text to Clipboard", + .description = "Copy the selected text as plain text to the clipboard.", + }, .{ + .action = .{ .copy_to_clipboard = .vt }, + .title = "Copy Selection as ANSI Sequences to Clipboard", + .description = "Copy the selected text as ANSI escape sequences to the clipboard.", }, .{ .action = .{ .copy_to_clipboard = .html }, - .title = "Copy HTML to Clipboard", + .title = "Copy Selection as HTML to Clipboard", .description = "Copy the selected text as HTML to the clipboard.", } }, From 901708e8da3d8599a0befb368a722f19aaae58f6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Oct 2025 09:31:56 -0700 Subject: [PATCH 242/702] input: write_*_file actions take an optional format Fixes #9398 --- include/ghostty.h | 1 + macos/Sources/Ghostty/Ghostty.Action.swift | 3 + macos/Sources/Ghostty/Ghostty.App.swift | 4 + src/Surface.zig | 52 +++++-- src/apprt/action.zig | 3 + src/input/Binding.zig | 154 +++++++++++++++++++-- src/input/command.zig | 100 +++++++++++++ src/os/open.zig | 2 +- 8 files changed, 289 insertions(+), 30 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6c3f5af64..9b7a918ec 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -700,6 +700,7 @@ typedef struct { typedef enum { GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, + GHOSTTY_ACTION_OPEN_URL_KIND_HTML, } ghostty_action_open_url_kind_e; // apprt.action.OpenUrl.C diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 4921ef8df..9d389a8c2 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -45,11 +45,14 @@ extension Ghostty.Action { enum Kind { case unknown case text + case html init(_ c: ghostty_action_open_url_kind_e) { switch c { case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT: self = .text + case GHOSTTY_ACTION_OPEN_URL_KIND_HTML: + self = .html default: self = .unknown } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 074b0f6d5..466e7859d 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -676,6 +676,10 @@ extension Ghostty { return true } + case .html: + // The extension will be HTML and we do the right thing automatically. + break + case .unknown: break } diff --git a/src/Surface.zig b/src/Surface.zig index 6d5c9b683..cbc3c0cee 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5280,14 +5280,24 @@ const WriteScreenLoc = enum { fn writeScreenFile( self: *Surface, loc: WriteScreenLoc, - write_action: input.Binding.Action.WriteScreenAction, + write_screen: input.Binding.Action.WriteScreen, ) !void { // Create a temporary directory to store our scrollback. var tmp_dir = try internal_os.TempDir.init(); errdefer tmp_dir.deinit(); var filename_buf: [std.fs.max_path_bytes]u8 = undefined; - const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)}); + const filename = try std.fmt.bufPrint( + &filename_buf, + "{s}.{s}", + .{ + @tagName(loc), + switch (write_screen.emit) { + .plain, .vt => "txt", + .html => "html", + }, + }, + ); // Open our scrollback file var file = try tmp_dir.dir.createFile( @@ -5347,18 +5357,24 @@ fn writeScreenFile( return; }; - // Use topLeft and bottomRight to ensure correct coordinate ordering - const tl = sel.topLeft(&self.io.terminal.screen); - const br = sel.bottomRight(&self.io.terminal.screen); - - try self.io.terminal.screen.dumpString( - buf_writer, - .{ - .tl = tl, - .br = br, - .unwrap = true, + const ScreenFormatter = terminal.formatter.ScreenFormatter; + var formatter: ScreenFormatter = .init(&self.io.terminal.screen, .{ + .emit = switch (write_screen.emit) { + .plain => .plain, + .vt => .vt, + .html => .html, }, - ); + .unwrap = true, + .trim = false, + .background = self.io.terminal.colors.background.get(), + .foreground = self.io.terminal.colors.foreground.get(), + .palette = &self.io.terminal.colors.palette.current, + }); + formatter.content = .{ .selection = sel.ordered( + &self.io.terminal.screen, + .forward, + ) }; + try formatter.format(buf_writer); } try buf_writer.flush(); @@ -5366,7 +5382,7 @@ fn writeScreenFile( var path_buf: [std.fs.max_path_bytes]u8 = undefined; const path = try tmp_dir.dir.realpath(filename, &path_buf); - switch (write_action) { + switch (write_screen.action) { .copy => { const pathZ = try self.alloc.dupeZ(u8, path); defer self.alloc.free(pathZ); @@ -5375,7 +5391,13 @@ fn writeScreenFile( .data = pathZ, }}, false); }, - .open => try self.openUrl(.{ .kind = .text, .url = path }), + .open => try self.openUrl(.{ + .kind = switch (write_screen.emit) { + .plain, .vt => .text, + .html => .html, + }, + .url = path, + }), .paste => self.io.queueMessage(try termio.Message.writeReq( self.alloc, path, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index e593d4bce..1c286e98d 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -724,6 +724,9 @@ pub const OpenUrl = struct { /// should try to open the URL in a text editor or viewer or /// some equivalent, if possible. text, + + /// The URL is known to contain HTML content. + html, }; // Sync with: ghostty_action_open_url_s diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 26278f386..94868c2c1 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -434,13 +434,13 @@ pub const Action = union(enum) { /// The default OS editor is determined by using `open` on macOS /// and `xdg-open` on Linux. /// - write_scrollback_file: WriteScreenAction, + write_scrollback_file: WriteScreen, /// Write the contents of the screen into a temporary file with the /// specified action. /// /// See `write_scrollback_file` for possible actions. - write_screen_file: WriteScreenAction, + write_screen_file: WriteScreen, /// Write the currently selected text into a temporary file with the /// specified action. @@ -448,7 +448,7 @@ pub const Action = union(enum) { /// See `write_scrollback_file` for possible actions. /// /// Does nothing when no text is selected. - write_selection_file: WriteScreenAction, + write_selection_file: WriteScreen, /// Open a new window. /// @@ -811,6 +811,15 @@ pub const Action = union(enum) { .application = try alloc.dupe(u8, self.application), }; } + + pub fn format( + self: CursorKey, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + _ = self; + _ = writer; + @panic("formatting not supported"); + } }; pub const AdjustSelection = enum { @@ -902,10 +911,64 @@ pub const Action = union(enum) { pub const default: CopyToClipboard = .mixed; }; - pub const WriteScreenAction = enum { - copy, - paste, - open, + pub const WriteScreen = struct { + action: WriteScreen.Action, + emit: WriteScreen.Format, + + pub const copy: WriteScreen = .{ .action = .copy, .emit = .plain }; + pub const paste: WriteScreen = .{ .action = .paste, .emit = .plain }; + pub const open: WriteScreen = .{ .action = .open, .emit = .plain }; + + pub const Action = enum { + copy, + paste, + open, + }; + + pub const Format = enum { + plain, + vt, + html, + }; + + pub fn parse(param: []const u8) !WriteScreen { + // If we don't have a `,`, default to the plain format. This is + // also very important for backwards compatibility before Ghostty + // 1.3 which didn't support output formats. + const idx = std.mem.indexOfScalar(u8, param, ',') orelse return .{ + .action = try Binding.Action.parseEnum( + WriteScreen.Action, + param, + ), + .emit = .plain, + }; + + return .{ + .action = try Binding.Action.parseEnum( + WriteScreen.Action, + param[0..idx], + ), + .emit = try Binding.Action.parseEnum( + WriteScreen.Format, + param[idx + 1 ..], + ), + }; + } + + pub fn clone( + self: WriteScreen, + alloc: Allocator, + ) Allocator.Error!WriteScreen { + _ = alloc; + return self; + } + + pub fn format(self: WriteScreen, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.print("{t},{t}", .{ + self.action, + self.emit, + }); + } }; // Extern because it is used in the embedded runtime ABI. @@ -948,7 +1011,7 @@ pub const Action = union(enum) { if (@hasDecl(field.type, "parse") and @typeInfo(@TypeOf(field.type.parse)) == .@"fn") { - return field.type.parse(param); + return try field.type.parse(param); } } @@ -1244,12 +1307,19 @@ pub const Action = union(enum) { .@"enum" => try writer.print("{t}", .{value}), .float => try writer.print("{d}", .{value}), .int => try writer.print("{d}", .{value}), - .@"struct" => |info| if (!info.is_tuple) { - try writer.print("{} (not configurable)", .{value}); - } else { - inline for (info.fields, 0..) |field, i| { - try formatValue(writer, @field(value, field.name)); - if (i + 1 < info.fields.len) try writer.writeAll(","); + .@"struct" => |info| format: { + if (@hasDecl(Value, "format")) { + try value.format(writer); + break :format; + } + + if (!info.is_tuple) { + @compileError("unhandled struct type: " ++ @typeName(Value)); + } else { + inline for (info.fields, 0..) |field, i| { + try formatValue(writer, @field(value, field.name)); + if (i + 1 < info.fields.len) try writer.writeAll(","); + } } }, else => @compileError("unhandled type: " ++ @typeName(Value)), @@ -3274,6 +3344,62 @@ test "parse: copy to clipboard explicit" { } } +test "parse: write screen file no format" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=write_screen_file:copy"); + try testing.expect(binding.action == .write_screen_file); + try testing.expectEqual(Action.WriteScreen.copy, binding.action.write_screen_file); + } +} + +test "parse: write screen file format" { + const testing = std.testing; + + // parameter + { + const binding = try parseSingle("a=write_screen_file:copy,html"); + try testing.expect(binding.action == .write_screen_file); + try testing.expectEqual(Action.WriteScreen{ + .action = .copy, + .emit = .html, + }, binding.action.write_screen_file); + } +} + +test "parse: write screen file format as string" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + const binding = try parseSingle("a=write_screen_file:copy,html"); + try binding.action.format(&buf.writer); + try testing.expectEqualStrings("write_screen_file:copy,html", buf.written()); + } +} + +test "parse: write screen file invalid" { + const testing = std.testing; + + // paramet r + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:,", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:copy,", + )); + try testing.expectError(Error.InvalidFormat, parseSingle( + "a=write_screen_file:copy,html,extra", + )); +} + test "action: format" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/input/command.zig b/src/input/command.zig index b97b98cca..f38295a4f 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -239,6 +239,56 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Copy Screen to Temporary File and Open", .description = "Copy the screen contents to a temporary file and open it.", }, + + .{ + .action = .{ .write_screen_file = .{ + .action = .copy, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Copy Path", + .description = "Copy the screen contents as HTML to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .paste, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Paste Path", + .description = "Copy the screen contents as HTML to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .open, + .emit = .html, + } }, + .title = "Copy Screen as HTML to Temporary File and Open", + .description = "Copy the screen contents as HTML to a temporary file and open it.", + }, + + .{ + .action = .{ .write_screen_file = .{ + .action = .copy, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Copy Path", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .paste, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Paste Path", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_screen_file = .{ + .action = .open, + .emit = .vt, + } }, + .title = "Copy Screen as ANSI Sequences to Temporary File and Open", + .description = "Copy the screen contents as ANSI escape sequences to a temporary file and open it.", + }, }, .write_selection_file => comptime &.{ @@ -257,6 +307,56 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Copy Selection to Temporary File and Open", .description = "Copy the selection contents to a temporary file and open it.", }, + + .{ + .action = .{ .write_selection_file = .{ + .action = .copy, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Copy Path", + .description = "Copy the selection contents as HTML to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .paste, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Paste Path", + .description = "Copy the selection contents as HTML to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .open, + .emit = .html, + } }, + .title = "Copy Selection as HTML to Temporary File and Open", + .description = "Copy the selection contents as HTML to a temporary file and open it.", + }, + + .{ + .action = .{ .write_selection_file = .{ + .action = .copy, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Copy Path", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and copy the path to the clipboard.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .paste, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Paste Path", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and paste the path to the file.", + }, + .{ + .action = .{ .write_selection_file = .{ + .action = .open, + .emit = .vt, + } }, + .title = "Copy Selection as ANSI Sequences to Temporary File and Open", + .description = "Copy the selection contents as ANSI escape sequences to a temporary file and open it.", + }, }, .new_window => comptime &.{.{ diff --git a/src/os/open.zig b/src/os/open.zig index 9b069c80f..28d1c23ee 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -34,7 +34,7 @@ pub fn open( .macos => .init( switch (kind) { .text => &.{ "open", "-t", url }, - .unknown => &.{ "open", url }, + .html, .unknown => &.{ "open", url }, }, alloc, ), From 46db1cfd8f7839899c2feaa83ce01bdd8ded67c6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Oct 2025 10:51:24 -0700 Subject: [PATCH 243/702] apprt/gtk: set multiple content types for clipboard ops This supports the new `setClipboard` parameter that may provide data in multiple formats, allowing us to copy rich text to/from the clipboard as well as other types in the future. --- src/Surface.zig | 2 +- src/apprt/gtk/class/surface.zig | 31 ++++++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index cbc3c0cee..346cbb8bf 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1962,7 +1962,7 @@ fn copySelectionToClipboards( ) !void { // Create an arena to simplify memory management here. var arena = ArenaAllocator.init(self.alloc); - errdefer arena.deinit(); + defer arena.deinit(); const alloc = arena.allocator(); // The options we'll use for all formatting. We'll just override the diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 3f6d64652..fcbfbe6ab 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3340,8 +3340,9 @@ const Clipboard = struct { ) void { const priv = self.private(); - // For GTK, we only support text/plain type to set strings currently. - const val: [:0]const u8 = for (contents) |content| { + // Grab our plaintext content for use in confirmation dialogs + // and signals. We always expect one to exist. + const text: [:0]const u8 = for (contents) |content| { if (std.mem.eql(u8, content.mime, "text/plain")) { break content.data; } @@ -3353,12 +3354,32 @@ const Clipboard = struct { priv.gl_area.as(gtk.Widget), clipboard_type, ) orelse return; - clipboard.setText(val); + + const alloc = Application.default().allocator(); + if (alloc.alloc(*gdk.ContentProvider, contents.len)) |providers| { + // Note: we don't need to unref the individual providers + // because new_union takes ownership of them. + defer alloc.free(providers); + + for (contents, 0..) |content, i| { + const bytes = glib.Bytes.new(content.data.ptr, content.data.len); + defer bytes.unref(); + const provider = gdk.ContentProvider.newForBytes(content.mime, bytes); + providers[i] = provider; + } + + const all = gdk.ContentProvider.newUnion(providers.ptr, providers.len); + defer all.unref(); + _ = clipboard.setContent(all); + } else |_| { + // If we fail to alloc, we can at least set the text content. + clipboard.setText(text); + } Surface.signals.@"clipboard-write".impl.emit( self, null, - .{ clipboard_type, val.ptr }, + .{ clipboard_type, text.ptr }, null, ); @@ -3368,7 +3389,7 @@ const Clipboard = struct { showClipboardConfirmation( self, .{ .osc_52_write = clipboard_type }, - val, + text, ); } From 9e2caedb7d531877cae9fb5da69cf7da3e018359 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 2 Nov 2025 00:15:16 +0000 Subject: [PATCH 244/702] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 2f238d8a2..7c686cecc 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251020-150521-589c0ea/ghostty-themes.tgz", - .hash = "N-V-__8AAPk1AwBAULkMLaJVuAoOyhTMRIh-joZs-jRJKHBJ", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/ghostty-themes.tgz", + .hash = "N-V-__8AAPk1AwCUvXvHG6RrcHLBcnO9OM9_eld_kjLSz6wm", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 16f0f1f4e..ad0edaa50 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAPk1AwBAULkMLaJVuAoOyhTMRIh-joZs-jRJKHBJ": { + "N-V-__8AAPk1AwCUvXvHG6RrcHLBcnO9OM9_eld_kjLSz6wm": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251020-150521-589c0ea/ghostty-themes.tgz", - "hash": "sha256-y2vhwlDUpgC6x5XPpDY96KNSHd/sRhyJpgiuzstP9p0=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/ghostty-themes.tgz", + "hash": "sha256-KyZrjqOjmrgIZSI9LXTEX5xS7OohaD0Fy1yGZ8uH0YQ=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 7ab87e26c..ccd6285d9 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AAPk1AwBAULkMLaJVuAoOyhTMRIh-joZs-jRJKHBJ"; + name = "N-V-__8AAPk1AwCUvXvHG6RrcHLBcnO9OM9_eld_kjLSz6wm"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251020-150521-589c0ea/ghostty-themes.tgz"; - hash = "sha256-y2vhwlDUpgC6x5XPpDY96KNSHd/sRhyJpgiuzstP9p0="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/ghostty-themes.tgz"; + hash = "sha256-KyZrjqOjmrgIZSI9LXTEX5xS7OohaD0Fy1yGZ8uH0YQ="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index c0576d8f2..8abc4fb7d 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -29,7 +29,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/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251020-150521-589c0ea/ghostty-themes.tgz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/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 3de5fd0e6..2a1e38986 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-20251020-150521-589c0ea/ghostty-themes.tgz", - "dest": "vendor/p/N-V-__8AAPk1AwBAULkMLaJVuAoOyhTMRIh-joZs-jRJKHBJ", - "sha256": "cb6be1c250d4a600bac795cfa4363de8a3521ddfec461c89a608aececb4ff69d" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AAPk1AwCUvXvHG6RrcHLBcnO9OM9_eld_kjLSz6wm", + "sha256": "2b266b8ea3a39ab80865223d2d74c45f9c52ecea21683d05cb5c8667cb87d184" }, { "type": "archive", From f6faf2a5150e5558d24e7a8af5bbfdeca681885b Mon Sep 17 00:00:00 2001 From: realguse Date: Sun, 2 Nov 2025 14:52:54 +0100 Subject: [PATCH 245/702] chore(i18n): prefer using ellipsis over three dots --- po/nb_NO.UTF-8.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/po/nb_NO.UTF-8.po b/po/nb_NO.UTF-8.po index 047736470..65940d9c7 100644 --- a/po/nb_NO.UTF-8.po +++ b/po/nb_NO.UTF-8.po @@ -90,7 +90,7 @@ msgstr "Del til høyre" #: src/apprt/gtk/ui/1.5/command-palette.blp:16 msgid "Execute a command…" -msgstr "Kjør en kommando..." +msgstr "Kjør en kommando…" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 From e7c68142e343a583cfeff038784619903aff969b Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Sun, 2 Nov 2025 10:44:40 -0800 Subject: [PATCH 246/702] apprt/gtk: (clipboard) add X11 atoms, extra MIME types for text content This adds the UTF8_STRING atom and explicit UTF-8 character set MIME type (text/plain;charset=utf-8) to text content when being sent to the clipboard under the new multipart support. This fixes clipboard support under X11 particularly, which generally looks for the UTF8_STRING atom when looking for text content. This can be verified with xclip -out -verbose, or trying to do things like paste in Firefox. I've noted that there's a number of other older atoms that exist, but I've refrained from adding them for now. Kitty only seems to set UTF8_STRING and I've had a hard time finding consensus on what exactly is the correct set otherwise. --- src/apprt/gtk/class/surface.zig | 38 +++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index fcbfbe6ab..2cd032f08 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3364,8 +3364,42 @@ const Clipboard = struct { for (contents, 0..) |content, i| { const bytes = glib.Bytes.new(content.data.ptr, content.data.len); defer bytes.unref(); - const provider = gdk.ContentProvider.newForBytes(content.mime, bytes); - providers[i] = provider; + if (std.mem.eql(u8, content.mime, "text/plain")) { + // Add some extra MIME types (and X11 atoms) for + // text/plain. This can be expanded on if certain + // applications are expecting text in a particular type + // or atom that is not currently here; UTF8_STRING + // seems to be the most common one for modern X11, but + // there are some older ones, e.g., XA_STRING or just + // plain STRING. Kitty seems to get by with just + // UTF8_STRING, but I'm also adding the explicit utf-8 + // MIME parameter for correctness; technically, for + // MIME, when the charset is missing, the default + // charset is ASCII. + const text_provider_atoms = [_][:0]const u8{ + "text/plain", + "text/plain;charset=utf-8", + "UTF8_STRING", + }; + // Following on the same logic as our outer union, + // looks like we only need this memory during union + // construction, so it's okay if this is just a + // static-length array and goes out of scope when we're + // done. Similarly, we don't unref these providers. + var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined; + for (text_provider_atoms, 0..) |atom, j| { + const provider = gdk.ContentProvider.newForBytes(atom, bytes); + text_providers[j] = provider; + } + const text_union = gdk.ContentProvider.newUnion( + &text_providers, + text_providers.len, + ); + providers[i] = text_union; + } else { + const provider = gdk.ContentProvider.newForBytes(content.mime, bytes); + providers[i] = provider; + } } const all = gdk.ContentProvider.newUnion(providers.ptr, providers.len); From d4f474bb35d28382fff1a6c467f09098ed13eb33 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Sun, 2 Nov 2025 13:15:58 -0600 Subject: [PATCH 247/702] nix: don't use deprecated pkgs.system 'system' has been renamed to/replaced by 'stdenv.hostPlatform.system' https://github.com/NixOS/nixpkgs/pull/456527 --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 85550b989..9ede1c788 100644 --- a/flake.nix +++ b/flake.nix @@ -107,10 +107,10 @@ overlays = { default = self.overlays.releasefast; releasefast = final: prev: { - ghostty = self.packages.${prev.system}.ghostty-releasefast; + ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-releasefast; }; debug = final: prev: { - ghostty = self.packages.${prev.system}.ghostty-debug; + ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug; }; }; create-vm = import ./nix/vm/create.nix; From 551c1e68e0ede52ed495e06df3e62ac8fd1f533b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:07:29 +0000 Subject: [PATCH 248/702] build(deps): bump namespacelabs/nscloud-cache-action Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.20 to 1.2.21. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/d93899d984f5f3ba6b18c53a318e59994a3449be...446d8f390563cd54ca27e8de5bdb816f63c0b706) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.2.21 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 | 2 +- .github/workflows/snap.yml | 2 +- .github/workflows/test.yml | 48 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 7525e2470..69799e2c2 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 489b15324..d0387b45d 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index d5b1e20f5..5ea5ef067 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -161,7 +161,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 90c19dae8..641bbcca6 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -38,7 +38,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5bf10a145..2ff5c57a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,7 +72,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -115,7 +115,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -148,7 +148,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -182,7 +182,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -225,7 +225,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -261,7 +261,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -290,7 +290,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -323,7 +323,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -369,7 +369,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -588,7 +588,7 @@ jobs: echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -630,7 +630,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -678,7 +678,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -713,7 +713,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -777,7 +777,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -806,7 +806,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -834,7 +834,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -861,7 +861,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -888,7 +888,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -915,7 +915,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -942,7 +942,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -976,7 +976,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -1003,7 +1003,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -1038,7 +1038,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix @@ -1126,7 +1126,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 0ae7733a0..2bc79241a 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@d93899d984f5f3ba6b18c53a318e59994a3449be # v1.2.20 + uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 with: path: | /nix From d678e2e30511751cf066af48c0f5d806fe696c81 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Mon, 3 Nov 2025 22:48:54 -0800 Subject: [PATCH 249/702] Use notifications to deal with NSScrollPocket --- macos/Sources/Ghostty/SurfaceScrollView.swift | 70 +++++++++++++++---- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index e2c6946c7..6fdac9973 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -102,6 +102,19 @@ class SurfaceScrollView: NSView { self?.handleLiveScroll() }) + // Listen for frame change events. See the docstring for + // handleFrameChange for why this is necessary. + observers.append(NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: nil, + // Since this observer is used to immediately override the event + // that produced the notification, we let it run synchronously on + // the posting thread. + queue: nil + ) { [weak self] notification in + self?.handleFrameChange(notification) + }) + // Listen for derived config changes to update scrollbar settings live surfaceView.$derivedConfig .sink { [weak self] _ in @@ -134,20 +147,6 @@ class SurfaceScrollView: NSView { override func layout() { super.layout() - - // The SwiftUI ScrollView host likes to add its own styling overlays to - // the titlebar area, which are incompatible with the hidden titlebar - // style. They won't be present when the app is first opened, but will - // appear when creating splits or cycling fullscreen. There's no public - // way to disable them in AppKit, so we just have to play whack-a-mole. - // See https://developer.apple.com/forums/thread/798392. - if window is HiddenTitlebarTerminalWindow { - for view in scrollView.subviews { - if view.className.contains("NSScrollPocket") { - view.removeFromSuperview() - } - } - } // Fill entire bounds with scroll view scrollView.frame = bounds @@ -283,6 +282,49 @@ class SurfaceScrollView: NSView { scrollView.reflectScrolledClipView(scrollView.contentView) } + /// Handles a change in the frame of NSScrollPocket styling overlays + /// + /// NSScrollView instances are set up with a subview hierarchy which, as far + /// as I can tell, is intended to add a blur effect to any part of a scroll + /// view that lies under the titlebar, presumably to complement a titlebar + /// using liquid glass transparency. This doesn't work correctly with our + /// hidden titlebar style, which does have a titlebar container, albeit + /// hidden. The styling overlays don't care and size themselves to this + /// container, creating a blurry, transparent field that clips the top of + /// the surface view. + /// + /// With other titlebar styles, these views always have zero frame size, + /// presumably because there is no overlap between the scroll view and the + /// titlebar container. + /// + /// In native fullscreen, the titlebar detaches from the window and these + /// views seem to work a bit differently, taking non-zero sizes for all + /// styles without creating any problems. + /// + /// To handle this in a way that minimizes the difference between how the + /// hidden titlebar and other window styles behave, we do as follows: If we + /// have the hidden titlebar style and we're not fullscreen, we listen to + /// frame changes on NSScrollPocket-related objects in scrollView.subviews, + /// and reset their frame to zero. + /// + /// See also https://developer.apple.com/forums/thread/798392. + private func handleFrameChange(_ notification: Notification) { + guard let window = window as? HiddenTitlebarTerminalWindow else { return } + guard !window.styleMask.contains(.fullScreen) else { return } + guard let view = notification.object as? NSView else { return } + guard view.className.contains("NSScrollPocket") else { return } + guard scrollView.subviews.contains(view) else { return } + // These guards to avoid an infinite loop don't actually seem necessary. + // The number of times we reach this point during any given event (e.g., + // creating a split) is the same either way. We keep them anyway out of + // an abundance of caution. + view.postsFrameChangedNotifications = false + view.frame = NSRect(x: 0, y: 0, width: 0, height: 0) + view.postsFrameChangedNotifications = true + } + + // MARK: Calculations + /// Calculate the appropriate document view height given a scrollbar state private func documentHeight(_ scrollbar: Ghostty.Action.Scrollbar?) -> CGFloat { let contentHeight = scrollView.contentSize.height From 9002c5dbd278497d547c081ee7e21c7fdcc0a6b1 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Mon, 3 Nov 2025 22:51:30 -0800 Subject: [PATCH 250/702] Preserve surface content size across backing updates --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 410646f6f..a04d00761 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -89,6 +89,14 @@ extension Ghostty { // then the view is moved to a new window. var initialSize: NSSize? = nil + // A content size received through sizeDidChange that may in some cases + // be different from the frame size. + private var contentSizeBacking: NSSize? + private var contentSize: NSSize { + get { return contentSizeBacking ?? frame.size } + set { contentSizeBacking = newValue } + } + // Set whether the surface is currently on a password input or not. This is // detected with the set_password_input_cb on the Ghostty state. var passwordInput: Bool = false { @@ -410,6 +418,8 @@ extension Ghostty { // The size represents our final size we're going for. let scaledSize = self.convertToBacking(size) setSurfaceSize(width: UInt32(scaledSize.width), height: UInt32(scaledSize.height)) + // Store this size so we can reuse it when backing properties change + contentSize = size } private func setSurfaceSize(width: UInt32, height: UInt32) { @@ -764,7 +774,8 @@ extension Ghostty { ghostty_surface_set_content_scale(surface, xScale, yScale) // When our scale factor changes, so does our fb size so we send that too - setSurfaceSize(width: UInt32(fbFrame.size.width), height: UInt32(fbFrame.size.height)) + let scaledSize = self.convertToBacking(contentSize) + setSurfaceSize(width: UInt32(scaledSize.width), height: UInt32(scaledSize.height)) } override func mouseDown(with event: NSEvent) { From afc64f628532e6d4a40e2437da1f8e3e99e1d2ec Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 1 Nov 2025 14:40:48 -0700 Subject: [PATCH 251/702] Refactor scrollview to preserve state across split tree changes --- macos/Sources/Ghostty/SurfaceScrollView.swift | 141 +++++++++--------- macos/Sources/Ghostty/SurfaceView.swift | 4 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 + 3 files changed, 77 insertions(+), 71 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 6fdac9973..e455a32c8 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -19,7 +19,6 @@ class SurfaceScrollView: NSView { private var observers: [NSObjectProtocol] = [] private var cancellables: Set = [] private var isLiveScrolling = false - private var currentScrollbar: Ghostty.Action.Scrollbar? = nil /// The last row position sent via scroll_to_row action. Used to avoid /// sending redundant actions when the user drags the scrollbar but stays @@ -137,13 +136,6 @@ class SurfaceScrollView: NSView { // insets. This is necessary for the content view to match the // surface view if we have the "hidden" titlebar style. override var safeAreaInsets: NSEdgeInsets { return NSEdgeInsetsZero } - - override func setFrameSize(_ newSize: NSSize) { - super.setFrameSize(newSize) - - // Force layout to be called to fix up our various subviews. - needsLayout = true - } override func layout() { super.layout() @@ -151,43 +143,22 @@ class SurfaceScrollView: NSView { // Fill entire bounds with scroll view scrollView.frame = bounds - // Use contentSize to account for visible scrollers - // - // Only update sizes if we have a valid (non-zero) content size. The content size - // can be zero when this is added early to a view, or to an invisible hierarchy. - // Practically, this happened in the quick terminal. - var contentSize = scrollView.contentSize - guard contentSize.width > 0 && contentSize.height > 0 else { - synchronizeSurfaceView() - return - } - - // If we have a legacy scrollbar and its not visible, then we account for that - // in advance, because legacy scrollbars change our contentSize and force reflow - // of our terminal which is not desirable. - // See: https://github.com/ghostty-org/ghostty/discussions/9254 - let style = scrollView.verticalScroller?.scrollerStyle ?? NSScroller.preferredScrollerStyle - if style == .legacy { - if (scrollView.verticalScroller?.isHidden ?? true) { - let scrollerWidth = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) - contentSize.width -= scrollerWidth - } - } - - // Keep document width synchronized with content width, and update the - // document height as appropriate for the current surface size. - documentView.setFrameSize(CGSize( - width: contentSize.width, - height: documentHeight(currentScrollbar), - )) + // When our scrollview changes make sure our scroller and surface views are synchronized + synchronizeScrollView() + synchronizeSurfaceView() // Inform the actual pty of our size change. This doesn't change the actual view // frame because we do want to render the whole thing, but it will prevent our // rows/cols from going into the non-content area. - surfaceView.sizeDidChange(contentSize) - - // When our scrollview changes make sure our surface view is synchronized - synchronizeSurfaceView() + // + // Only update the pty if we have a valid (non-zero) content size. The content size + // can be zero when this is added early to a view, or to an invisible hierarchy. + // Practically, this happened in the quick terminal. + let width = surfaceContentWidth() + let height = surfaceView.frame.height + if width > 0 && height > 0 { + surfaceView.sizeDidChange(CGSize(width: width, height: height)) + } } // MARK: Scrolling @@ -206,6 +177,38 @@ class SurfaceScrollView: NSView { let visibleRect = scrollView.contentView.documentVisibleRect surfaceView.frame = visibleRect } + + /// Sizes the document view and scrolls the content view according to the scrollbar state + private func synchronizeScrollView() { + // We adjust the document height first, as the content width may depend on it. + documentView.frame.size.height = documentHeight() + + // Our width should be the content width to account for visible scrollers. + // We don't do horizontal scrolling in terminals. The surfaceView width is + // yoked to the document width (this is distinct from the content width + // passed to surfaceView.sizeDidChange, which is only updated on layout). + documentView.frame.size.width = scrollView.contentSize.width + surfaceView.frame.size.width = scrollView.contentSize.width + + // Only update our actual scroll position if we're not actively scrolling. + if !isLiveScrolling { + // Convert row units to pixels using cell height, ignore zero height. + let cellHeight = surfaceView.cellSize.height + if cellHeight > 0, let scrollbar = surfaceView.scrollbar { + // Invert coordinate system: terminal offset is from top, AppKit position from bottom + let offsetY = + CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight + scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) + + // Track the current row position to avoid redundant movements when we + // move the scrollbar. + lastSentRow = Int(scrollbar.offset) + } + } + + // Always update our scrolled view with the latest dimensions + scrollView.reflectScrolledClipView(scrollView.contentView) + } // MARK: Notifications @@ -254,32 +257,8 @@ class SurfaceScrollView: NSView { guard let scrollbar = notification.userInfo?[SwiftUI.Notification.Name.ScrollbarKey] as? Ghostty.Action.Scrollbar else { return } - currentScrollbar = scrollbar - - // Convert row units to pixels using cell height, ignore zero height. - let cellHeight = surfaceView.cellSize.height - guard cellHeight > 0 else { return } - - // Our width should be the content width to account for visible scrollers. - // We don't do horizontal scrolling in terminals. - documentView.setFrameSize(CGSize( - width: scrollView.contentSize.width, - height: documentHeight(scrollbar), - )) - - // Only update our actual scroll position if we're not actively scrolling. - if !isLiveScrolling { - // Invert coordinate system: terminal offset is from top, AppKit position from bottom - let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight - scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) - - // Track the current row position to avoid redundant movements when we - // move the scrollbar. - lastSentRow = Int(scrollbar.offset) - } - - // Always update our scrolled view with the latest dimensions - scrollView.reflectScrolledClipView(scrollView.contentView) + surfaceView.scrollbar = scrollbar + synchronizeScrollView() } /// Handles a change in the frame of NSScrollPocket styling overlays @@ -325,11 +304,35 @@ class SurfaceScrollView: NSView { // MARK: Calculations + /// Calculate the content width reported to the core surface + /// + /// If we have a legacy scrollbar and its not visible, then we account for that + /// in advance, because legacy scrollbars change our contentSize and force reflow + /// of our terminal which is not desirable. + /// See: https://github.com/ghostty-org/ghostty/discussions/9254 + private func surfaceContentWidth() -> CGFloat { + let contentWidth = scrollView.contentSize.width + if scrollView.hasVerticalScroller { + let style = + scrollView.verticalScroller?.scrollerStyle + ?? NSScroller.preferredScrollerStyle + // We only subtract the scrollbar width if it's hidden or not present, + // otherwise its width is already accounted for in contentSize. + if style == .legacy && (scrollView.verticalScroller?.isHidden ?? true) { + let scrollerWidth = + scrollView.verticalScroller?.frame.width + ?? NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + return max(0, contentWidth - scrollerWidth) + } + } + return contentWidth + } + /// Calculate the appropriate document view height given a scrollbar state - private func documentHeight(_ scrollbar: Ghostty.Action.Scrollbar?) -> CGFloat { + private func documentHeight() -> CGFloat { let contentHeight = scrollView.contentSize.height let cellHeight = surfaceView.cellSize.height - if cellHeight > 0, let scrollbar = scrollbar { + if cellHeight > 0, let scrollbar = surfaceView.scrollbar { // The document view must have the same vertical padding around the // scrollback grid as the content view has around the terminal grid // otherwise the content view loses alignment with the surface. diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index c650bdf8f..b42e34314 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -407,8 +407,8 @@ extension Ghostty { } func updateOSView(_ scrollView: SurfaceScrollView, context: Context) { - // Our scrollview always takes up the full size. - scrollView.frame.size = size + // Nothing to do: SwiftUI automatically updates the frame size, and + // SurfaceScrollView handles the rest in response to that } #else func makeOSView(context: Context) -> SurfaceView { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index a04d00761..3ab2f3e94 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -152,6 +152,9 @@ extension Ghostty { var surface: ghostty_surface_t? { surfaceModel?.unsafeCValue } + /// Current scrollbar state, cached here for persistence across rebuilds + /// of the SwiftUI view hierarchy, for example when changing splits + var scrollbar: Ghostty.Action.Scrollbar? // Notification identifiers associated with this surface var notificationIdentifiers: Set = [] From 13d5f0c503d8908b61fb2fc2265190b71f5e7e99 Mon Sep 17 00:00:00 2001 From: Avery Mcnab Date: Tue, 4 Nov 2025 15:02:03 +0000 Subject: [PATCH 252/702] Add an example for `palette` configuration I found this syntax a bit confusing when I first read it, so I thought it would be helpful for the next person to add a short example. --- src/config/Config.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index f8322d5fc..027c5b158 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -699,7 +699,8 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// Color palette for the 256 color form that many terminal applications use. /// The syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for /// the 256 colors in the terminal color table) and `COLOR` is a typical RGB -/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color. +/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color. For example, +/// `palette = 5=#BB78D9` will set the 'purple' color. /// /// The palette index can be in decimal, binary, octal, or hexadecimal. /// Decimal is assumed unless a prefix is used: `0b` for binary, `0o` for octal, From 7472fb773290d45e1402244569678bf9700d1306 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:27:35 +0100 Subject: [PATCH 253/702] macOS: set the macos-icon from a separate thread --- macos/Sources/App/macOS/AppDelegate.swift | 68 +++++++++++++---------- macos/Sources/Ghostty/Package.swift | 2 +- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a723d015a..5da2f1d5b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -119,19 +119,7 @@ class AppDelegate: NSObject, private var signals: [DispatchSourceSignal] = [] /// The custom app icon image that is currently in use. - @Published private(set) var appIcon: NSImage? = nil { - didSet { -#if DEBUG - // if no custom icon specified, we use blueprint to distinguish from release app - NSApplication.shared.applicationIconImage = appIcon ?? NSImage(named: "BlueprintImage") -#else - NSApplication.shared.applicationIconImage = appIcon -#endif - let appPath = Bundle.main.bundlePath - NSWorkspace.shared.setIcon(appIcon, forFile: appPath, options: []) - NSWorkspace.shared.noteFileSystemChanged(appPath) - } - } + @Published private(set) var appIcon: NSImage? = nil override init() { super.init() @@ -874,48 +862,53 @@ class AppDelegate: NSObject, } else { GlobalEventTap.shared.disable() } + Task { + await updateAppIcon(from: config) + } } /// Sync the appearance of our app with the theme specified in the config. private func syncAppearance(config: Ghostty.Config) { NSApplication.shared.appearance = .init(ghosttyConfig: config) - + } + + @concurrent + private func updateAppIcon(from config: Ghostty.Config) async { + var appIcon: NSImage? + switch (config.macosIcon) { case .official: - self.appIcon = nil break - case .blueprint: - self.appIcon = NSImage(named: "BlueprintImage")! + appIcon = NSImage(named: "BlueprintImage")! case .chalkboard: - self.appIcon = NSImage(named: "ChalkboardImage")! + appIcon = NSImage(named: "ChalkboardImage")! case .glass: - self.appIcon = NSImage(named: "GlassImage")! + appIcon = NSImage(named: "GlassImage")! case .holographic: - self.appIcon = NSImage(named: "HolographicImage")! + appIcon = NSImage(named: "HolographicImage")! case .microchip: - self.appIcon = NSImage(named: "MicrochipImage")! + appIcon = NSImage(named: "MicrochipImage")! case .paper: - self.appIcon = NSImage(named: "PaperImage")! + appIcon = NSImage(named: "PaperImage")! case .retro: - self.appIcon = NSImage(named: "RetroImage")! + appIcon = NSImage(named: "RetroImage")! case .xray: - self.appIcon = NSImage(named: "XrayImage")! + appIcon = NSImage(named: "XrayImage")! case .custom: if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) { - self.appIcon = userIcon + appIcon = userIcon } else { - self.appIcon = nil // Revert back to official icon if invalid location + appIcon = nil // Revert back to official icon if invalid location } - case .customStyle: guard let ghostColor = config.macosIconGhostColor else { break } guard let screenColors = config.macosIconScreenColor else { break } @@ -924,7 +917,26 @@ class AppDelegate: NSObject, ghostColor: ghostColor, frame: config.macosIconFrame ).makeImage() else { break } - self.appIcon = icon + appIcon = icon + } + // make it immutable, so Swift 6 won't complain + let newIcon = appIcon + + let appPath = Bundle.main.bundlePath + NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) + NSWorkspace.shared.noteFileSystemChanged(appPath) + + 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 } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 91714f084..f36b486ba 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -318,7 +318,7 @@ extension Ghostty { } /// macos-icon - enum MacOSIcon: String { + enum MacOSIcon: String, Sendable { case official case blueprint case chalkboard From 98ae1dbd10d79dc6586fd644c54599675f98bba0 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:22:08 +0100 Subject: [PATCH 254/702] macOS: fix Dictation icon starting above the text, not below Partially fixes #8493. After dictating some texts, the icon still appears above, but it will return to its right position after resizing or `\n` (saying newline, not hitting enter). This behaviour is better than before, where the icon always appeared above. ### Reference: https://github.com/emacs-mirror/emacs/blob/9e905357bb5da9c0d54ec9d8a708ff0ac0411a09/src/nsterm.m#L7426 --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 410646f6f..46014ac25 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1728,10 +1728,13 @@ extension Ghostty.SurfaceView: NSTextInputClient { // 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, + // width should be 0 so that dictation indicator + // can start in the right place let viewRect = NSMakeRect( x, frame.size.height - y, - max(width, cellSize.width), + width, max(height, cellSize.height)) // Convert the point to the window coordinates From 631c58a302168356e15861f5d4ef6d95cac65299 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 5 Nov 2025 14:54:05 -0800 Subject: [PATCH 255/702] unicode: update uucode, force emoji modifiers width 2 as standalone This updates uucode. As part of this, the wcwidth implementation was updated (in uucode) to make emoji modifiers width ZERO. But if they're standalone, we want them as width 2. So this also contains a change to force them as width 2 for our width calculation. This only matters for standalone emoji modifiers, because when they form a valid grapheme we don't use this width calculation. --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- src/build/uucode_config.zig | 17 ++++++++++++++++- src/terminal/Terminal.zig | 29 +++++++++++++++++++++++++++++ 7 files changed, 57 insertions(+), 13 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 7c686cecc..f24871fd6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -39,8 +39,8 @@ }, .uucode = .{ // TODO: currently the use-llvm branch because its broken on self-hosted - .url = "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz", - .hash = "uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT", + .url = "https://github.com/jacobsandlund/uucode/archive/ca307fdeb7eca5c2812b288cdd5650e66b3115eb.tar.gz", + .hash = "uucode-0.1.0-ZZjBPkxTQwCphlTjg-UtY_TzztJy_TZqDL-S2nymmBXY", }, .zig_wayland = .{ // codeberg ifreund/zig-wayland diff --git a/build.zig.zon.json b/build.zig.zon.json index ad0edaa50..526109532 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-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT": { + "uucode-0.1.0-ZZjBPkxTQwCphlTjg-UtY_TzztJy_TZqDL-S2nymmBXY": { "name": "uucode", - "url": "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz", - "hash": "sha256-VomSYOF8fRJwb/8GtVG/QqR6c95zSkQt4649C/4KXAc=" + "url": "https://github.com/jacobsandlund/uucode/archive/ca307fdeb7eca5c2812b288cdd5650e66b3115eb.tar.gz", + "hash": "sha256-AY1JqW7qrWy1+WrlGzL8rHW7mZA6CmYhJVr1sSg19oI=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index ccd6285d9..ab51e34dc 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -267,11 +267,11 @@ in }; } { - name = "uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT"; + name = "uucode-0.1.0-ZZjBPkxTQwCphlTjg-UtY_TzztJy_TZqDL-S2nymmBXY"; path = fetchZigArtifact { name = "uucode"; - url = "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz"; - hash = "sha256-VomSYOF8fRJwb/8GtVG/QqR6c95zSkQt4649C/4KXAc="; + url = "https://github.com/jacobsandlund/uucode/archive/ca307fdeb7eca5c2812b288cdd5650e66b3115eb.tar.gz"; + hash = "sha256-AY1JqW7qrWy1+WrlGzL8rHW7mZA6CmYhJVr1sSg19oI="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 8abc4fb7d..f206259cf 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -20,7 +20,6 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz -https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz @@ -29,6 +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/ca307fdeb7eca5c2812b288cdd5650e66b3115eb.tar.gz https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/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 2a1e38986..8a39a0ccc 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -139,9 +139,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/uucode-f81f8ef8518b8ec5a7fca30ec5fdbc76cc6197df.tar.gz", - "dest": "vendor/p/uucode-0.1.0-ZZjBPjQHQADuCy1VMWftjrMl3iWqgMpUugWVQJG6_7xT", - "sha256": "56899260e17c7d12706fff06b551bf42a47a73de734a442de3ae3d0bfe0a5c07" + "url": "https://github.com/jacobsandlund/uucode/archive/ca307fdeb7eca5c2812b288cdd5650e66b3115eb.tar.gz", + "dest": "vendor/p/uucode-0.1.0-ZZjBPkxTQwCphlTjg-UtY_TzztJy_TZqDL-S2nymmBXY", + "sha256": "018d49a96eeaad6cb5f96ae51b32fcac75bb99903a0a6621255af5b12835f682" }, { "type": "archive", diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index 9a3b4bec7..d9e4cb4a3 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const assert = std.debug.assert; const config = @import("config.zig"); const config_x = @import("config.x.zig"); const d = config.default; @@ -17,11 +18,25 @@ fn computeWidth( _ = cp; _ = 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; + } + data.width = @intCast(@min(2, @max(0, data.wcwidth))); } const width = config.Extension{ - .inputs = &.{"wcwidth"}, + .inputs = &.{ + "is_emoji_modifier", + "wcwidth", + }, .compute = &computeWidth, .fields = &.{ .{ .name = "width", .type = u2 }, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 64cda5ee3..fb3f458f7 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3330,6 +3330,35 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { } } +test "Terminal: Fitzpatrick skin tone next valid base" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "👋🏿" (waving hand with dark skin tone) + try t.print(0x1F44B); // 👋 Waving hand (valid base) + try t.print(0x1F3FF); // 🏿 Dark skin tone modifier + + // The skin tone should combine with the base emoji into a single grapheme cluster, + // taking 2 cells (wide character). + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // The base emoji should be in cell 0 with the skin tone as a grapheme + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F44B), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } +} + test "Terminal: Fitzpatrick skin tone next to non-base" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); From 3d58dc51c9d86c2257442407b8c0ff9a4c1c6f78 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 5 Nov 2025 10:02:12 -0700 Subject: [PATCH 256/702] terminal: keypad variation sequences should respect VS16 This fixes the VS16 issues found in this test: https://ucs-detect.readthedocs.io/sw_results/ghostty.html#ghostty This is also a more robust way to handle VS15/16 in general. This commit also changes our propeties to be a packed struct which reduces its size from 4 bytes to 1 and likewise drops our unicode table size 4x. --- build.zig.zon | 4 +- build.zig.zon.json | 6 +- build.zig.zon.nix | 6 +- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +- src/build/uucode_config.zig | 2 + src/terminal/Terminal.zig | 72 ++++++++++++++++++++++- src/unicode/grapheme.zig | 2 +- src/unicode/main.zig | 2 +- src/unicode/{Properties.zig => props.zig} | 65 +++++++++++--------- src/unicode/props_table.zig | 2 +- src/unicode/props_uucode.zig | 18 +++--- 12 files changed, 136 insertions(+), 51 deletions(-) rename src/unicode/{Properties.zig => props.zig} (50%) diff --git a/build.zig.zon b/build.zig.zon index f24871fd6..199dbce57 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -39,8 +39,8 @@ }, .uucode = .{ // TODO: currently the use-llvm branch because its broken on self-hosted - .url = "https://github.com/jacobsandlund/uucode/archive/ca307fdeb7eca5c2812b288cdd5650e66b3115eb.tar.gz", - .hash = "uucode-0.1.0-ZZjBPkxTQwCphlTjg-UtY_TzztJy_TZqDL-S2nymmBXY", + .url = "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz", + .hash = "uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3", }, .zig_wayland = .{ // codeberg ifreund/zig-wayland diff --git a/build.zig.zon.json b/build.zig.zon.json index 526109532..42054afb7 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-ZZjBPkxTQwCphlTjg-UtY_TzztJy_TZqDL-S2nymmBXY": { + "uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3": { "name": "uucode", - "url": "https://github.com/jacobsandlund/uucode/archive/ca307fdeb7eca5c2812b288cdd5650e66b3115eb.tar.gz", - "hash": "sha256-AY1JqW7qrWy1+WrlGzL8rHW7mZA6CmYhJVr1sSg19oI=" + "url": "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz", + "hash": "sha256-jvko1MdWr1OG4P58KjdB1JMnWy4EbrO3xIkV8fiQkC0=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index ab51e34dc..915c7cf35 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -267,11 +267,11 @@ in }; } { - name = "uucode-0.1.0-ZZjBPkxTQwCphlTjg-UtY_TzztJy_TZqDL-S2nymmBXY"; + name = "uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3"; path = fetchZigArtifact { name = "uucode"; - url = "https://github.com/jacobsandlund/uucode/archive/ca307fdeb7eca5c2812b288cdd5650e66b3115eb.tar.gz"; - hash = "sha256-AY1JqW7qrWy1+WrlGzL8rHW7mZA6CmYhJVr1sSg19oI="; + url = "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz"; + hash = "sha256-jvko1MdWr1OG4P58KjdB1JMnWy4EbrO3xIkV8fiQkC0="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index f206259cf..14e8be13e 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/ca307fdeb7eca5c2812b288cdd5650e66b3115eb.tar.gz +https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/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 8a39a0ccc..9fefa072f 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -139,9 +139,9 @@ }, { "type": "archive", - "url": "https://github.com/jacobsandlund/uucode/archive/ca307fdeb7eca5c2812b288cdd5650e66b3115eb.tar.gz", - "dest": "vendor/p/uucode-0.1.0-ZZjBPkxTQwCphlTjg-UtY_TzztJy_TZqDL-S2nymmBXY", - "sha256": "018d49a96eeaad6cb5f96ae51b32fcac75bb99903a0a6621255af5b12835f682" + "url": "https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz", + "dest": "vendor/p/uucode-0.1.0-ZZjBPl_mQwDHQowHOzXwgTPhAqcFekfYpAeUfeHZNCl3", + "sha256": "8ef928d4c756af5386e0fe7c2a3741d493275b2e046eb3b7c48915f1f890902d" }, { "type": "archive", diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index d9e4cb4a3..277e3cb49 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -92,6 +92,8 @@ pub const tables = [_]config.Table{ 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/terminal/Terminal.zig b/src/terminal/Terminal.zig index fb3f458f7..8013110b7 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -374,10 +374,20 @@ 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 only applies to emoji + // 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) return; + 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; + } switch (c) { 0xFE0F => wide: { @@ -3330,6 +3340,64 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { } } +test "Terminal: keypad sequence VS15" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "#︎" (number sign with text presentation selector) + try t.print(0x23); // # Number sign (valid base) + try t.print(0xFE0E); // VS15 (text presentation selector) + + // VS15 should combine with the base character into a single grapheme cluster, + // taking 1 cell (narrow character). + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // The base emoji should be in cell 0 with the skin tone as a grapheme + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x23), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: keypad sequence VS16" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "#️" (number sign with emoji presentation selector) + try t.print(0x23); // # Number sign (valid base) + try t.print(0xFE0F); // VS16 (emoji presentation selector) + + // VS16 should combine with the base character into a single grapheme cluster, + // taking 2 cells (wide character). + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // The base emoji should be in cell 0 with the skin tone as a grapheme + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x23), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } +} + test "Terminal: Fitzpatrick skin tone next valid base" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); diff --git a/src/unicode/grapheme.zig b/src/unicode/grapheme.zig index 2311bbeec..47be43bb0 100644 --- a/src/unicode/grapheme.zig +++ b/src/unicode/grapheme.zig @@ -1,6 +1,6 @@ const std = @import("std"); const table = @import("props_table.zig").table; -const GraphemeBoundaryClass = @import("Properties.zig").GraphemeBoundaryClass; +const GraphemeBoundaryClass = @import("props.zig").GraphemeBoundaryClass; /// Determines if there is a grapheme break between two codepoints. This /// must be called sequentially maintaining the state between calls. diff --git a/src/unicode/main.zig b/src/unicode/main.zig index cb2fb567f..427c65614 100644 --- a/src/unicode/main.zig +++ b/src/unicode/main.zig @@ -2,7 +2,7 @@ pub const lut = @import("lut.zig"); const grapheme = @import("grapheme.zig"); pub const table = @import("props_table.zig").table; -pub const Properties = @import("Properties.zig"); +pub const Properties = @import("props.zig").Properties; pub const graphemeBreak = grapheme.graphemeBreak; pub const GraphemeBreakState = grapheme.BreakState; diff --git a/src/unicode/Properties.zig b/src/unicode/props.zig similarity index 50% rename from src/unicode/Properties.zig rename to src/unicode/props.zig index c8c4a581c..7099e79cd 100644 --- a/src/unicode/Properties.zig +++ b/src/unicode/props.zig @@ -3,39 +3,50 @@ //! Adding to this lets you find new properties but also potentially makes //! our lookup tables less efficient. Any changes to this should run the //! benchmarks in src/bench to verify that we haven't regressed. -const Properties = @This(); const std = @import("std"); -/// Codepoint width. We clamp to [0, 2] since Ghostty handles control -/// characters and we max out at 2 for wide characters (i.e. 3-em dash -/// becomes a 2-em dash). -width: u2 = 0, +pub const Properties = packed struct { + /// Codepoint width. We clamp to [0, 2] since Ghostty handles control + /// characters and we max out at 2 for wide characters (i.e. 3-em dash + /// becomes a 2-em dash). + width: u2 = 0, -/// Grapheme boundary class. -grapheme_boundary_class: GraphemeBoundaryClass = .invalid, + /// Grapheme boundary class. + grapheme_boundary_class: GraphemeBoundaryClass = .invalid, -// 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; -} + /// Emoji VS compatibility + emoji_vs_text: bool = false, + emoji_vs_emoji: bool = false, -// Needed for lut.Generator -pub fn format( - self: Properties, - writer: *std.Io.Writer, -) !void { - try writer.print( - \\.{{ - \\ .width= {}, - \\ .grapheme_boundary_class= .{s}, - \\}} - , .{ - self.width, - @tagName(self.grapheme_boundary_class), - }); -} + // 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; + } + + // Needed for lut.Generator + pub fn format( + self: Properties, + writer: *std.Io.Writer, + ) !void { + try writer.print( + \\.{{ + \\ .width= {}, + \\ .grapheme_boundary_class= .{s}, + \\ .emoji_vs_text= {}, + \\ .emoji_vs_emoji= {}, + \\}} + , .{ + self.width, + @tagName(self.grapheme_boundary_class), + self.emoji_vs_text, + self.emoji_vs_emoji, + }); + } +}; /// Possible grapheme boundary classes. This isn't an exhaustive list: /// we omit control, CR, LF, etc. because in Ghostty's usage that are diff --git a/src/unicode/props_table.zig b/src/unicode/props_table.zig index d168fbb9c..dac7e6f5e 100644 --- a/src/unicode/props_table.zig +++ b/src/unicode/props_table.zig @@ -1,4 +1,4 @@ -const Properties = @import("Properties.zig"); +const Properties = @import("props.zig").Properties; const lut = @import("lut.zig"); /// The lookup tables for Ghostty. diff --git a/src/unicode/props_uucode.zig b/src/unicode/props_uucode.zig index 6aed7d7d5..84aafd0be 100644 --- a/src/unicode/props_uucode.zig +++ b/src/unicode/props_uucode.zig @@ -3,8 +3,8 @@ const std = @import("std"); const assert = std.debug.assert; const uucode = @import("uucode"); const lut = @import("lut.zig"); -const Properties = @import("Properties.zig"); -const GraphemeBoundaryClass = Properties.GraphemeBoundaryClass; +const Properties = @import("props.zig").Properties; +const GraphemeBoundaryClass = @import("props.zig").GraphemeBoundaryClass; /// Gets the grapheme boundary class for a codepoint. /// The use case for this is only in generating lookup tables. @@ -48,14 +48,18 @@ fn graphemeBoundaryClass(cp: u21) GraphemeBoundaryClass { } pub fn get(cp: u21) Properties { - const width = if (cp > uucode.config.max_code_point) - 1 - else - uucode.get(.width, cp); + if (cp > uucode.config.max_code_point) return .{ + .width = 1, + .grapheme_boundary_class = .invalid, + .emoji_vs_text = false, + .emoji_vs_emoji = false, + }; return .{ - .width = width, + .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), }; } From c8c36a6035e36cda9ba5431ff0d67c15bea7a92e Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:02:07 +0100 Subject: [PATCH 257/702] macOS: fix funky resolution in quick terminal --- macos/Sources/Features/Terminal/TerminalView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 0cdff7c1f..74e82836d 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -136,6 +136,7 @@ fileprivate struct UpdateOverlay: View { .padding(.trailing, 9) } } + .frame(maxWidth: .greatestFiniteMagnitude, maxHeight: .greatestFiniteMagnitude) } } } From c8e317b60fe1a0ac4869e2730feb5a2b861f02cb Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 5 Nov 2025 19:41:51 -0600 Subject: [PATCH 258/702] core: handle utf-8 bom in config files If a UTF-8 byte order mark starts a config file, it should be ignored. This also refactors config file loading a bit to reduce redundant code and to make it possible to test loading config from a file. Fixes #9490 --- src/config/Config.zig | 71 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 027c5b158..96b4ad82c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3451,15 +3451,78 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { }; defer file.close(); + try self.loadFsFile(alloc, &file, path); +} + +/// Load config from the given File. +fn loadFsFile(self: *Config, alloc: Allocator, file: *std.fs.File, path: []const u8) !void { std.log.info("reading configuration file path={s}", .{path}); var buf: [2048]u8 = undefined; var file_reader = file.reader(&buf); const reader = &file_reader.interface; + try self.loadReader(alloc, reader, path); +} + +/// Load config from the given Reader. +fn loadReader(self: *Config, alloc: Allocator, reader: *std.Io.Reader, path: []const u8) !void { + bom: { + // If the file starts with a UTF-8 byte order mark, skip it. + // https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8 + const bom: []const u8 = &.{ 0xef, 0xbb, 0xbf }; + const str = reader.peek(bom.len) catch break :bom; + if (std.mem.eql(u8, str, bom)) { + log.warn("skipping UTF-8 byte order mark", .{}); + reader.toss(bom.len); + } + } var iter: cli.args.LineIterator = .{ .r = reader, .filepath = path }; try self.loadIter(alloc, &iter); try self.expandPaths(std.fs.path.dirname(path).?); } +test "handle bom in config files" { + const testing = std.testing; + const alloc = testing.allocator; + + { + const data = "\xef\xbb\xbfabnormal-command-exit-runtime = 2500\n"; + var reader: std.Io.Reader = .fixed(data); + var cfg = try Config.default(alloc); + defer cfg.deinit(); + try cfg.loadReader( + alloc, + &reader, + "/home/ghostty/.config/ghostty/config.ghostty", + ); + try cfg.finalize(); + + try testing.expect(cfg._diagnostics.empty()); + try testing.expectEqual( + 2500, + cfg.@"abnormal-command-exit-runtime", + ); + } + + { + const data = "abnormal-command-exit-runtime = 2500\n"; + var reader: std.Io.Reader = .fixed(data); + var cfg = try Config.default(alloc); + defer cfg.deinit(); + try cfg.loadReader( + alloc, + &reader, + "/home/ghostty/.config/ghostty/config.ghostty", + ); + try cfg.finalize(); + + try testing.expect(cfg._diagnostics.empty()); + try testing.expectEqual( + 2500, + cfg.@"abnormal-command-exit-runtime", + ); + } +} + pub const OptionalFileAction = enum { loaded, not_found, @"error" }; /// Load optional configuration file from `path`. All errors are ignored. @@ -3764,13 +3827,7 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { }, } - log.info("loading config-file path={s}", .{path}); - var buf: [2048]u8 = undefined; - var file_reader = file.reader(&buf); - const reader = &file_reader.interface; - var iter: cli.args.LineIterator = .{ .r = reader, .filepath = path }; - try self.loadIter(alloc_gpa, &iter); - try self.expandPaths(std.fs.path.dirname(path).?); + try self.loadFsFile(arena_alloc, &file, path); } // If we have a suffix, add that back. From df86e30877c78b37c39c84c4227b0610c9c26cc2 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 6 Nov 2025 11:00:38 -0600 Subject: [PATCH 259/702] drop utf-8 bom log message to info --- 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 96b4ad82c..7420075af 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3471,7 +3471,7 @@ fn loadReader(self: *Config, alloc: Allocator, reader: *std.Io.Reader, path: []c const bom: []const u8 = &.{ 0xef, 0xbb, 0xbf }; const str = reader.peek(bom.len) catch break :bom; if (std.mem.eql(u8, str, bom)) { - log.warn("skipping UTF-8 byte order mark", .{}); + log.info("skipping UTF-8 byte order mark", .{}); reader.toss(bom.len); } } From a315f8f32e2f806f79795d5b51f9f607c6b9b51e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 6 Nov 2025 08:14:13 -0800 Subject: [PATCH 260/702] nix: add ucs-detect This makes `ucs-detect` available in our Nix environment so that we can run tests on our Unicode support. In the future, I'd like to modify our CI to run this too. This also adds a `./test/ucs-detect.sh` script that runs `ucs-detect` with consistent options that match the upstream test styles. --- flake.nix | 10 ++++++++- nix/devShell.nix | 5 +++++ nix/pkgs/blessed.nix | 37 +++++++++++++++++++++++++++++++++ nix/pkgs/ucs-detect.nix | 41 +++++++++++++++++++++++++++++++++++++ nix/{ => pkgs}/wraptest.nix | 0 test/ucs-detect.sh | 11 ++++++++++ 6 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 nix/pkgs/blessed.nix create mode 100644 nix/pkgs/ucs-detect.nix rename nix/{ => pkgs}/wraptest.nix (100%) create mode 100755 test/ucs-detect.sh diff --git a/flake.nix b/flake.nix index 9ede1c788..3dcfef185 100644 --- a/flake.nix +++ b/flake.nix @@ -50,8 +50,16 @@ in { devShell.${system} = pkgs.callPackage ./nix/devShell.nix { zig = zig.packages.${system}."0.15.2"; - wraptest = pkgs.callPackage ./nix/wraptest.nix {}; + wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {}; zon2nix = zon2nix; + + python3 = pkgs.python3.override { + self = pkgs.python3; + packageOverrides = pyfinal: pyprev: { + blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {}; + ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {}; + }; + }; }; packages.${system} = let diff --git a/nix/devShell.nix b/nix/devShell.nix index 0c97ec0da..4aaf4ef5c 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -136,6 +136,11 @@ in blueprint-compiler libadwaita gtk4 + + # Python packages + (python3.withPackages (python-pkgs: [ + python-pkgs.ucs-detect + ])) ] ++ lib.optionals stdenv.hostPlatform.isLinux [ # My nix shell environment installs the non-interactive version diff --git a/nix/pkgs/blessed.nix b/nix/pkgs/blessed.nix new file mode 100644 index 000000000..8b6728f43 --- /dev/null +++ b/nix/pkgs/blessed.nix @@ -0,0 +1,37 @@ +{ + lib, + buildPythonPackage, + fetchPypi, + pythonOlder, + flit-core, + six, + wcwidth, +}: +buildPythonPackage rec { + pname = "blessed"; + version = "1.23.0"; + pyproject = true; + + disabled = pythonOlder "3.7"; + + src = fetchPypi { + inherit pname version; + hash = "sha256-VlkaMpZvcE9hMfFACvQVHZ6PX0FEEzpcoDQBl2Pe53s="; + }; + + build-system = [flit-core]; + + propagatedBuildInputs = [ + wcwidth + six + ]; + + doCheck = false; + + meta = with lib; { + homepage = "https://github.com/jquast/blessed"; + description = "Thin, practical wrapper around terminal capabilities in Python"; + maintainers = []; + license = licenses.mit; + }; +} diff --git a/nix/pkgs/ucs-detect.nix b/nix/pkgs/ucs-detect.nix new file mode 100644 index 000000000..07ec6c2fc --- /dev/null +++ b/nix/pkgs/ucs-detect.nix @@ -0,0 +1,41 @@ +{ + lib, + buildPythonPackage, + fetchPypi, + pythonOlder, + setuptools, + # Dependencies + blessed, + wcwidth, + pyyaml, +}: +buildPythonPackage rec { + pname = "ucs-detect"; + version = "1.0.8"; + pyproject = true; + + disabled = pythonOlder "3.8"; + + src = fetchPypi { + inherit version; + pname = "ucs_detect"; + hash = "sha256-ihB+tZCd6ykdeXYxc6V1Q6xALQ+xdCW5yqSL7oppqJc="; + }; + + dependencies = [ + blessed + wcwidth + pyyaml + ]; + + nativeBuildInputs = [setuptools]; + + doCheck = false; + + meta = with lib; { + description = "Measures number of Terminal column cells of wide-character codes"; + homepage = "https://github.com/jquast/ucs-detect"; + license = licenses.mit; + maintainers = []; + }; +} diff --git a/nix/wraptest.nix b/nix/pkgs/wraptest.nix similarity index 100% rename from nix/wraptest.nix rename to nix/pkgs/wraptest.nix diff --git a/test/ucs-detect.sh b/test/ucs-detect.sh new file mode 100755 index 000000000..79d5fca0c --- /dev/null +++ b/test/ucs-detect.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +# This runs ucs-detect with the same settings consistently so we can +# compare our results over time. This is based on: +# https://github.com/jquast/ucs-detect/blob/2958b7766783c92b3aad6a55e1e752cbe07ccaf3/data/ghostty.yaml +ucs-detect \ + --limit-codepoints=5000 \ + --limit-words=5000 \ + --limit-errors=1000 \ + --quick=false \ + --stream=stderr From 7f0468f910fba3e73303bccf1e3d92a36ece3acd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 6 Nov 2025 12:56:41 -0800 Subject: [PATCH 261/702] fix ucs-detect script --- test/ucs-detect.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/test/ucs-detect.sh b/test/ucs-detect.sh index 79d5fca0c..5cffd0520 100755 --- a/test/ucs-detect.sh +++ b/test/ucs-detect.sh @@ -7,5 +7,4 @@ ucs-detect \ --limit-codepoints=5000 \ --limit-words=5000 \ --limit-errors=1000 \ - --quick=false \ --stream=stderr From 3f20f153c56312fc30cea702fe0620a95695e89f Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:19:13 +0100 Subject: [PATCH 262/702] macOS: fix undo new tab will cause a crash --- .../Features/Terminal/TerminalController.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 08bdac2ad..844808e97 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -377,9 +377,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr withTarget: controller, expiresAfter: controller.undoExpiration ) { target in - // Close the tab when undoing - undoManager.disableUndoRegistration { - target.closeTab(nil) + // Close the tab when undoing. We do this in a DispatchQueue because + // for some people on macOS Tahoe this caused a crash and the queue + // fixes it. + // https://github.com/ghostty-org/ghostty/pull/9512 + DispatchQueue.main.async { + undoManager.disableUndoRegistration { + target.closeTab(nil) + } } // Register redo action From f94cb01ec8b3be4a00b5ca71ccc041248157b2be Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:07:18 +0100 Subject: [PATCH 263/702] macOS: attach close confirmation alert to the first window that actually needs it --- .../Terminal/BaseTerminalController.swift | 3 +- .../Terminal/TerminalController.swift | 48 ++++++++++++------- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 552f864ee..0afa2c810 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -287,6 +287,7 @@ class BaseTerminalController: NSWindowController, func confirmClose( messageText: String, informativeText: String, + attachedWindow: NSWindow? = nil, completion: @escaping () -> Void ) { // If we already have an alert, we need to wait for that one. @@ -294,7 +295,7 @@ class BaseTerminalController: NSWindowController, // If there is no window to attach the modal then we assume success // since we'll never be able to show the modal. - guard let window else { + guard let window = attachedWindow ?? self.window else { completion() return } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 844808e97..b05d37596 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -817,32 +817,37 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// Close all windows, asking for confirmation if necessary. static func closeAllWindows() { - let needsConfirm: Bool = all.contains { - $0.surfaceTree.contains { $0.needsConfirmQuit } + var confirmWindow: NSWindow? + var needsConfirm = false + for controller in all { + if let surfaceToConfirm = controller.surfaceTree.first(where: { $0.needsConfirmQuit }) { + needsConfirm = true + confirmWindow = surfaceToConfirm.window + break + } } - if (!needsConfirm) { + guard needsConfirm else { closeAllWindowsImmediately() return } - // If we don't have a main window, we just close all windows because - // we have no window to show the modal on top of. I'm sure there's a way - // to do an app-level alert but I don't know how and this case should never - // really happen. - guard let alertWindow = preferredParent?.window else { - closeAllWindowsImmediately() - return - } - - // If we need confirmation by any, show one confirmation for all windows let alert = NSAlert() alert.messageText = "Close All Windows?" alert.informativeText = "All terminal sessions will be terminated." alert.addButton(withTitle: "Close All Windows") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning - alert.beginSheetModal(for: alertWindow, completionHandler: { response in + guard let confirmWindow else { + if (alert.runModal() == .alertFirstButtonReturn) { + // This is important so that we avoid losing focus when Stage + // Manager is used (#8336) + alert.window.orderOut(nil) + closeAllWindowsImmediately() + } + return + } + alert.beginSheetModal(for: confirmWindow, completionHandler: { response in if (response == .alertFirstButtonReturn) { // This is important so that we avoid losing focus when Stage // Manager is used (#8336) @@ -1163,12 +1168,18 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // reason we check ourselves. let windows: [NSWindow] = window.tabGroup?.windows ?? [window] + var confirmWindow: NSWindow? + var needsConfirm = false // Check if any windows require close confirmation. - let needsConfirm = windows.contains { tabWindow in + for tabWindow in windows { guard let controller = tabWindow.windowController as? TerminalController else { - return false + continue + } + if let surfaceToConfirm = controller.surfaceTree.first(where: { $0.needsConfirmQuit }) { + needsConfirm = true + confirmWindow = surfaceToConfirm.window + break } - return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) } // If none need confirmation then we can just close all the windows. @@ -1179,7 +1190,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr confirmClose( messageText: "Close Window?", - informativeText: "All terminal sessions in this window will be terminated." + informativeText: "All terminal sessions in this window will be terminated.", + attachedWindow: confirmWindow, ) { self.closeWindowImmediately() } From 1eecd448e959f97de174aa17ec974b7817dd9bf7 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:21:45 +0100 Subject: [PATCH 264/702] remove needsConfirm --- .../Terminal/TerminalController.swift | 44 +++++-------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index b05d37596..74e31a383 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -817,17 +817,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// Close all windows, asking for confirmation if necessary. static func closeAllWindows() { - var confirmWindow: NSWindow? - var needsConfirm = false - for controller in all { - if let surfaceToConfirm = controller.surfaceTree.first(where: { $0.needsConfirmQuit }) { - needsConfirm = true - confirmWindow = surfaceToConfirm.window - break - } - } + let confirmWindow: NSWindow? = all + .first { $0.surfaceTree.contains(where: { $0.needsConfirmQuit }) }? + .surfaceTree.first { $0.needsConfirmQuit }? + .window - guard needsConfirm else { + guard let confirmWindow else { closeAllWindowsImmediately() return } @@ -838,15 +833,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr alert.addButton(withTitle: "Close All Windows") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning - guard let confirmWindow else { - if (alert.runModal() == .alertFirstButtonReturn) { - // This is important so that we avoid losing focus when Stage - // Manager is used (#8336) - alert.window.orderOut(nil) - closeAllWindowsImmediately() - } - return - } alert.beginSheetModal(for: confirmWindow, completionHandler: { response in if (response == .alertFirstButtonReturn) { // This is important so that we avoid losing focus when Stage @@ -1168,22 +1154,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // reason we check ourselves. let windows: [NSWindow] = window.tabGroup?.windows ?? [window] - var confirmWindow: NSWindow? - var needsConfirm = false - // Check if any windows require close confirmation. - for tabWindow in windows { - guard let controller = tabWindow.windowController as? TerminalController else { - continue + let confirmWindow: NSWindow? = windows + .first { + ($0.windowController as? TerminalController)?.surfaceTree.contains(where: { $0.needsConfirmQuit }) == true } - if let surfaceToConfirm = controller.surfaceTree.first(where: { $0.needsConfirmQuit }) { - needsConfirm = true - confirmWindow = surfaceToConfirm.window - break - } - } + .flatMap { + ($0.windowController as? TerminalController)?.surfaceTree.first(where: { $0.needsConfirmQuit }) + }?.window // If none need confirmation then we can just close all the windows. - if !needsConfirm { + guard let confirmWindow else { closeWindowImmediately() return } From 04563a16b6373a8e21110c91cf6efcdd59591496 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 7 Nov 2025 14:19:44 -0800 Subject: [PATCH 265/702] macos: simplify the code to a more understandable style --- .../Terminal/BaseTerminalController.swift | 3 +- .../Terminal/TerminalController.swift | 32 ++++++++----------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 0afa2c810..552f864ee 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -287,7 +287,6 @@ class BaseTerminalController: NSWindowController, func confirmClose( messageText: String, informativeText: String, - attachedWindow: NSWindow? = nil, completion: @escaping () -> Void ) { // If we already have an alert, we need to wait for that one. @@ -295,7 +294,7 @@ class BaseTerminalController: NSWindowController, // If there is no window to attach the modal then we assume success // since we'll never be able to show the modal. - guard let window = attachedWindow ?? self.window else { + guard let window else { completion() return } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 74e31a383..4de0336ce 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -817,12 +817,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// Close all windows, asking for confirmation if necessary. static func closeAllWindows() { - let confirmWindow: NSWindow? = all - .first { $0.surfaceTree.contains(where: { $0.needsConfirmQuit }) }? - .surfaceTree.first { $0.needsConfirmQuit }? + // The window we use for confirmations. Try to find the first window that + // needs quit confirmation. This lets us attach the confirmation to something + // that is running. + guard let confirmWindow = all + .first(where: { $0.surfaceTree.contains(where: { $0.needsConfirmQuit }) })? + .surfaceTree.first(where: { $0.needsConfirmQuit })? .window - - guard let confirmWindow else { + else { closeAllWindowsImmediately() return } @@ -1153,25 +1155,19 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // if we're closing the window. If we don't have a tabgroup for any // reason we check ourselves. let windows: [NSWindow] = window.tabGroup?.windows ?? [window] - - let confirmWindow: NSWindow? = windows - .first { - ($0.windowController as? TerminalController)?.surfaceTree.contains(where: { $0.needsConfirmQuit }) == true - } - .flatMap { - ($0.windowController as? TerminalController)?.surfaceTree.first(where: { $0.needsConfirmQuit }) - }?.window - - // If none need confirmation then we can just close all the windows. - guard let confirmWindow else { + guard let confirmController = windows + .compactMap({ $0.windowController as? TerminalController }) + .first(where: { $0.surfaceTree.contains(where: { $0.needsConfirmQuit }) }) + else { closeWindowImmediately() return } - confirmClose( + // We call confirmClose on the proper controller so the alert is + // attached to the window that needs confirmation. + confirmController.confirmClose( messageText: "Close Window?", informativeText: "All terminal sessions in this window will be terminated.", - attachedWindow: confirmWindow, ) { self.closeWindowImmediately() } From a162fa8f55589386d3d81c8bce445fdc72ceb69f Mon Sep 17 00:00:00 2001 From: benodiwal Date: Thu, 6 Nov 2025 13:13:32 +0530 Subject: [PATCH 266/702] feat: add clipboard-codepoint-map configuration parsing --- src/config/ClipboardCodepointMap.zig | 143 ++++++++++++++++++ src/config/Config.zig | 212 +++++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 src/config/ClipboardCodepointMap.zig diff --git a/src/config/ClipboardCodepointMap.zig b/src/config/ClipboardCodepointMap.zig new file mode 100644 index 000000000..dd6a172c6 --- /dev/null +++ b/src/config/ClipboardCodepointMap.zig @@ -0,0 +1,143 @@ +/// ClipboardCodepointMap is a map of codepoints to replacement values +/// for clipboard operations. When copying text to clipboard, matching +/// codepoints will be replaced with their mapped values. +const ClipboardCodepointMap = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +pub const Replacement = union(enum) { + /// Replace with a single codepoint + codepoint: u21, + /// Replace with a UTF-8 string + string: []const u8, +}; + +pub const Entry = struct { + /// Unicode codepoint range. Asserts range[0] <= range[1]. + range: [2]u21, + + /// The replacement value for this range. + replacement: Replacement, +}; + +/// The list of entries. We use a multiarraylist for cache-friendly lookups. +/// +/// Note: we do a linear search because we expect to always have very +/// few entries, so the overhead of a binary search is not worth it. +list: std.MultiArrayList(Entry) = .{}, + +pub fn deinit(self: *ClipboardCodepointMap, alloc: Allocator) void { + self.list.deinit(alloc); +} + +/// Deep copy of the struct. The given allocator is expected to +/// be an arena allocator of some sort since the struct itself +/// doesn't support fine-grained deallocation of fields. +pub fn clone(self: *const ClipboardCodepointMap, alloc: Allocator) !ClipboardCodepointMap { + var list = try self.list.clone(alloc); + for (list.items(.replacement)) |*r| { + switch (r.*) { + .string => |s| r.string = try alloc.dupe(u8, s), + .codepoint => {}, // no allocation needed + } + } + + return .{ .list = list }; +} + +/// Add an entry to the map. +/// +/// For conflicting codepoints, entries added later take priority over +/// entries added earlier. +pub fn add(self: *ClipboardCodepointMap, alloc: Allocator, entry: Entry) !void { + assert(entry.range[0] <= entry.range[1]); + try self.list.append(alloc, entry); +} + +/// Get a replacement for a codepoint. +pub fn get(self: *const ClipboardCodepointMap, cp: u21) ?Replacement { + const items = self.list.items(.range); + for (0..items.len) |forward_i| { + const i = items.len - forward_i - 1; + const range = items[i]; + if (range[0] <= cp and cp <= range[1]) { + const replacements = self.list.items(.replacement); + return replacements[i]; + } + } + + return null; +} + +/// Hash with the given hasher. +pub fn hash(self: *const ClipboardCodepointMap, hasher: anytype) void { + const autoHash = std.hash.autoHash; + autoHash(hasher, self.list.len); + const slice = self.list.slice(); + for (0..slice.len) |i| { + const entry = slice.get(i); + autoHash(hasher, entry.range); + switch (entry.replacement) { + .codepoint => |cp| autoHash(hasher, cp), + .string => |s| autoHash(hasher, s), + } + } +} + +/// Returns a hash code that can be used to uniquely identify this +/// action. +pub fn hashcode(self: *const ClipboardCodepointMap) u64 { + var hasher = std.hash.Wyhash.init(0); + self.hash(&hasher); + return hasher.final(); +} + +test "clipboard codepoint map" { + const testing = std.testing; + const alloc = testing.allocator; + + var m: ClipboardCodepointMap = .{}; + defer m.deinit(alloc); + + // Test no matches initially + try testing.expect(m.get(1) == null); + + // Add exact range with codepoint replacement + try m.add(alloc, .{ + .range = .{ 1, 1 }, + .replacement = .{ .codepoint = 65 }, // 'A' + }); + { + const replacement = m.get(1).?; + try testing.expect(replacement == .codepoint); + try testing.expectEqual(@as(u21, 65), replacement.codepoint); + } + + // Later entry takes priority + try m.add(alloc, .{ + .range = .{ 1, 2 }, + .replacement = .{ .string = "B" }, + }); + { + const replacement = m.get(1).?; + try testing.expect(replacement == .string); + try testing.expectEqualStrings("B", replacement.string); + } + + // Non-matching + try testing.expect(m.get(0) == null); + try testing.expect(m.get(3) == null); + + // Test range matching + try m.add(alloc, .{ + .range = .{ 3, 5 }, + .replacement = .{ .string = "range" }, + }); + { + const replacement = m.get(4).?; + try testing.expectEqualStrings("range", replacement.string); + } + try testing.expect(m.get(6) == null); +} \ No newline at end of file diff --git a/src/config/Config.zig b/src/config/Config.zig index 7420075af..6469c333e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -38,6 +38,7 @@ const RepeatableReadableIO = @import("io.zig").RepeatableReadableIO; const RepeatableStringMap = @import("RepeatableStringMap.zig"); pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; +const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); // We do this instead of importing all of terminal/main.zig to // limit the dependency graph. This is important because some things @@ -279,6 +280,30 @@ pub const compatibility = std.StaticStringMap( /// i.e. new windows, tabs, etc. @"font-codepoint-map": RepeatableCodepointMap = .{}, +/// Map specific Unicode codepoints to replacement values when copying text +/// to clipboard. +/// +/// This configuration allows you to replace specific Unicode characters with +/// other characters or strings when copying terminal content to the clipboard. +/// This is useful for converting special terminal symbols to more compatible +/// characters for pasting into other applications. +/// +/// The syntax is similar to `font-codepoint-map`: +/// - Single codepoint: `U+1234=U+ABCD` or `U+1234=replacement_text` +/// - Codepoint range: `U+1234-U+5678=U+ABCD` +/// +/// Examples: +/// - `clipboard-codepoint-map = U+2500=U+002D` (box drawing horizontal → hyphen) +/// - `clipboard-codepoint-map = U+2502=U+007C` (box drawing vertical → pipe) +/// - `clipboard-codepoint-map = U+03A3=SUM` (Greek sigma → "SUM") +/// +/// This configuration can be repeated multiple times to specify multiple +/// mappings. Later entries take priority over earlier ones for overlapping +/// ranges. +/// +/// Note: This only applies to text copying operations, not URL copying. +@"clipboard-codepoint-map": RepeatableClipboardCodepointMap = .{}, + /// Draw fonts with a thicker stroke, if supported. /// This is currently only supported on macOS. @"font-thicken": bool = false, @@ -6868,6 +6893,193 @@ pub const RepeatableCodepointMap = struct { } }; +/// See "clipboard-codepoint-map" for documentation. +pub const RepeatableClipboardCodepointMap = struct { + const Self = @This(); + + map: ClipboardCodepointMap = .{}, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + const input = input_ orelse return error.ValueRequired; + const eql_idx = std.mem.indexOf(u8, input, "=") orelse return error.InvalidValue; + const whitespace = " \t"; + const key = std.mem.trim(u8, input[0..eql_idx], whitespace); + const value = std.mem.trim(u8, input[eql_idx + 1 ..], whitespace); + + // Parse the replacement value - either a codepoint or string + const replacement: ClipboardCodepointMap.Replacement = if (std.mem.startsWith(u8, value, "U+")) blk: { + // Parse as codepoint + const cp_str = value[2..]; // Skip "U+" + const cp = std.fmt.parseInt(u21, cp_str, 16) catch return error.InvalidValue; + break :blk .{ .codepoint = cp }; + } else blk: { + // Parse as UTF-8 string - validate it's valid UTF-8 + if (!std.unicode.utf8ValidateSlice(value)) return error.InvalidValue; + const value_copy = try alloc.dupe(u8, value); + break :blk .{ .string = value_copy }; + }; + + var p: UnicodeRangeParser = .{ .input = key }; + while (try p.next()) |range| { + try self.map.add(alloc, .{ + .range = range, + .replacement = replacement, + }); + } + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + return .{ .map = try self.map.clone(alloc) }; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + const itemsA = self.map.list.slice(); + const itemsB = other.map.list.slice(); + if (itemsA.len != itemsB.len) return false; + for (0..itemsA.len) |i| { + const a = itemsA.get(i); + const b = itemsB.get(i); + if (!std.meta.eql(a.range, b.range)) return false; + switch (a.replacement) { + .codepoint => |cp_a| switch (b.replacement) { + .codepoint => |cp_b| if (cp_a != cp_b) return false, + .string => return false, + }, + .string => |str_a| switch (b.replacement) { + .string => |str_b| if (!std.mem.eql(u8, str_a, str_b)) return false, + .codepoint => return false, + }, + } + } + return true; + } + + /// Used by Formatter + pub fn formatEntry( + self: Self, + formatter: anytype, + ) !void { + if (self.map.list.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var buf: [1024]u8 = undefined; + var value_buf: [32]u8 = undefined; + const ranges = self.map.list.items(.range); + const replacements = self.map.list.items(.replacement); + for (ranges, replacements) |range, replacement| { + const value_str = switch (replacement) { + .codepoint => |cp| try std.fmt.bufPrint(&value_buf, "U+{X:0>4}", .{cp}), + .string => |s| s, + }; + + if (range[0] == range[1]) { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "U+{X:0>4}={s}", + .{ range[0], value_str }, + ) catch return error.OutOfMemory, + ); + } else { + try formatter.formatEntry( + []const u8, + std.fmt.bufPrint( + &buf, + "U+{X:0>4}-U+{X:0>4}={s}", + .{ range[0], range[1], value_str }, + ) catch return error.OutOfMemory, + ); + } + } + } + + /// Reuse the same UnicodeRangeParser from RepeatableCodepointMap + const UnicodeRangeParser = RepeatableCodepointMap.UnicodeRangeParser; + + test "parseCLI codepoint replacement" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+2500=U+002D"); // box drawing → hyphen + + try testing.expectEqual(@as(usize, 1), list.map.list.len); + const entry = list.map.list.get(0); + try testing.expectEqual([2]u21{ 0x2500, 0x2500 }, entry.range); + try testing.expect(entry.replacement == .codepoint); + try testing.expectEqual(@as(u21, 0x002D), entry.replacement.codepoint); + } + + test "parseCLI string replacement" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+03A3=SUM"); // Greek sigma → "SUM" + + try testing.expectEqual(@as(usize, 1), list.map.list.len); + const entry = list.map.list.get(0); + try testing.expectEqual([2]u21{ 0x03A3, 0x03A3 }, entry.range); + try testing.expect(entry.replacement == .string); + try testing.expectEqualStrings("SUM", entry.replacement.string); + } + + test "parseCLI range replacement" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+2500-U+2503=|"); // box drawing range → pipe + + try testing.expectEqual(@as(usize, 1), list.map.list.len); + const entry = list.map.list.get(0); + try testing.expectEqual([2]u21{ 0x2500, 0x2503 }, entry.range); + try testing.expect(entry.replacement == .string); + try testing.expectEqualStrings("|", entry.replacement.string); + } + + test "formatConfig codepoint" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+2500=U+002D"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = U+2500=U+002D\n", buf.written()); + } + + test "formatConfig string" { + const testing = std.testing; + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Self = .{}; + try list.parseCLI(alloc, "U+03A3=SUM"); + try list.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); + try std.testing.expectEqualSlices(u8, "a = U+03A3=SUM\n", buf.written()); + } +}; + pub const FontStyle = union(enum) { const Self = @This(); From 11274cd9e5edeb8df0997ace341735e6dae47dbd Mon Sep 17 00:00:00 2001 From: benodiwal Date: Thu, 6 Nov 2025 13:43:22 +0530 Subject: [PATCH 267/702] feat: integrate clipboard-codepoint-map with clipboard pipeline --- src/Surface.zig | 84 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 346cbb8bf..8f875becf 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -260,6 +260,7 @@ const DerivedConfig = struct { clipboard_trim_trailing_spaces: bool, clipboard_paste_protection: bool, clipboard_paste_bracketed_safe: bool, + clipboard_codepoint_map: configpkg.Config.RepeatableClipboardCodepointMap, copy_on_select: configpkg.CopyOnSelect, right_click_action: configpkg.RightClickAction, confirm_close_surface: configpkg.ConfirmCloseSurface, @@ -334,6 +335,7 @@ const DerivedConfig = struct { .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces", .clipboard_paste_protection = config.@"clipboard-paste-protection", .clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe", + .clipboard_codepoint_map = try config.@"clipboard-codepoint-map".clone(alloc), .copy_on_select = config.@"copy-on-select", .right_click_action = config.@"right-click-action", .confirm_close_surface = config.@"confirm-close-surface", @@ -1954,6 +1956,54 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) }; } +/// Apply clipboard codepoint mappings to transform text content. +/// Returns the transformed text, which may be the same as input if no mappings apply. +fn applyClipboardCodepointMappings( + alloc: Allocator, + input_text: []const u8, + mappings: *const configpkg.Config.RepeatableClipboardCodepointMap, +) ![]const u8 { + // If no mappings configured, return input unchanged + if (mappings.map.list.len == 0) { + return try alloc.dupe(u8, input_text); + } + + // We'll build the output in this list + var output: std.ArrayList(u8) = .empty; + defer output.deinit(alloc); + + // UTF-8 decode and process each codepoint + var iter = std.unicode.Utf8Iterator{ .bytes = input_text, .i = 0 }; + while (iter.nextCodepoint()) |codepoint| { + if (mappings.map.get(codepoint)) |replacement| { + switch (replacement) { + .codepoint => |cp| { + // Encode the replacement codepoint to UTF-8 + var utf8_buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(cp, &utf8_buf) catch { + // If encoding fails, use original codepoint + const orig_len = std.unicode.utf8Encode(codepoint, &utf8_buf) catch continue; + try output.appendSlice(alloc, utf8_buf[0..orig_len]); + continue; + }; + try output.appendSlice(alloc, utf8_buf[0..len]); + }, + .string => |s| { + // Append the replacement string directly + try output.appendSlice(alloc, s); + }, + } + } else { + // No mapping found, keep original codepoint + var utf8_buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(codepoint, &utf8_buf) catch continue; + try output.appendSlice(alloc, utf8_buf[0..len]); + } + } + + return try output.toOwnedSlice(alloc); +} + fn copySelectionToClipboards( self: *Surface, sel: terminal.Selection, @@ -1984,9 +2034,19 @@ fn copySelectionToClipboards( var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); + + // Apply clipboard codepoint mappings + const original_text = try aw.toOwnedSlice(); + const transformed_text = try applyClipboardCodepointMappings( + alloc, + original_text, + &self.config.clipboard_codepoint_map, + ); + const transformed_text_z = try alloc.dupeZ(u8, transformed_text); + try contents.append(alloc, .{ .mime = "text/plain", - .data = try aw.toOwnedSliceSentinel(0), + .data = transformed_text_z, }); }, @@ -1998,6 +2058,9 @@ fn copySelectionToClipboards( }); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); + + // Note: We don't apply codepoint mappings to VT format since it contains + // escape sequences that should be preserved as-is try contents.append(alloc, .{ .mime = "text/plain", .data = try aw.toOwnedSliceSentinel(0), @@ -2012,6 +2075,9 @@ fn copySelectionToClipboards( }); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); + + // Note: We don't apply codepoint mappings to HTML format since HTML + // has its own character encoding and entity system try contents.append(alloc, .{ .mime = "text/html", .data = try aw.toOwnedSliceSentinel(0), @@ -2019,15 +2085,27 @@ fn copySelectionToClipboards( }, .mixed => { + // First, generate plain text with codepoint mappings applied var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); + + // Apply clipboard codepoint mappings to plain text + const original_text = try aw.toOwnedSlice(); + const transformed_text = try applyClipboardCodepointMappings( + alloc, + original_text, + &self.config.clipboard_codepoint_map, + ); + const transformed_text_z = try alloc.dupeZ(u8, transformed_text); + try contents.append(alloc, .{ .mime = "text/plain", - .data = try aw.toOwnedSliceSentinel(0), + .data = transformed_text_z, }); assert(aw.written().len == 0); + // Second, generate HTML without codepoint mappings formatter = .init(&self.io.terminal.screen, opts: { var copy = opts; copy.emit = .html; @@ -2042,6 +2120,8 @@ fn copySelectionToClipboards( }); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); + + // Note: We don't apply codepoint mappings to HTML format try contents.append(alloc, .{ .mime = "text/html", .data = try aw.toOwnedSliceSentinel(0), From 422fa8d3048435c688fa7fc1186bec967d88db51 Mon Sep 17 00:00:00 2001 From: benodiwal Date: Thu, 6 Nov 2025 13:58:03 +0530 Subject: [PATCH 268/702] refactor: remove unused hash methods from ClipboardCodepointMap --- src/config/ClipboardCodepointMap.zig | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/config/ClipboardCodepointMap.zig b/src/config/ClipboardCodepointMap.zig index dd6a172c6..a0e8fed36 100644 --- a/src/config/ClipboardCodepointMap.zig +++ b/src/config/ClipboardCodepointMap.zig @@ -71,28 +71,6 @@ pub fn get(self: *const ClipboardCodepointMap, cp: u21) ?Replacement { return null; } -/// Hash with the given hasher. -pub fn hash(self: *const ClipboardCodepointMap, hasher: anytype) void { - const autoHash = std.hash.autoHash; - autoHash(hasher, self.list.len); - const slice = self.list.slice(); - for (0..slice.len) |i| { - const entry = slice.get(i); - autoHash(hasher, entry.range); - switch (entry.replacement) { - .codepoint => |cp| autoHash(hasher, cp), - .string => |s| autoHash(hasher, s), - } - } -} - -/// Returns a hash code that can be used to uniquely identify this -/// action. -pub fn hashcode(self: *const ClipboardCodepointMap) u64 { - var hasher = std.hash.Wyhash.init(0); - self.hash(&hasher); - return hasher.final(); -} test "clipboard codepoint map" { const testing = std.testing; From 43d81600ded98b241495ea071d55634a272847f3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 7 Nov 2025 14:45:16 -0800 Subject: [PATCH 269/702] terminal: add codepoint mapping to the formatter itself --- src/Surface.zig | 73 +---- src/config/ClipboardCodepointMap.zig | 91 +----- src/terminal/formatter.zig | 449 ++++++++++++++++++++++++++- 3 files changed, 457 insertions(+), 156 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 8f875becf..a44563ad4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1956,54 +1956,6 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) }; } -/// Apply clipboard codepoint mappings to transform text content. -/// Returns the transformed text, which may be the same as input if no mappings apply. -fn applyClipboardCodepointMappings( - alloc: Allocator, - input_text: []const u8, - mappings: *const configpkg.Config.RepeatableClipboardCodepointMap, -) ![]const u8 { - // If no mappings configured, return input unchanged - if (mappings.map.list.len == 0) { - return try alloc.dupe(u8, input_text); - } - - // We'll build the output in this list - var output: std.ArrayList(u8) = .empty; - defer output.deinit(alloc); - - // UTF-8 decode and process each codepoint - var iter = std.unicode.Utf8Iterator{ .bytes = input_text, .i = 0 }; - while (iter.nextCodepoint()) |codepoint| { - if (mappings.map.get(codepoint)) |replacement| { - switch (replacement) { - .codepoint => |cp| { - // Encode the replacement codepoint to UTF-8 - var utf8_buf: [4]u8 = undefined; - const len = std.unicode.utf8Encode(cp, &utf8_buf) catch { - // If encoding fails, use original codepoint - const orig_len = std.unicode.utf8Encode(codepoint, &utf8_buf) catch continue; - try output.appendSlice(alloc, utf8_buf[0..orig_len]); - continue; - }; - try output.appendSlice(alloc, utf8_buf[0..len]); - }, - .string => |s| { - // Append the replacement string directly - try output.appendSlice(alloc, s); - }, - } - } else { - // No mapping found, keep original codepoint - var utf8_buf: [4]u8 = undefined; - const len = std.unicode.utf8Encode(codepoint, &utf8_buf) catch continue; - try output.appendSlice(alloc, utf8_buf[0..len]); - } - } - - return try output.toOwnedSlice(alloc); -} - fn copySelectionToClipboards( self: *Surface, sel: terminal.Selection, @@ -2021,6 +1973,7 @@ fn copySelectionToClipboards( .emit = .plain, // We'll override this below .unwrap = true, .trim = self.config.clipboard_trim_trailing_spaces, + .codepoint_map = self.config.clipboard_codepoint_map.map.list, .background = self.io.terminal.colors.background.get(), .foreground = self.io.terminal.colors.foreground.get(), .palette = &self.io.terminal.colors.palette.current, @@ -2034,19 +1987,9 @@ fn copySelectionToClipboards( var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); - - // Apply clipboard codepoint mappings - const original_text = try aw.toOwnedSlice(); - const transformed_text = try applyClipboardCodepointMappings( - alloc, - original_text, - &self.config.clipboard_codepoint_map, - ); - const transformed_text_z = try alloc.dupeZ(u8, transformed_text); - try contents.append(alloc, .{ .mime = "text/plain", - .data = transformed_text_z, + .data = try aw.toOwnedSliceSentinel(0), }); }, @@ -2089,19 +2032,9 @@ fn copySelectionToClipboards( var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); - - // Apply clipboard codepoint mappings to plain text - const original_text = try aw.toOwnedSlice(); - const transformed_text = try applyClipboardCodepointMappings( - alloc, - original_text, - &self.config.clipboard_codepoint_map, - ); - const transformed_text_z = try alloc.dupeZ(u8, transformed_text); - try contents.append(alloc, .{ .mime = "text/plain", - .data = transformed_text_z, + .data = try aw.toOwnedSliceSentinel(0), }); assert(aw.written().len == 0); diff --git a/src/config/ClipboardCodepointMap.zig b/src/config/ClipboardCodepointMap.zig index a0e8fed36..354db10d9 100644 --- a/src/config/ClipboardCodepointMap.zig +++ b/src/config/ClipboardCodepointMap.zig @@ -7,20 +7,9 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -pub const Replacement = union(enum) { - /// Replace with a single codepoint - codepoint: u21, - /// Replace with a UTF-8 string - string: []const u8, -}; - -pub const Entry = struct { - /// Unicode codepoint range. Asserts range[0] <= range[1]. - range: [2]u21, - - /// The replacement value for this range. - replacement: Replacement, -}; +// To ease our usage later, we map it directly to formatter entries. +pub const Entry = @import("../terminal/formatter.zig").CodepointMap; +pub const Replacement = Entry.Replacement; /// The list of entries. We use a multiarraylist for cache-friendly lookups. /// @@ -37,12 +26,10 @@ pub fn deinit(self: *ClipboardCodepointMap, alloc: Allocator) void { /// doesn't support fine-grained deallocation of fields. pub fn clone(self: *const ClipboardCodepointMap, alloc: Allocator) !ClipboardCodepointMap { var list = try self.list.clone(alloc); - for (list.items(.replacement)) |*r| { - switch (r.*) { - .string => |s| r.string = try alloc.dupe(u8, s), - .codepoint => {}, // no allocation needed - } - } + for (list.items(.replacement)) |*r| switch (r.*) { + .string => |s| r.string = try alloc.dupe(u8, s), + .codepoint => {}, // no allocation needed + }; return .{ .list = list }; } @@ -55,67 +42,3 @@ pub fn add(self: *ClipboardCodepointMap, alloc: Allocator, entry: Entry) !void { assert(entry.range[0] <= entry.range[1]); try self.list.append(alloc, entry); } - -/// Get a replacement for a codepoint. -pub fn get(self: *const ClipboardCodepointMap, cp: u21) ?Replacement { - const items = self.list.items(.range); - for (0..items.len) |forward_i| { - const i = items.len - forward_i - 1; - const range = items[i]; - if (range[0] <= cp and cp <= range[1]) { - const replacements = self.list.items(.replacement); - return replacements[i]; - } - } - - return null; -} - - -test "clipboard codepoint map" { - const testing = std.testing; - const alloc = testing.allocator; - - var m: ClipboardCodepointMap = .{}; - defer m.deinit(alloc); - - // Test no matches initially - try testing.expect(m.get(1) == null); - - // Add exact range with codepoint replacement - try m.add(alloc, .{ - .range = .{ 1, 1 }, - .replacement = .{ .codepoint = 65 }, // 'A' - }); - { - const replacement = m.get(1).?; - try testing.expect(replacement == .codepoint); - try testing.expectEqual(@as(u21, 65), replacement.codepoint); - } - - // Later entry takes priority - try m.add(alloc, .{ - .range = .{ 1, 2 }, - .replacement = .{ .string = "B" }, - }); - { - const replacement = m.get(1).?; - try testing.expect(replacement == .string); - try testing.expectEqualStrings("B", replacement.string); - } - - // Non-matching - try testing.expect(m.get(0) == null); - try testing.expect(m.get(3) == null); - - // Test range matching - try m.add(alloc, .{ - .range = .{ 3, 5 }, - .replacement = .{ .string = "range" }, - }); - { - const replacement = m.get(4).?; - try testing.expectEqualStrings("range", replacement.string); - } - try testing.expect(m.get(6) == null); -} \ No newline at end of file diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index ddb6d5334..46cc971c8 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -59,6 +59,24 @@ pub const Format = enum { } }; +pub const CodepointMap = struct { + /// Unicode codepoint range to replace. + /// Asserts: range[0] <= range[1] + range: [2]u21, + + /// Replacement value for this range. + replacement: Replacement, + + pub const Replacement = union(enum) { + /// A single replacement codepoint. + codepoint: u21, + + /// A UTF-8 encoded string to replace with. Asserts the + /// UTF-8 encoding (must be valid). + string: []const u8, + }; +}; + /// Common encoding options regardless of what exact formatter is used. pub const Options = struct { /// The format to emit. @@ -74,6 +92,10 @@ pub const Options = struct { /// is currently only space characters (0x20). trim: bool = true, + /// Replace matching Unicode codepoints with some other values. + /// This will use the last matching range found in the list. + codepoint_map: ?std.MultiArrayList(CodepointMap) = .{}, + /// Set a background and foreground color to use for the "screen". /// For styled formats, this will emit the proper sequences or styles. background: ?color.RGB = null, @@ -1241,14 +1263,58 @@ pub const PageFormatter = struct { writer: *std.Io.Writer, cell: *const Cell, ) !void { - try self.writeCodepoint(writer, cell.content.codepoint); + try self.writeCodepointWithReplacement(writer, cell.content.codepoint); if (comptime tag == .codepoint_grapheme) { for (self.page.lookupGrapheme(cell).?) |cp| { - try self.writeCodepoint(writer, cp); + try self.writeCodepointWithReplacement(writer, cp); } } } + fn writeCodepointWithReplacement( + self: PageFormatter, + writer: *std.Io.Writer, + codepoint: u21, + ) !void { + // Search for our replacement + const r_: ?CodepointMap.Replacement = replacement: { + const map = self.opts.codepoint_map orelse break :replacement null; + const items = map.items(.range); + for (0..items.len) |forward_i| { + const i = items.len - forward_i - 1; + const range = items[i]; + if (range[0] <= codepoint and codepoint <= range[1]) { + const replacements = map.items(.replacement); + break :replacement replacements[i]; + } + } + + break :replacement null; + }; + + // If no replacement, write it directly. + const r = r_ orelse return try self.writeCodepoint( + writer, + codepoint, + ); + + switch (r) { + .codepoint => |v| try self.writeCodepoint( + writer, + v, + ), + + .string => |s| { + const view = std.unicode.Utf8View.init(s) catch unreachable; + var it = view.iterator(); + while (it.nextCodepoint()) |cp| try self.writeCodepoint( + writer, + cp, + ); + }, + } + } + fn writeCodepoint( self: PageFormatter, writer: *std.Io.Writer, @@ -5302,3 +5368,382 @@ test "Page VT style reset properly closes styles" { // The reset should properly close the bold style try testing.expectEqualStrings("\x1b[0m\x1b[1mbold\x1b[0mnormal", output); } + +test "Page codepoint_map single replacement" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Replace 'o' with 'x' + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'o', 'o' }, + .replacement = .{ .codepoint = 'x' }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hellx wxrld", output); + + // Verify point map - each output byte should map to original cell position + try testing.expectEqual(output.len, point_map.items.len); + // "hello world" -> "hellx wxrld" + // h e l l o w o r l d + // 0 1 2 3 4 5 6 7 8 9 10 + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // h + try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[1]); // e + try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[2]); // l + try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // l + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[4]); // x (was o) + try testing.expectEqual(Coordinate{ .x = 5, .y = 0 }, point_map.items[5]); // space + try testing.expectEqual(Coordinate{ .x = 6, .y = 0 }, point_map.items[6]); // w + try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[7]); // x (was o) + try testing.expectEqual(Coordinate{ .x = 8, .y = 0 }, point_map.items[8]); // r + try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[9]); // l + try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[10]); // d +} + +test "Page codepoint_map conflicting replacement prefers last" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Replace 'o' with 'x', then with 'y' - should prefer last + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'o', 'o' }, + .replacement = .{ .codepoint = 'x' }, + }); + try map.append(alloc, .{ + .range = .{ 'o', 'o' }, + .replacement = .{ .codepoint = 'y' }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("helly", output); +} + +test "Page codepoint_map replace with string" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Replace 'o' with a multi-byte string + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'o', 'o' }, + .replacement = .{ .string = "XYZ" }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hellXYZ", output); + + // Verify point map - string replacements should all map to the original cell + try testing.expectEqual(output.len, point_map.items.len); + // "hello" -> "hellXYZ" + // h e l l o + // 0 1 2 3 4 + try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // h + try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[1]); // e + try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[2]); // l + try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // l + // All bytes of the replacement string "XYZ" should point to position 4 (where 'o' was) + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[4]); // X + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // Y + try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // Z +} + +test "Page codepoint_map range replacement" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("abcdefg"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Replace 'b' through 'e' with 'X' + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'b', 'e' }, + .replacement = .{ .codepoint = 'X' }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("aXXXXfg", output); +} + +test "Page codepoint_map multiple ranges" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z' + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'a', 'm' }, + .replacement = .{ .codepoint = 'A' }, + }); + try map.append(alloc, .{ + .range = .{ 'n', 'z' }, + .replacement = .{ .codepoint = 'Z' }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + // h e l l o w o r l d + // A A A A Z Z Z Z A A + try testing.expectEqualStrings("AAAAZ ZZZAA", output); +} + +test "Page codepoint_map unicode replacement" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello ⚡ world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Replace lightning bolt with fire emoji + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ '⚡', '⚡' }, + .replacement = .{ .string = "🔥" }, + }); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + var point_map: std.ArrayList(Coordinate) = .empty; + defer point_map.deinit(alloc); + formatter.point_map = .{ .alloc = alloc, .map = &point_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello 🔥 world", output); + + // Verify point map + try testing.expectEqual(output.len, point_map.items.len); + // "hello ⚡ world" + // h e l l o ⚡ w o r l d + // 0 1 2 3 4 5 6 8 9 10 11 12 + // Note: ⚡ is a wide character occupying cells 6-7 + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(i), .y = 0 }, + point_map.items[i], + ); + // 🔥 is 4 UTF-8 bytes, all should map to cell 6 (where ⚡ was) + const fire_start = 6; // "hello " is 6 bytes + for (0..4) |i| try testing.expectEqual( + Coordinate{ .x = 6, .y = 0 }, + point_map.items[fire_start + i], + ); + // " world" follows + const world_start = fire_start + 4; + for (0..6) |i| try testing.expectEqual( + Coordinate{ .x = @intCast(8 + i), .y = 0 }, + point_map.items[world_start + i], + ); +} + +test "Page codepoint_map with styled formats" { + 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 = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[31mred text\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Replace 'e' with 'X' in styled text + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + try map.append(alloc, .{ + .range = .{ 'e', 'e' }, + .replacement = .{ .codepoint = 'X' }, + }); + + var opts: Options = .vt; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + // Should preserve styles while replacing text + // "red text" becomes "rXd tXxt" + // VT format uses \x1b[38;5;1m for palette color 1 + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mrXd tXxt\x1b[0m", output); +} + +test "Page codepoint_map empty map" { + 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 = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + // Empty map should not change anything + var map: std.MultiArrayList(CodepointMap) = .{}; + defer map.deinit(alloc); + + var opts: Options = .plain; + opts.codepoint_map = map; + var formatter: PageFormatter = .init(page, opts); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + try testing.expectEqualStrings("hello world", output); +} From cf126baeb5472f83c7eaf676d3259cc6c3b0f458 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Fri, 7 Nov 2025 20:29:16 -0800 Subject: [PATCH 270/702] Check that file reader has capacity before priming --- src/cli/args.zig | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/cli/args.zig b/src/cli/args.zig index a34560b78..76026fbf2 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -1427,7 +1427,12 @@ pub const LineIterator = struct { // // This will also optimize reads down the line as we're // more likely to beworking with buffered data. - self.r.fillMore() catch {}; + // + // fillMore asserts that the buffer has available capacity, + // so skip this if it's full. + if (self.r.bufferedLen() < self.r.buffer.len) { + self.r.fillMore() catch {}; + } var writer: std.Io.Writer = .fixed(self.entry[2..]); @@ -1590,3 +1595,33 @@ test "LineIterator with CRLF line endings" { try testing.expectEqual(@as(?[]const u8, null), iter.next()); try testing.expectEqual(@as(?[]const u8, null), iter.next()); } + +test "LineIterator with buffered reader" { + const testing = std.testing; + var f: std.Io.Reader = .fixed("A\nB = C\n"); + var buf: [2]u8 = undefined; + var r = f.limited(.unlimited, &buf); + const reader = &r.interface; + + var iter: LineIterator = .init(reader); + try testing.expectEqualStrings("--A", iter.next().?); + try testing.expectEqualStrings("--B=C", iter.next().?); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); +} + +test "LineIterator with buffered and primed reader" { + const testing = std.testing; + var f: std.Io.Reader = .fixed("A\nB = C\n"); + var buf: [2]u8 = undefined; + var r = f.limited(.unlimited, &buf); + const reader = &r.interface; + + try reader.fill(buf.len); + + var iter: LineIterator = .init(reader); + try testing.expectEqualStrings("--A", iter.next().?); + try testing.expectEqualStrings("--B=C", iter.next().?); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); +} From 3142c5aa606056a0af0d73a2579c910c84476450 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 8 Nov 2025 08:29:59 -0800 Subject: [PATCH 271/702] macOS: Don't clip surfaceView to contentView Fixes #9248 --- macos/Sources/Ghostty/SurfaceScrollView.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index e455a32c8..70e22b648 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -36,6 +36,9 @@ class SurfaceScrollView: NSView { scrollView.usesPredominantAxisScrolling = true // hide default background to show blur effect properly scrollView.drawsBackground = false + // don't let the content view clip it's subviews, to enable the + // surface to draw the background behind non-overlay scrollers + scrollView.contentView.clipsToBounds = false // The document view is what the scrollview is actually going // to be directly scrolling. We set it up to a "blank" NSView @@ -142,6 +145,11 @@ class SurfaceScrollView: NSView { // Fill entire bounds with scroll view scrollView.frame = bounds + surfaceView.frame.size = scrollView.bounds.size + + // We only set the width of the documentView here, as the height depends + // on the scrollbar state and is updated in synchronizeScrollView + documentView.frame.size.width = scrollView.bounds.width // When our scrollview changes make sure our scroller and surface views are synchronized synchronizeScrollView() @@ -175,20 +183,13 @@ class SurfaceScrollView: NSView { /// so the renderer only needs to render what's currently on screen. private func synchronizeSurfaceView() { let visibleRect = scrollView.contentView.documentVisibleRect - surfaceView.frame = visibleRect + surfaceView.frame.origin = visibleRect.origin } /// Sizes the document view and scrolls the content view according to the scrollbar state private func synchronizeScrollView() { - // We adjust the document height first, as the content width may depend on it. + // Update the document height to give our scroller the correct proportions documentView.frame.size.height = documentHeight() - - // Our width should be the content width to account for visible scrollers. - // We don't do horizontal scrolling in terminals. The surfaceView width is - // yoked to the document width (this is distinct from the content width - // passed to surfaceView.sizeDidChange, which is only updated on layout). - documentView.frame.size.width = scrollView.contentSize.width - surfaceView.frame.size.width = scrollView.contentSize.width // Only update our actual scroll position if we're not actively scrolling. if !isLiveScrolling { From 9339ccf769b122d1f171462f73d423de1b05df09 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 26 Oct 2025 16:09:14 -0700 Subject: [PATCH 272/702] Decouple balanced top and left window paddings --- src/renderer/size.zig | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/renderer/size.zig b/src/renderer/size.zig index b26c1581e..d8b529c26 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -44,6 +44,15 @@ pub const Size = struct { self.grid(), self.cell, ); + + // The top/bottom padding is interesting. Subjectively, lots of padding + // at the top looks bad. So instead of always being equal (like left/right), + // we force the top padding to be at most equal to the maximum left padding, + // which is the balanced explicit horizontal padding plus half a cell width. + const max_padding_left = (explicit.left + explicit.right + self.cell.width) / 2; + const vshift = self.padding.top -| max_padding_left; + self.padding.top -= vshift; + self.padding.bottom += vshift; } }; @@ -258,16 +267,12 @@ pub const Padding = struct { const space_right = @as(f32, @floatFromInt(screen.width)) - grid_width; const space_bot = @as(f32, @floatFromInt(screen.height)) - grid_height; - // The left/right padding is just an equal split. + // The padding is split equally along both axes. const padding_right = @floor(space_right / 2); const padding_left = padding_right; - // The top/bottom padding is interesting. Subjectively, lots of padding - // at the top looks bad. So instead of always being equal (like left/right), - // we force the top padding to be at most equal to the left, and the bottom - // padding is the difference thereafter. - const padding_top = @min(padding_left, @floor(space_bot / 2)); - const padding_bot = space_bot - padding_top; + const padding_bot = @floor(space_bot / 2); + const padding_top = padding_bot; const zero = @as(f32, 0); return .{ From 49abd901e4a93149eb8fa64cbda1318c15c3ec8c Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 9 Nov 2025 00:15:23 +0000 Subject: [PATCH 273/702] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 199dbce57..7e795338a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/ghostty-themes.tgz", - .hash = "N-V-__8AAPk1AwCUvXvHG6RrcHLBcnO9OM9_eld_kjLSz6wm", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251103-150536-ae86c8c/ghostty-themes.tgz", + .hash = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 42054afb7..107b93906 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAPk1AwCUvXvHG6RrcHLBcnO9OM9_eld_kjLSz6wm": { + "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/ghostty-themes.tgz", - "hash": "sha256-KyZrjqOjmrgIZSI9LXTEX5xS7OohaD0Fy1yGZ8uH0YQ=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251103-150536-ae86c8c/ghostty-themes.tgz", + "hash": "sha256-wloAMNeEm+8S3oVDzwmJ+F0tvn0lyZt8o7nCYagy9Sk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 915c7cf35..6b22dfe2f 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AAPk1AwCUvXvHG6RrcHLBcnO9OM9_eld_kjLSz6wm"; + name = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/ghostty-themes.tgz"; - hash = "sha256-KyZrjqOjmrgIZSI9LXTEX5xS7OohaD0Fy1yGZ8uH0YQ="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251103-150536-ae86c8c/ghostty-themes.tgz"; + hash = "sha256-wloAMNeEm+8S3oVDzwmJ+F0tvn0lyZt8o7nCYagy9Sk="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 14e8be13e..c92dd1471 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/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz -https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251027-150540-8f50c1d/ghostty-themes.tgz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251103-150536-ae86c8c/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 9fefa072f..c10e03fa2 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-20251027-150540-8f50c1d/ghostty-themes.tgz", - "dest": "vendor/p/N-V-__8AAPk1AwCUvXvHG6RrcHLBcnO9OM9_eld_kjLSz6wm", - "sha256": "2b266b8ea3a39ab80865223d2d74c45f9c52ecea21683d05cb5c8667cb87d184" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251103-150536-ae86c8c/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", + "sha256": "c25a0030d7849bef12de8543cf0989f85d2dbe7d25c99b7ca3b9c261a832f529" }, { "type": "archive", From 5845a7bd29b981585ff60ec3c90a534c90b35ef9 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 8 Nov 2025 22:20:02 -0800 Subject: [PATCH 274/702] macOS: Update core surface size when scroller style changes --- macos/Sources/Ghostty/SurfaceScrollView.swift | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 70e22b648..237139e7b 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -104,6 +104,14 @@ class SurfaceScrollView: NSView { self?.handleLiveScroll() }) + observers.append(NotificationCenter.default.addObserver( + forName: NSScroller.preferredScrollerStyleDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleScrollerStyleChange() + }) + // Listen for frame change events. See the docstring for // handleFrameChange for why this is necessary. observers.append(NotificationCenter.default.addObserver( @@ -154,19 +162,7 @@ class SurfaceScrollView: NSView { // When our scrollview changes make sure our scroller and surface views are synchronized synchronizeScrollView() synchronizeSurfaceView() - - // Inform the actual pty of our size change. This doesn't change the actual view - // frame because we do want to render the whole thing, but it will prevent our - // rows/cols from going into the non-content area. - // - // Only update the pty if we have a valid (non-zero) content size. The content size - // can be zero when this is added early to a view, or to an invisible hierarchy. - // Practically, this happened in the quick terminal. - let width = surfaceContentWidth() - let height = surfaceView.frame.height - if width > 0 && height > 0 { - surfaceView.sizeDidChange(CGSize(width: width, height: height)) - } + synchronizeCoreSurface() } // MARK: Scrolling @@ -186,6 +182,20 @@ class SurfaceScrollView: NSView { surfaceView.frame.origin = visibleRect.origin } + /// Inform the actual pty of our size change. This doesn't change the actual view + /// frame because we do want to render the whole thing, but it will prevent our + /// rows/cols from going into the non-content area. + private func synchronizeCoreSurface() { + // Only update the pty if we have a valid (non-zero) content size. The content size + // can be zero when this is added early to a view, or to an invisible hierarchy. + // Practically, this happened in the quick terminal. + let width = surfaceContentWidth() + let height = surfaceView.frame.height + if width > 0 && height > 0 { + surfaceView.sizeDidChange(CGSize(width: width, height: height)) + } + } + /// Sizes the document view and scrolls the content view according to the scrollbar state private func synchronizeScrollView() { // Update the document height to give our scroller the correct proportions @@ -217,6 +227,11 @@ class SurfaceScrollView: NSView { private func handleScrollChange(_ notification: Notification) { synchronizeSurfaceView() } + + /// Handles scrollbar style changes + private func handleScrollerStyleChange() { + synchronizeCoreSurface() + } /// Handles live scroll events (user actively dragging the scrollbar). /// From e29862082885de7956319afd9cae926a08c7799f Mon Sep 17 00:00:00 2001 From: Sean Kelly Date: Sat, 8 Nov 2025 23:16:16 -0800 Subject: [PATCH 275/702] macOS: equalize splits when double tapping on SplitView divider --- macos/Sources/Features/Splits/SplitView.swift | 10 +++++++++- .../Features/Splits/TerminalSplitTreeView.swift | 4 ++++ macos/Sources/Ghostty/InspectorView.swift | 3 +++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Splits/SplitView.swift b/macos/Sources/Features/Splits/SplitView.swift index 3dc3c36a3..42de97590 100644 --- a/macos/Sources/Features/Splits/SplitView.swift +++ b/macos/Sources/Features/Splits/SplitView.swift @@ -21,6 +21,9 @@ struct SplitView: View { let left: L let right: R + /// Called when the divider is double-tapped to equalize splits. + let onEqualize: () -> Void + /// The minimum size (in points) of a split let minSize: CGFloat = 10 @@ -56,6 +59,9 @@ struct SplitView: View { split: $split) .position(splitterPoint) .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) + .onTapGesture(count: 2) { + onEqualize() + } } .accessibilityElement(children: .contain) .accessibilityLabel(splitViewLabel) @@ -69,7 +75,8 @@ struct SplitView: View { dividerColor: Color, resizeIncrements: NSSize = .init(width: 1, height: 1), @ViewBuilder left: (() -> L), - @ViewBuilder right: (() -> R) + @ViewBuilder right: (() -> R), + onEqualize: @escaping () -> Void ) { self.direction = direction self._split = split @@ -77,6 +84,7 @@ struct SplitView: View { self.resizeIncrements = resizeIncrements self.left = left() self.right = right() + self.onEqualize = onEqualize } private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture { diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 6b8171ff5..103413c70 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -55,6 +55,10 @@ struct TerminalSplitSubtreeView: View { }, right: { TerminalSplitSubtreeView(node: split.right, onResize: onResize) + }, + onEqualize: { + guard let surface = node.leftmostLeaf().surface else { return } + ghostty.splitEqualize(surface: surface) } ) } diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/InspectorView.swift index 8008e49c2..2a004ac76 100644 --- a/macos/Sources/Ghostty/InspectorView.swift +++ b/macos/Sources/Ghostty/InspectorView.swift @@ -32,6 +32,9 @@ extension Ghostty { InspectorViewRepresentable(surfaceView: surfaceView) .focused($inspectorFocus) .focusedValue(\.ghosttySurfaceView, surfaceView) + }, onEqualize: { + guard let surface = surfaceView.surface else { return } + ghostty.splitEqualize(surface: surface) }) } } From e3ff49e653fd2efb76f420dd53561b8b3fe1ca4b Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 8 Nov 2025 23:12:34 -0800 Subject: [PATCH 276/702] macOS: Update core surface size when config changes --- macos/Sources/Ghostty/SurfaceScrollView.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 237139e7b..41a3df530 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -129,7 +129,7 @@ class SurfaceScrollView: NSView { surfaceView.$derivedConfig .sink { [weak self] _ in DispatchQueue.main.async { [weak self] in - self?.synchronizeAppearance() + self?.handleConfigChange() } } .store(in: &cancellables) @@ -232,6 +232,12 @@ class SurfaceScrollView: NSView { private func handleScrollerStyleChange() { synchronizeCoreSurface() } + + /// Handles config changes + private func handleConfigChange() { + synchronizeAppearance() + synchronizeCoreSurface() + } /// Handles live scroll events (user actively dragging the scrollbar). /// From 52d3329f843c0a25195e341e3a8dca2c950e1c8d Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 9 Nov 2025 13:19:03 +0100 Subject: [PATCH 277/702] macOS: use unobtrusive when quick terminal is visible --- macos/Sources/Features/Update/UpdateDriver.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 4bddda809..27be6b0df 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -200,7 +200,7 @@ class UpdateDriver: NSObject, SPUUserDriver { /// True if there is a target that can render our unobtrusive update checker. var hasUnobtrusiveTarget: Bool { NSApp.windows.contains { window in - window is TerminalWindow && + (window is TerminalWindow || window is QuickTerminalWindow) && window.isVisible } } From c965afe06656ce7ff014ad4009cbb586c044ef14 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 9 Nov 2025 12:11:03 -0600 Subject: [PATCH 278/702] fix typo in shaper --- src/font/shaper/Cache.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/shaper/Cache.zig b/src/font/shaper/Cache.zig index 672845bfd..bcc0a1d93 100644 --- a/src/font/shaper/Cache.zig +++ b/src/font/shaper/Cache.zig @@ -41,7 +41,7 @@ const CellCacheTable = CacheTable( // I'd expect then an average of 256 frequently cached runs is a // safe guess most terminal screens. 256, - // 8 items per bucket to give decent resilliency to important runs. + // 8 items per bucket to give decent resiliency to important runs. 8, ); From 7e3aba7c9987e04f45f3986d33bbb5f648b66664 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 9 Nov 2025 13:32:23 +0100 Subject: [PATCH 279/702] macOS: remove `readyToInstall` state in update capsule There is a sparkle-related 'issue' with the previous implementation. When you download/install in the `updateAvailable` state, if you don't install it, then check the updates again. Sparkle loses its downloaded stage in the delegate (it's normal when I use the sparkle source code). This time, when you click install in the `updateAvailable` state, it just uses the previous downloaded package and starts to install, without calling `showReady(toInstallAndRelaunch:)`. I think removing `readyToInstall` in our customed ui, will reduce one step to install an update for most of the users out there, which makes sense, since the current package is pretty small, only takes a few seconds to download for a normal network, and they intended to install this update. --- macos/Sources/App/macOS/AppDelegate.swift | 8 ++++ .../Features/Update/UpdateController.swift | 3 +- .../Features/Update/UpdateDelegate.swift | 12 ++++- .../Features/Update/UpdateDriver.swift | 4 +- .../Features/Update/UpdatePopoverView.swift | 46 ++----------------- .../Features/Update/UpdateSimulator.swift | 13 +----- .../Features/Update/UpdateViewModel.swift | 38 ++++----------- macos/Tests/Update/UpdateStateTests.swift | 12 ++--- macos/Tests/Update/UpdateViewModelTests.swift | 10 ++-- 9 files changed, 44 insertions(+), 102 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 5da2f1d5b..57e0212bb 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -815,6 +815,14 @@ class AppDelegate: NSObject, autoUpdate == .check || autoUpdate == .download updateController.updater.automaticallyDownloadsUpdates = autoUpdate == .download + /** + To test `auto-update` easily, uncomment the line below and + delete `SUEnableAutomaticChecks` in Ghostty-Info.plist. + + Note: When `auto-update = download`, you may need to + `Clean Build Folder` if a background install has already begun. + */ + //updateController.updater.checkForUpdatesInBackground() } // Config could change keybindings, so update everything that depends on that diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift index 8a2a939bd..939eed420 100644 --- a/macos/Sources/Features/Update/UpdateController.swift +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -10,7 +10,6 @@ import Combine class UpdateController { private(set) var updater: SPUUpdater private let userDriver: UpdateDriver - private let updaterDelegate = UpdaterDelegate() private var installCancellable: AnyCancellable? var viewModel: UpdateViewModel { @@ -32,7 +31,7 @@ class UpdateController { hostBundle: hostBundle, applicationBundle: hostBundle, userDriver: userDriver, - delegate: updaterDelegate + delegate: userDriver ) } diff --git a/macos/Sources/Features/Update/UpdateDelegate.swift b/macos/Sources/Features/Update/UpdateDelegate.swift index 1112c1f44..26242b49e 100644 --- a/macos/Sources/Features/Update/UpdateDelegate.swift +++ b/macos/Sources/Features/Update/UpdateDelegate.swift @@ -1,7 +1,7 @@ import Sparkle import Cocoa -class UpdaterDelegate: NSObject, SPUUpdaterDelegate { +extension UpdateDriver: SPUUpdaterDelegate { func feedURLString(for updater: SPUUpdater) -> String? { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil @@ -16,6 +16,16 @@ class UpdaterDelegate: NSObject, SPUUpdaterDelegate { } } + /// Called when an update is scheduled to install silently, + /// which occurs when `auto-update = download`. + /// + /// When `auto-update = check`, Sparkle will call the corresponding + /// delegate method on the responsible driver instead. + func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool { + viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: immediateInstallHandler)) + return true + } + func updaterWillRelaunchApplication(_ updater: SPUUpdater) { // When the updater is relaunching the application we want to get macOS // to invalidate and re-encode all of our restorable state so that when diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 27be6b0df..94b43a3f3 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -164,10 +164,10 @@ class UpdateDriver: NSObject, SPUUserDriver { } func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { - viewModel.state = .readyToInstall(.init(reply: reply)) - if !hasUnobtrusiveTarget { standard.showReady(toInstallAndRelaunch: reply) + } else { + reply(.install) } } diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 770b9aedd..08d25a4d1 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -35,10 +35,10 @@ struct UpdatePopoverView: View { case .extracting(let extracting): ExtractingView(extracting: extracting) - case .readyToInstall(let ready): - ReadyToInstallView(ready: ready, dismiss: dismiss) - case .installing(let installing): + // This is only required when `installing.isAutoUpdate == true`, + // but we keep it anyway, just in case something unexpected + // happens during installing InstallingView(installing: installing, dismiss: dismiss) case .notFound(let notFound): @@ -181,7 +181,7 @@ fileprivate struct UpdateAvailableView: View { Spacer() - Button("Install") { + Button("Install and Relaunch") { update.reply(.install) dismiss() } @@ -274,44 +274,6 @@ fileprivate struct ExtractingView: View { } } -fileprivate struct ReadyToInstallView: View { - let ready: UpdateState.ReadyToInstall - let dismiss: DismissAction - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 8) { - Text("Ready to Install") - .font(.system(size: 13, weight: .semibold)) - - Text("The update is ready to install.") - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - - HStack(spacing: 8) { - Button("Later") { - ready.reply(.dismiss) - dismiss() - } - .keyboardShortcut(.cancelAction) - .controlSize(.small) - - Spacer() - - Button("Install and Relaunch") { - ready.reply(.install) - dismiss() - } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - .padding(16) - } -} - fileprivate struct InstallingView: View { let installing: UpdateState.Installing let dismiss: DismissAction diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift index c855282c0..cb4383a00 100644 --- a/macos/Sources/Features/Update/UpdateSimulator.swift +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -262,18 +262,7 @@ enum UpdateSimulator { if j == 5 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - viewModel.state = .readyToInstall(.init( - reply: { choice in - if choice == .install { - viewModel.state = .installing(.init(retryTerminatingApplication: { - print("Restart button clicked in simulator - resetting to idle") - viewModel.state = .idle - })) - } else { - viewModel.state = .idle - } - } - )) + simulateInstalling(viewModel) } } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 7a92337cc..96cbe7c3d 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -30,10 +30,8 @@ class UpdateViewModel: ObservableObject { return "Downloading…" case .extracting(let extracting): return String(format: "Preparing: %.0f%%", extracting.progress * 100) - case .readyToInstall: - return "Ready to Install Update" - case .installing: - return "Restart to Complete Update" + case .installing(let install): + return install.isAutoUpdate ? "Restart to Complete Update" : "Installing…" case .notFound: return "No Updates Available" case .error(let err): @@ -69,8 +67,6 @@ class UpdateViewModel: ObservableObject { return "arrow.down.circle" case .extracting: return "shippingbox" - case .readyToInstall: - return "restart.circle.fill" case .installing: return "power.circle" case .notFound: @@ -96,10 +92,8 @@ class UpdateViewModel: ObservableObject { return "Downloading the update package" case .extracting: return "Extracting and preparing the update" - case .readyToInstall: - return "Update is ready to install" - case .installing: - return "Installing update and preparing to restart" + case let .installing(install): + return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart" case .notFound: return "You are running the latest version" case .error: @@ -136,7 +130,7 @@ class UpdateViewModel: ObservableObject { return .white case .checking: return .secondary - case .updateAvailable, .readyToInstall: + case .updateAvailable: return .accentColor case .downloading, .extracting, .installing: return .secondary @@ -154,8 +148,6 @@ class UpdateViewModel: ObservableObject { return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue) case .updateAvailable: return .accentColor - case .readyToInstall: - return Color(nsColor: NSColor.systemGreen.blended(withFraction: 0.3, of: .black) ?? .systemGreen) case .notFound: return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue) case .error: @@ -170,7 +162,7 @@ class UpdateViewModel: ObservableObject { switch state { case .permissionRequest: return .white - case .updateAvailable, .readyToInstall: + case .updateAvailable: return .white case .notFound: return .white @@ -191,7 +183,6 @@ enum UpdateState: Equatable { case error(Error) case downloading(Downloading) case extracting(Extracting) - case readyToInstall(ReadyToInstall) case installing(Installing) var isIdle: Bool { @@ -206,7 +197,6 @@ enum UpdateState: Equatable { .updateAvailable, .downloading, .extracting, - .readyToInstall, .installing: return true @@ -223,8 +213,6 @@ enum UpdateState: Equatable { available.reply(.dismiss) case .downloading(let downloading): downloading.cancel() - case .readyToInstall(let ready): - ready.reply(.dismiss) case .notFound(let notFound): notFound.acknowledgement() case .error(let err): @@ -241,8 +229,6 @@ enum UpdateState: Equatable { switch self { case .updateAvailable(let available): available.reply(.install) - case .readyToInstall(let ready): - ready.reply(.install) default: break } @@ -266,10 +252,8 @@ enum UpdateState: Equatable { return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength case (.extracting(let lExt), .extracting(let rExt)): return lExt.progress == rExt.progress - case (.readyToInstall, .readyToInstall): - return true - case (.installing, .installing): - return true + case (.installing(let lInstall), .installing(let rInstall)): + return lInstall.isAutoUpdate == rInstall.isAutoUpdate default: return false } @@ -379,11 +363,9 @@ enum UpdateState: Equatable { let progress: Double } - struct ReadyToInstall { - let reply: @Sendable (SPUUserUpdateChoice) -> Void - } - struct Installing { + /// True if this state is triggered by ``Ghostty/UpdateDriver/updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)`` + var isAutoUpdate = false let retryTerminatingApplication: () -> Void } } diff --git a/macos/Tests/Update/UpdateStateTests.swift b/macos/Tests/Update/UpdateStateTests.swift index 269cd3153..1d1d7b37d 100644 --- a/macos/Tests/Update/UpdateStateTests.swift +++ b/macos/Tests/Update/UpdateStateTests.swift @@ -25,9 +25,11 @@ struct UpdateStateTests { } @Test func testInstallingEquality() { - let state1: UpdateState = .installing(.init(retryTerminatingApplication: {})) - let state2: UpdateState = .installing(.init(retryTerminatingApplication: {})) + let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {})) + let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {})) #expect(state1 == state2) + let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {})) + #expect(state3 != state2) } @Test func testPermissionRequestEquality() { @@ -38,12 +40,6 @@ struct UpdateStateTests { #expect(state1 == state2) } - @Test func testReadyToInstallEquality() { - let state1: UpdateState = .readyToInstall(.init(reply: { _ in })) - let state2: UpdateState = .readyToInstall(.init(reply: { _ in })) - #expect(state1 == state2) - } - @Test func testDownloadingEqualityWithSameProgress() { let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift index e41804e08..5b223c59d 100644 --- a/macos/Tests/Update/UpdateViewModelTests.swift +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -50,15 +50,11 @@ struct UpdateViewModelTests { #expect(viewModel.text == "Preparing: 75%") } - @Test func testReadyToInstallText() { - let viewModel = UpdateViewModel() - viewModel.state = .readyToInstall(.init(reply: { _ in })) - #expect(viewModel.text == "Ready to Install Update") - } - @Test func testInstallingText() { let viewModel = UpdateViewModel() - viewModel.state = .installing(.init(retryTerminatingApplication: {})) + viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {})) + #expect(viewModel.text == "Installing…") + viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {})) #expect(viewModel.text == "Restart to Complete Update") } From 687e62b907d05774fedad7164a6c87e156a332bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 00:10:08 +0000 Subject: [PATCH 280/702] build(deps): bump softprops/action-gh-release from 2.4.1 to 2.4.2 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.4.1 to 2.4.2. - [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/6da8fa9354ddfdc4aeace5fc48d7f679b5214090...5be0e66d93ac7ed76da52eca8bb058f665c3a5fe) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 2.4.2 dependency-type: direct:production update-type: version-update:semver-patch ... 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 5ea5ef067..7a16da829 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@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -356,7 +356,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -583,7 +583,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -767,7 +767,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true From ded31cd93107e6dc9d973311d6337a95c35b944a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 00:10:17 +0000 Subject: [PATCH 281/702] build(deps): bump cachix/install-nix-action from 31.8.2 to 31.8.3 Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.8.2 to 31.8.3. - [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/456688f15bc354bef6d396e4a35f4f89d40bf2b7...7ec16f2c061ab07b235a7245e06ed46fe9a1cab6) --- updated-dependencies: - dependency-name: cachix/install-nix-action dependency-version: 31.8.3 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 69799e2c2..56e50889b 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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 d0387b45d..9edc8b48d 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 with: nix_path: nixpkgs=channel:nixos-unstable diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 5ea5ef067..866b94f95 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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 2ff5c57a6..8a067cea3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + - uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 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 2bc79241a..a0dfdf298 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@456688f15bc354bef6d396e4a35f4f89d40bf2b7 # v31.8.2 + uses: cachix/install-nix-action@7ec16f2c061ab07b235a7245e06ed46fe9a1cab6 # v31.8.3 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 From 7ca858d404586da1ffb7d198453912ca49415f5c Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:11:34 +0100 Subject: [PATCH 282/702] macOS: move focus if command palette is not showing --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index ad2c72c26..3375e47ce 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -903,6 +903,7 @@ extension Ghostty { // Handle focus-follows-mouse if let window, let controller = window.windowController as? BaseTerminalController, + !controller.commandPaletteIsShowing, (window.isKeyWindow && !self.focused && controller.focusFollowsMouse) From 2ee1f3191e8f6e5c45d9f19fa09bb04c639fd4e1 Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Mon, 10 Nov 2025 16:53:27 +0100 Subject: [PATCH 283/702] 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 f5bddb346c2901f87dcf8447d8cdcd01384adaf5 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:54:46 +0100 Subject: [PATCH 284/702] macOS: support `close_all_windows` action --- macos/Sources/Ghostty/Ghostty.App.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 466e7859d..9c19199e8 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -604,7 +604,8 @@ extension Ghostty { scrollbar(app, target: target, v: action.action.scrollbar) case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: - fallthrough + closeAllWindows(app, target: target) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -878,6 +879,11 @@ extension Ghostty { } } + private static func closeAllWindows(_ app: ghostty_app_t, target: ghostty_target_s) { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + appDelegate.closeAllWindows(nil) + } + private static func toggleFullscreen( _ app: ghostty_app_t, target: ghostty_target_s, From 791d8f8200e13a45f4c6efe35713f831a00c1e00 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 11 Nov 2025 07:05:20 -0800 Subject: [PATCH 285/702] macos: add a "restart later" option to the installing state --- .../Features/Update/UpdateDelegate.swift | 8 ++++- .../Features/Update/UpdateDriver.swift | 7 ++++- .../Features/Update/UpdatePopoverView.swift | 8 +++++ .../Features/Update/UpdateSimulator.swift | 31 ++++++++++++++++--- .../Features/Update/UpdateViewModel.swift | 1 + macos/Tests/Update/UpdateStateTests.swift | 6 ++-- macos/Tests/Update/UpdateViewModelTests.swift | 4 +-- 7 files changed, 54 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Features/Update/UpdateDelegate.swift b/macos/Sources/Features/Update/UpdateDelegate.swift index 26242b49e..619540851 100644 --- a/macos/Sources/Features/Update/UpdateDelegate.swift +++ b/macos/Sources/Features/Update/UpdateDelegate.swift @@ -22,7 +22,13 @@ extension UpdateDriver: SPUUpdaterDelegate { /// When `auto-update = check`, Sparkle will call the corresponding /// delegate method on the responsible driver instead. func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool { - viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: immediateInstallHandler)) + viewModel.state = .installing(.init( + isAutoUpdate: true, + retryTerminatingApplication: immediateInstallHandler, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + } + )) return true } diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 94b43a3f3..3beb4c9be 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -172,7 +172,12 @@ class UpdateDriver: NSObject, SPUUserDriver { } func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { - viewModel.state = .installing(.init(retryTerminatingApplication: retryTerminatingApplication)) + viewModel.state = .installing(.init( + retryTerminatingApplication: retryTerminatingApplication, + dismiss: { [weak viewModel] in + viewModel?.state = .idle + } + )) if !hasUnobtrusiveTarget { standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication) diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 08d25a4d1..87d76f801 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -291,7 +291,15 @@ fileprivate struct InstallingView: View { } HStack { + Button("Restart Later") { + installing.dismiss() + dismiss() + } + .keyboardShortcut(.cancelAction) + .controlSize(.small) + Spacer() + Button("Restart Now") { installing.retryTerminatingApplication() dismiss() diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift index cb4383a00..bf168d9fc 100644 --- a/macos/Sources/Features/Update/UpdateSimulator.swift +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -31,6 +31,9 @@ enum UpdateSimulator { /// Shows the installing state with restart button: installing (stays until dismissed) case installing + /// Simulates auto-update flow: goes directly to installing state without showing intermediate UI + case autoUpdate + func simulate(with viewModel: UpdateViewModel) { switch self { case .happyPath: @@ -49,6 +52,8 @@ enum UpdateSimulator { simulateCancelDuringChecking(viewModel) case .installing: simulateInstalling(viewModel) + case .autoUpdate: + simulateAutoUpdate(viewModel) } } @@ -270,9 +275,27 @@ enum UpdateSimulator { } private func simulateInstalling(_ viewModel: UpdateViewModel) { - viewModel.state = .installing(.init(retryTerminatingApplication: { - print("Restart button clicked in simulator - resetting to idle") - viewModel.state = .idle - })) + viewModel.state = .installing(.init( + retryTerminatingApplication: { + print("Restart button clicked in simulator - resetting to idle") + viewModel.state = .idle + }, + dismiss: { + viewModel.state = .idle + } + )) + } + + private func simulateAutoUpdate(_ viewModel: UpdateViewModel) { + viewModel.state = .installing(.init( + isAutoUpdate: true, + retryTerminatingApplication: { + print("Restart button clicked in simulator - resetting to idle") + viewModel.state = .idle + }, + dismiss: { + viewModel.state = .idle + } + )) } } diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 96cbe7c3d..1f9304616 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -367,5 +367,6 @@ enum UpdateState: Equatable { /// True if this state is triggered by ``Ghostty/UpdateDriver/updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)`` var isAutoUpdate = false let retryTerminatingApplication: () -> Void + let dismiss: () -> Void } } diff --git a/macos/Tests/Update/UpdateStateTests.swift b/macos/Tests/Update/UpdateStateTests.swift index 1d1d7b37d..354d371c5 100644 --- a/macos/Tests/Update/UpdateStateTests.swift +++ b/macos/Tests/Update/UpdateStateTests.swift @@ -25,10 +25,10 @@ struct UpdateStateTests { } @Test func testInstallingEquality() { - let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {})) - let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {})) + let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) + let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) #expect(state1 == state2) - let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {})) + let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) #expect(state3 != state2) } diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift index 5b223c59d..529c2bc52 100644 --- a/macos/Tests/Update/UpdateViewModelTests.swift +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -52,9 +52,9 @@ struct UpdateViewModelTests { @Test func testInstallingText() { let viewModel = UpdateViewModel() - viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {})) + viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) #expect(viewModel.text == "Installing…") - viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {})) + viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) #expect(viewModel.text == "Restart to Complete Update") } From 8437be8ee1e593afaff96f4d702cdce12e97cecd Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 11 Nov 2025 11:30:44 +0100 Subject: [PATCH 286/702] macOS: 'restore' non native fullscreen styles --- .../Features/Terminal/TerminalRestorable.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 1e640967e..56fa48d55 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -109,6 +109,17 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } completionHandler(window, nil) + // We don't restore the previous fullscreen mode. If the saved mode differs from + // the current configuration, using either could be confusing. Instead, we honor + // the configured mode (consistent with new_window behavior). + let mode = appDelegate.ghostty.config.windowFullscreenMode + guard mode != .native else { + // We let AppKit handle native fullscreen + return + } + // Give the window to AppKit first, then adjust its frame and style + // to minimise any visible frame changes. + c.toggleFullscreen(mode: mode) } /// This restores the focus state of the surfaceview within the given window. When restoring, From 2debeb0f13d5c4582f750a367685cf70ed12663b Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:09:38 +0100 Subject: [PATCH 287/702] macOS: save effective fullscreen styles --- .../Terminal/BaseTerminalController.swift | 18 ++++++++++++++++++ .../Features/Terminal/TerminalRestorable.swift | 9 ++++----- macos/Sources/Helpers/Fullscreen.swift | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 552f864ee..1bfae1674 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -66,6 +66,24 @@ class BaseTerminalController: NSWindowController, /// Fullscreen state management. private(set) var fullscreenStyle: FullscreenStyle? + /// The current effective fullscreen mode. + /// This is non-nil only while the window is in fullscreen. + var effectiveFullscreenMode: FullscreenMode? { + guard let fullscreenStyle, fullscreenStyle.isFullscreen else { + return nil + } + + switch fullscreenStyle { + case is NativeFullscreen: return .native + case is NonNativeFullscreen: return .nonNative + case is NonNativeFullscreenPaddedNotch: return .nonNativePaddedNotch + case is NonNativeFullscreenVisibleMenu: return .nonNativeVisibleMenu + default: + assertionFailure("Missing case here") + return nil + } + } + /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 56fa48d55..b4691f2ca 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -8,10 +8,12 @@ class TerminalRestorableState: Codable { let focusedSurface: String? let surfaceTree: SplitTree + let effectiveFullscreenMode: FullscreenMode? init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree + self.effectiveFullscreenMode = controller.effectiveFullscreenMode } init?(coder aDecoder: NSCoder) { @@ -28,6 +30,7 @@ class TerminalRestorableState: Codable { self.surfaceTree = v.value.surfaceTree self.focusedSurface = v.value.focusedSurface + self.effectiveFullscreenMode = v.value.effectiveFullscreenMode } func encode(with coder: NSCoder) { @@ -109,11 +112,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { } completionHandler(window, nil) - // We don't restore the previous fullscreen mode. If the saved mode differs from - // the current configuration, using either could be confusing. Instead, we honor - // the configured mode (consistent with new_window behavior). - let mode = appDelegate.ghostty.config.windowFullscreenMode - guard mode != .native else { + guard let mode = state.effectiveFullscreenMode, mode != .native else { // We let AppKit handle native fullscreen return } diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 6c70e8cf7..2ce9baea8 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -2,7 +2,7 @@ import Cocoa import GhosttyKit /// The fullscreen modes we support define how the fullscreen behaves. -enum FullscreenMode { +enum FullscreenMode: String, Codable { case native case nonNative case nonNativeVisibleMenu From eff361987876b21fd543c5239b412d44047f4389 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 11 Nov 2025 09:21:08 -0800 Subject: [PATCH 288/702] macos: Require fullScreenMode on fullscreenStyle --- .../Terminal/BaseTerminalController.swift | 18 ------------------ .../Features/Terminal/TerminalRestorable.swift | 2 +- macos/Sources/Helpers/Fullscreen.swift | 6 ++++++ 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1bfae1674..552f864ee 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -66,24 +66,6 @@ class BaseTerminalController: NSWindowController, /// Fullscreen state management. private(set) var fullscreenStyle: FullscreenStyle? - /// The current effective fullscreen mode. - /// This is non-nil only while the window is in fullscreen. - var effectiveFullscreenMode: FullscreenMode? { - guard let fullscreenStyle, fullscreenStyle.isFullscreen else { - return nil - } - - switch fullscreenStyle { - case is NativeFullscreen: return .native - case is NonNativeFullscreen: return .nonNative - case is NonNativeFullscreenPaddedNotch: return .nonNativePaddedNotch - case is NonNativeFullscreenVisibleMenu: return .nonNativeVisibleMenu - default: - assertionFailure("Missing case here") - return nil - } - } - /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index b4691f2ca..71e54b612 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -13,7 +13,7 @@ class TerminalRestorableState: Codable { init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree - self.effectiveFullscreenMode = controller.effectiveFullscreenMode + self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode } init?(coder aDecoder: NSCoder) { diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 2ce9baea8..78c967661 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -31,6 +31,7 @@ enum FullscreenMode: String, Codable { /// Protocol that must be implemented by all fullscreen styles. protocol FullscreenStyle { var delegate: FullscreenDelegate? { get set } + var fullscreenMode: FullscreenMode { get } var isFullscreen: Bool { get } var supportsTabs: Bool { get } init?(_ window: NSWindow) @@ -87,6 +88,7 @@ class FullscreenBase { /// macOS native fullscreen. This is the typical behavior you get by pressing the green fullscreen /// button on regular titlebars. class NativeFullscreen: FullscreenBase, FullscreenStyle { + var fullscreenMode: FullscreenMode { .native } var isFullscreen: Bool { window.styleMask.contains(.fullScreen) } var supportsTabs: Bool { true } @@ -127,6 +129,8 @@ class NativeFullscreen: FullscreenBase, FullscreenStyle { } class NonNativeFullscreen: FullscreenBase, FullscreenStyle { + var fullscreenMode: FullscreenMode { .nonNative } + // Non-native fullscreen never supports tabs because tabs require // the "titled" style and we don't have it for non-native fullscreen. var supportsTabs: Bool { false } @@ -439,10 +443,12 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { } class NonNativeFullscreenVisibleMenu: NonNativeFullscreen { + override var fullscreenMode: FullscreenMode { .nonNativeVisibleMenu } override var properties: Properties { Properties(hideMenu: false) } } class NonNativeFullscreenPaddedNotch: NonNativeFullscreen { + override var fullscreenMode: FullscreenMode { .nonNativePaddedNotch } override var properties: Properties { Properties(paddedNotch: true) } } From 188caf42a11cc3e92eb0bc31dc86c6c49b6fa011 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Nov 2025 09:57:51 -0800 Subject: [PATCH 289/702] search: move PageListSearch to a dedicated file --- src/terminal/search.zig | 884 +----------------------------- src/terminal/search/pagelist.zig | 885 +++++++++++++++++++++++++++++++ 2 files changed, 888 insertions(+), 881 deletions(-) create mode 100644 src/terminal/search/pagelist.zig diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 932ab5a35..a043973ff 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -1,885 +1,7 @@ //! Search functionality for the terminal. -//! -//! At the time of writing this comment, this is a **work in progress**. -//! -//! Search at the time of writing is implemented using a simple -//! boyer-moore-horspool algorithm. The suboptimal part of the implementation -//! is that we need to encode each terminal page into a text buffer in order -//! to apply BMH to it. This is because the terminal page is not laid out -//! in a flat text form. -//! -//! To minimize memory usage, we use a sliding window to search for the -//! needle. The sliding window only keeps the minimum amount of page data -//! in memory to search for a needle (i.e. `needle.len - 1` bytes of overlap -//! between terminal pages). -//! -//! Future work: -//! -//! - PageListSearch on a PageList concurrently with another thread -//! - Handle pruned pages in a PageList to ensure we don't keep references -//! - Repeat search a changing active area of the screen -//! - Reverse search so that more recent matches are found first -//! -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const CircBuf = @import("../datastruct/main.zig").CircBuf; -const terminal = @import("main.zig"); -const point = terminal.point; -const Page = terminal.Page; -const PageList = terminal.PageList; -const Pin = PageList.Pin; -const Selection = terminal.Selection; -const Screen = terminal.Screen; -const PageFormatter = @import("formatter.zig").PageFormatter; +pub const PageList = @import("search/pagelist.zig").PageListSearch; -/// Searches for a term in a PageList structure. -/// -/// At the time of writing, this does not support searching a pagelist -/// simultaneously as its being used by another thread. This will be resolved -/// in the future. -pub const PageListSearch = struct { - /// The list we're searching. - list: *PageList, - - /// The sliding window of page contents and nodes to search. - window: SlidingWindow, - - /// Initialize the page list search. - /// - /// The needle is not copied and must be kept alive for the duration - /// of the search operation. - pub fn init( - alloc: Allocator, - list: *PageList, - needle: []const u8, - ) Allocator.Error!PageListSearch { - var window = try SlidingWindow.init(alloc, needle); - errdefer window.deinit(); - - return .{ - .list = list, - .window = window, - }; - } - - pub fn deinit(self: *PageListSearch) void { - self.window.deinit(); - } - - /// Find the next match for the needle in the pagelist. This returns - /// null when there are no more matches. - pub fn next(self: *PageListSearch) Allocator.Error!?Selection { - // Try to search for the needle in the window. If we find a match - // then we can return that and we're done. - if (self.window.next()) |sel| return sel; - - // Get our next node. If we have a value in our window then we - // can determine the next node. If we don't, we've never setup the - // window so we use our first node. - var node_: ?*PageList.List.Node = if (self.window.meta.last()) |meta| - meta.node.next - else - self.list.pages.first; - - // Add one pagelist node at a time, look for matches, and repeat - // until we find a match or we reach the end of the pagelist. - // This append then next pattern limits memory usage of the window. - while (node_) |node| : (node_ = node.next) { - try self.window.append(node); - if (self.window.next()) |sel| return sel; - } - - // We've reached the end of the pagelist, no matches. - return null; - } -}; - -/// 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. If you're doing a reverse search then the -/// pages should be appended in reverse order and the needle should be -/// reversed. -/// -/// All appends grow the window. The window is only pruned when a searc -/// 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. -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, - - /// 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 not own the memory. - needle: []const u8, - - /// 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 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, - needle: []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 overlap_buf = try alloc.alloc(u8, needle.len * 2); - errdefer alloc.free(overlap_buf); - - return .{ - .alloc = alloc, - .data = data, - .meta = meta, - .needle = needle, - .overlap_buf = overlap_buf, - }; - } - - pub fn deinit(self: *SlidingWindow) void { - self.alloc.free(self.overlap_buf); - 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. - pub fn next(self: *SlidingWindow) ?Selection { - 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.selection( - 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.selection( - 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.selection( - 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 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. - /// - /// 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( - self: *SlidingWindow, - start_offset: usize, - len: usize, - ) Selection { - const start = start_offset + self.data_offset; - 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); - - // 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; - - // 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); - - // 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; - - // 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) { - // 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); - } - self.meta.deleteOldest(meta_count); - - // Delete all the data up to our current index. - assert(tl_meta_consumed > 0); - self.data.deleteOldest(tl_meta_consumed); - } - - self.assertIntegrity(); - return .init(tl, br, false); - } - - /// 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; - } - - // 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, - }; - } - - // Unreachable because it is a precondition that the index is - // within the data buffer. - unreachable; - } - - /// 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()). - pub fn append( - self: *SlidingWindow, - node: *PageList.List.Node, - ) Allocator.Error!void { - // 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); - - // Ensure our buffers are big enough to store what we need. - try self.data.ensureUnusedCapacity(self.alloc, encoded.written().len); - try self.meta.ensureUnusedCapacity(self.alloc, 1); - - // Append our new node to the circular buffer. - try self.data.appendSlice(encoded.written()); - try self.meta.append(meta); - - self.assertIntegrity(); - } - - 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 "PageListSearch single page" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - - var search = try PageListSearch.init(alloc, &s.pages, "boo!"); - defer search.deinit(); - - // We should be able to find two matches. - { - const sel = (try search.next()).?; - 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 sel = (try search.next()).?; - 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((try search.next()) == null); - try testing.expect((try search.next()) == null); -} - -test "SlidingWindow empty on init" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "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 = try SlidingWindow.init(alloc, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; - 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 sel = w.next().?; - 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 = try SlidingWindow.init(alloc, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 = try SlidingWindow.init(alloc, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; - 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 sel = w.next().?; - 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 = try SlidingWindow.init(alloc, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; - 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 prunes first page" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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, 80, 24, 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 = try SlidingWindow.init(alloc, 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 = try SlidingWindow.init(alloc, "abc"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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.needle = "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 sel = w.next().?; - 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 = try SlidingWindow.init(alloc, "abcd"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); - - // 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.needle = "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 sel = w.next().?; - 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 { + @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig new file mode 100644 index 000000000..336b1dfba --- /dev/null +++ b/src/terminal/search/pagelist.zig @@ -0,0 +1,885 @@ +//! Search functionality for the terminal. +//! +//! At the time of writing this comment, this is a **work in progress**. +//! +//! Search at the time of writing is implemented using a simple +//! boyer-moore-horspool algorithm. The suboptimal part of the implementation +//! is that we need to encode each terminal page into a text buffer in order +//! to apply BMH to it. This is because the terminal page is not laid out +//! in a flat text form. +//! +//! To minimize memory usage, we use a sliding window to search for the +//! needle. The sliding window only keeps the minimum amount of page data +//! in memory to search for a needle (i.e. `needle.len - 1` bytes of overlap +//! between terminal pages). +//! +//! Future work: +//! +//! - PageListSearch on a PageList concurrently with another thread +//! - Handle pruned pages in a PageList to ensure we don't keep references +//! - Repeat search a changing active area of the screen +//! - Reverse search so that more recent matches are found first +//! + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const terminal = @import("../main.zig"); +const point = terminal.point; +const Page = terminal.Page; +const PageList = terminal.PageList; +const Pin = PageList.Pin; +const Selection = terminal.Selection; +const Screen = terminal.Screen; +const PageFormatter = @import("../formatter.zig").PageFormatter; + +/// Searches for a term in a PageList structure. +/// +/// At the time of writing, this does not support searching a pagelist +/// simultaneously as its being used by another thread. This will be resolved +/// in the future. +pub const PageListSearch = struct { + /// The list we're searching. + list: *PageList, + + /// The sliding window of page contents and nodes to search. + window: SlidingWindow, + + /// Initialize the page list search. + /// + /// The needle is not copied and must be kept alive for the duration + /// of the search operation. + pub fn init( + alloc: Allocator, + list: *PageList, + needle: []const u8, + ) Allocator.Error!PageListSearch { + var window = try SlidingWindow.init(alloc, needle); + errdefer window.deinit(); + + return .{ + .list = list, + .window = window, + }; + } + + pub fn deinit(self: *PageListSearch) void { + self.window.deinit(); + } + + /// Find the next match for the needle in the pagelist. This returns + /// null when there are no more matches. + pub fn next(self: *PageListSearch) Allocator.Error!?Selection { + // Try to search for the needle in the window. If we find a match + // then we can return that and we're done. + if (self.window.next()) |sel| return sel; + + // Get our next node. If we have a value in our window then we + // can determine the next node. If we don't, we've never setup the + // window so we use our first node. + var node_: ?*PageList.List.Node = if (self.window.meta.last()) |meta| + meta.node.next + else + self.list.pages.first; + + // Add one pagelist node at a time, look for matches, and repeat + // until we find a match or we reach the end of the pagelist. + // This append then next pattern limits memory usage of the window. + while (node_) |node| : (node_ = node.next) { + try self.window.append(node); + if (self.window.next()) |sel| return sel; + } + + // We've reached the end of the pagelist, no matches. + return null; + } +}; + +/// 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. If you're doing a reverse search then the +/// pages should be appended in reverse order and the needle should be +/// reversed. +/// +/// All appends grow the window. The window is only pruned when a searc +/// 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. +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, + + /// 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 not own the memory. + needle: []const u8, + + /// 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 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, + needle: []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 overlap_buf = try alloc.alloc(u8, needle.len * 2); + errdefer alloc.free(overlap_buf); + + return .{ + .alloc = alloc, + .data = data, + .meta = meta, + .needle = needle, + .overlap_buf = overlap_buf, + }; + } + + pub fn deinit(self: *SlidingWindow) void { + self.alloc.free(self.overlap_buf); + 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. + pub fn next(self: *SlidingWindow) ?Selection { + 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.selection( + 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.selection( + 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.selection( + 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 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. + /// + /// 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( + self: *SlidingWindow, + start_offset: usize, + len: usize, + ) Selection { + const start = start_offset + self.data_offset; + 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); + + // 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; + + // 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); + + // 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; + + // 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) { + // 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); + } + self.meta.deleteOldest(meta_count); + + // Delete all the data up to our current index. + assert(tl_meta_consumed > 0); + self.data.deleteOldest(tl_meta_consumed); + } + + self.assertIntegrity(); + return .init(tl, br, false); + } + + /// 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; + } + + // 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, + }; + } + + // Unreachable because it is a precondition that the index is + // within the data buffer. + unreachable; + } + + /// 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()). + pub fn append( + self: *SlidingWindow, + node: *PageList.List.Node, + ) Allocator.Error!void { + // 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); + + // Ensure our buffers are big enough to store what we need. + try self.data.ensureUnusedCapacity(self.alloc, encoded.written().len); + try self.meta.ensureUnusedCapacity(self.alloc, 1); + + // Append our new node to the circular buffer. + try self.data.appendSlice(encoded.written()); + try self.meta.append(meta); + + self.assertIntegrity(); + } + + 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 "PageListSearch single page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 0); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + + var search = try PageListSearch.init(alloc, &s.pages, "boo!"); + defer search.deinit(); + + // We should be able to find two matches. + { + const sel = (try search.next()).?; + 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 sel = (try search.next()).?; + 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((try search.next()) == null); + try testing.expect((try search.next()) == null); +} + +test "SlidingWindow empty on init" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "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 = try SlidingWindow.init(alloc, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; + 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 sel = w.next().?; + 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 = try SlidingWindow.init(alloc, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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 = try SlidingWindow.init(alloc, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; + 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 sel = w.next().?; + 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 = try SlidingWindow.init(alloc, "hello, world"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; + 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 prunes first page" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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, 80, 24, 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 = try SlidingWindow.init(alloc, 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 = try SlidingWindow.init(alloc, "abc"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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.needle = "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 sel = w.next().?; + 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 = try SlidingWindow.init(alloc, "abcd"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 0); + defer s.deinit(); + try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); + + // 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.needle = "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 sel = w.next().?; + 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 8848e98271fc4b3c6b4f76796fd47db00a10b683 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Nov 2025 10:07:21 -0800 Subject: [PATCH 290/702] terminal: search thread boilerplate (does nothing) --- src/terminal/search.zig | 1 + src/terminal/search/Thread.zig | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/terminal/search/Thread.zig diff --git a/src/terminal/search.zig b/src/terminal/search.zig index a043973ff..6782f3e10 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -1,6 +1,7 @@ //! Search functionality for the terminal. pub const PageList = @import("search/pagelist.zig").PageListSearch; +pub const Thread = @import("search/Thread.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig new file mode 100644 index 000000000..b9f98a9dc --- /dev/null +++ b/src/terminal/search/Thread.zig @@ -0,0 +1,63 @@ +//! Search thread that handles searching a terminal for a string match. +//! This is expected to run on a dedicated thread to try to prevent too much +//! overhead to other terminal read/write operations. +//! +//! The current architecture of search does acquire global locks for accessing +//! terminal data, so there's still added contention, but we do our best to +//! minimize this by trading off memory usage (copying data to minimize lock +//! time). +pub const Thread = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; + +const log = std.log.scoped(.search_thread); + +/// Allocator used for some state +alloc: std.mem.Allocator, + +/// The mailbox that can be used to send this thread messages. Note +/// this is a blocking queue so if it is full you will get errors (or block). +mailbox: *Mailbox, + +/// Initialize the thread. This does not START the thread. This only sets +/// up all the internal state necessary prior to starting the thread. It +/// is up to the caller to start the thread with the threadMain entrypoint. +pub fn init(alloc: Allocator) Thread { + // The mailbox for messaging this thread + var mailbox = try Mailbox.create(alloc); + errdefer mailbox.destroy(alloc); + + return .{ + .alloc = alloc, + .mailbox = mailbox, + }; +} + +/// Clean up the thread. This is only safe to call once the thread +/// completes executing; the caller must join prior to this. +pub fn deinit(self: *Thread) void { + // Nothing can possibly access the mailbox anymore, destroy it. + self.mailbox.destroy(self.alloc); +} + +/// The main entrypoint for the thread. +pub fn threadMain(self: *Thread) void { + // Call child function so we can use errors... + self.threadMain_() catch |err| { + // In the future, we should expose this on the thread struct. + log.warn("search thread err={}", .{err}); + }; +} + +fn threadMain_(self: *Thread) !void { + defer log.debug("search thread exited", .{}); + _ = self; +} + +/// The type used for sending messages to the thread. +pub const Mailbox = BlockingQueue(Message, 64); + +/// The messages that can be sent to the thread. +pub const Message = union(enum) {}; From 6439af0afc4e9ec0920c7db739cc73d96a4d71dc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Nov 2025 10:27:52 -0800 Subject: [PATCH 291/702] terminal: SlidingWindow search to dedicated file --- src/terminal/search.zig | 3 + src/terminal/search/sliding_window.zig | 766 +++++++++++++++++++++++++ 2 files changed, 769 insertions(+) create mode 100644 src/terminal/search/sliding_window.zig diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 6782f3e10..a375c6ece 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -5,4 +5,7 @@ pub const Thread = @import("search/Thread.zig"); test { @import("std").testing.refAllDecls(@This()); + + // Non-public APIs + _ = @import("search/sliding_window.zig"); } diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig new file mode 100644 index 000000000..732a2d611 --- /dev/null +++ b/src/terminal/search/sliding_window.zig @@ -0,0 +1,766 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const terminal = @import("../main.zig"); +const point = terminal.point; +const PageList = terminal.PageList; +const Pin = PageList.Pin; +const Selection = terminal.Selection; +const Screen = terminal.Screen; +const PageFormatter = @import("../formatter.zig").PageFormatter; + +/// 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. If you're doing a reverse search then the +/// pages should be appended in reverse order and the needle should be +/// reversed. +/// +/// 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, + + /// 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 not own the memory. + needle: []const u8, + + /// 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 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, + needle: []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 overlap_buf = try alloc.alloc(u8, needle.len * 2); + errdefer alloc.free(overlap_buf); + + return .{ + .alloc = alloc, + .data = data, + .meta = meta, + .needle = needle, + .overlap_buf = overlap_buf, + }; + } + + pub fn deinit(self: *SlidingWindow) void { + self.alloc.free(self.overlap_buf); + 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. + pub fn next(self: *SlidingWindow) ?Selection { + 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.selection( + 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.selection( + 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.selection( + 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 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. + /// + /// 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( + self: *SlidingWindow, + start_offset: usize, + len: usize, + ) Selection { + const start = start_offset + self.data_offset; + 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); + + // 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; + + // 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); + + // 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; + + // 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) { + // 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); + } + self.meta.deleteOldest(meta_count); + + // Delete all the data up to our current index. + assert(tl_meta_consumed > 0); + self.data.deleteOldest(tl_meta_consumed); + } + + self.assertIntegrity(); + return .init(tl, br, false); + } + + /// 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; + } + + // 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, + }; + } + + // Unreachable because it is a precondition that the index is + // within the data buffer. + unreachable; + } + + /// 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()). + pub fn append( + self: *SlidingWindow, + node: *PageList.List.Node, + ) Allocator.Error!void { + // 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); + + // Ensure our buffers are big enough to store what we need. + try self.data.ensureUnusedCapacity(self.alloc, encoded.written().len); + try self.meta.ensureUnusedCapacity(self.alloc, 1); + + // Append our new node to the circular buffer. + try self.data.appendSlice(encoded.written()); + try self.meta.append(meta); + + self.assertIntegrity(); + } + + 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 = try SlidingWindow.init(alloc, "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 = try SlidingWindow.init(alloc, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; + 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 sel = w.next().?; + 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 = try SlidingWindow.init(alloc, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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 = try SlidingWindow.init(alloc, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; + 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 sel = w.next().?; + 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 = try SlidingWindow.init(alloc, "hello, world"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; + 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 prunes first page" { + const testing = std.testing; + const alloc = testing.allocator; + + var w = try SlidingWindow.init(alloc, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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, 80, 24, 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 = try SlidingWindow.init(alloc, 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 = try SlidingWindow.init(alloc, "abc"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 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.needle = "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 sel = w.next().?; + 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 = try SlidingWindow.init(alloc, "abcd"); + defer w.deinit(); + + var s = try Screen.init(alloc, 80, 24, 0); + defer s.deinit(); + try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); + + // 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.needle = "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 sel = w.next().?; + 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 43835d146878964c7493590924b7465e9b7b46b9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Nov 2025 11:22:56 -0800 Subject: [PATCH 292/702] terminal: SlidingWindow supports forward/reverse directions --- src/terminal/search/pagelist.zig | 797 +------------------------ src/terminal/search/sliding_window.zig | 434 +++++++++++++- 2 files changed, 419 insertions(+), 812 deletions(-) diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index 336b1dfba..cb9d0ee45 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -33,6 +33,7 @@ const Pin = PageList.Pin; const Selection = terminal.Selection; const Screen = terminal.Screen; const PageFormatter = @import("../formatter.zig").PageFormatter; +const SlidingWindow = @import("sliding_window.zig").SlidingWindow; /// Searches for a term in a PageList structure. /// @@ -46,16 +47,14 @@ pub const PageListSearch = struct { /// The sliding window of page contents and nodes to search. window: SlidingWindow, - /// Initialize the page list search. - /// - /// The needle is not copied and must be kept alive for the duration - /// of the search operation. + /// Initialize the page list search. The needle is copied so it can + /// be freed immediately. pub fn init( alloc: Allocator, list: *PageList, needle: []const u8, ) Allocator.Error!PageListSearch { - var window = try SlidingWindow.init(alloc, needle); + var window: SlidingWindow = try .init(alloc, .forward, needle); errdefer window.deinit(); return .{ @@ -95,791 +94,3 @@ pub const PageListSearch = struct { return null; } }; - -/// 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. If you're doing a reverse search then the -/// pages should be appended in reverse order and the needle should be -/// reversed. -/// -/// All appends grow the window. The window is only pruned when a searc -/// 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. -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, - - /// 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 not own the memory. - needle: []const u8, - - /// 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 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, - needle: []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 overlap_buf = try alloc.alloc(u8, needle.len * 2); - errdefer alloc.free(overlap_buf); - - return .{ - .alloc = alloc, - .data = data, - .meta = meta, - .needle = needle, - .overlap_buf = overlap_buf, - }; - } - - pub fn deinit(self: *SlidingWindow) void { - self.alloc.free(self.overlap_buf); - 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. - pub fn next(self: *SlidingWindow) ?Selection { - 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.selection( - 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.selection( - 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.selection( - 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 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. - /// - /// 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( - self: *SlidingWindow, - start_offset: usize, - len: usize, - ) Selection { - const start = start_offset + self.data_offset; - 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); - - // 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; - - // 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); - - // 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; - - // 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) { - // 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); - } - self.meta.deleteOldest(meta_count); - - // Delete all the data up to our current index. - assert(tl_meta_consumed > 0); - self.data.deleteOldest(tl_meta_consumed); - } - - self.assertIntegrity(); - return .init(tl, br, false); - } - - /// 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; - } - - // 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, - }; - } - - // Unreachable because it is a precondition that the index is - // within the data buffer. - unreachable; - } - - /// 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()). - pub fn append( - self: *SlidingWindow, - node: *PageList.List.Node, - ) Allocator.Error!void { - // 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); - - // Ensure our buffers are big enough to store what we need. - try self.data.ensureUnusedCapacity(self.alloc, encoded.written().len); - try self.meta.ensureUnusedCapacity(self.alloc, 1); - - // Append our new node to the circular buffer. - try self.data.appendSlice(encoded.written()); - try self.meta.append(meta); - - self.assertIntegrity(); - } - - 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 "PageListSearch single page" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - - var search = try PageListSearch.init(alloc, &s.pages, "boo!"); - defer search.deinit(); - - // We should be able to find two matches. - { - const sel = (try search.next()).?; - 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 sel = (try search.next()).?; - 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((try search.next()) == null); - try testing.expect((try search.next()) == null); -} - -test "SlidingWindow empty on init" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "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 = try SlidingWindow.init(alloc, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; - 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 sel = w.next().?; - 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 = try SlidingWindow.init(alloc, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 = try SlidingWindow.init(alloc, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; - 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 sel = w.next().?; - 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 = try SlidingWindow.init(alloc, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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 sel = w.next().?; - 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 prunes first page" { - const testing = std.testing; - const alloc = testing.allocator; - - var w = try SlidingWindow.init(alloc, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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, 80, 24, 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 = try SlidingWindow.init(alloc, 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 = try SlidingWindow.init(alloc, "abc"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 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.needle = "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 sel = w.next().?; - 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 = try SlidingWindow.init(alloc, "abcd"); - defer w.deinit(); - - var s = try Screen.init(alloc, 80, 24, 0); - defer s.deinit(); - try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); - - // 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.needle = "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 sel = w.next().?; - 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/sliding_window.zig b/src/terminal/search/sliding_window.zig index 732a2d611..f27299db2 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -15,9 +15,9 @@ const PageFormatter = @import("../formatter.zig").PageFormatter; /// (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. If you're doing a reverse search then the -/// pages should be appended in reverse order and the needle should be -/// reversed. +/// 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()`. @@ -56,14 +56,27 @@ pub const SlidingWindow = struct { /// do enough to prune it. data_offset: usize = 0, - /// The needle we're searching for. Does not own the memory. + /// 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 { @@ -77,7 +90,8 @@ pub const SlidingWindow = struct { pub fn init( alloc: Allocator, - needle: []const u8, + direction: Direction, + needle_unowned: []const u8, ) Allocator.Error!SlidingWindow { var data = try DataBuf.init(alloc, 0); errdefer data.deinit(alloc); @@ -85,6 +99,13 @@ pub const SlidingWindow = struct { 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); @@ -93,12 +114,14 @@ pub const SlidingWindow = struct { .data = data, .meta = meta, .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.data.deinit(self.alloc); var meta_it = self.meta.iterator(.forward); @@ -298,7 +321,10 @@ pub const SlidingWindow = struct { } self.assertIntegrity(); - return .init(tl, br, false); + return switch (self.direction) { + .forward => .init(tl, br, false), + .reverse => .init(br, tl, false), + }; } /// Convert a data index into a pin. @@ -376,17 +402,35 @@ pub const SlidingWindow = struct { }; assert(meta.cell_map.items.len == encoded.written().len); + // 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, encoded.written().len); + try self.data.ensureUnusedCapacity(self.alloc, written.len); try self.meta.ensureUnusedCapacity(self.alloc, 1); // Append our new node to the circular buffer. - try self.data.appendSlice(encoded.written()); + try self.data.appendSlice(written); try self.meta.append(meta); self.assertIntegrity(); } + /// 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; @@ -410,7 +454,7 @@ test "SlidingWindow empty on init" { const testing = std.testing; const alloc = testing.allocator; - var w = try SlidingWindow.init(alloc, "boo!"); + 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()); @@ -420,7 +464,7 @@ test "SlidingWindow single append" { const testing = std.testing; const alloc = testing.allocator; - var w = try SlidingWindow.init(alloc, "boo!"); + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); defer w.deinit(); var s = try Screen.init(alloc, 80, 24, 0); @@ -463,7 +507,7 @@ test "SlidingWindow single append no match" { const testing = std.testing; const alloc = testing.allocator; - var w = try SlidingWindow.init(alloc, "nope!"); + var w: SlidingWindow = try .init(alloc, .forward, "nope!"); defer w.deinit(); var s = try Screen.init(alloc, 80, 24, 0); @@ -487,7 +531,7 @@ test "SlidingWindow two pages" { const testing = std.testing; const alloc = testing.allocator; - var w = try SlidingWindow.init(alloc, "boo!"); + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); defer w.deinit(); var s = try Screen.init(alloc, 80, 24, 1000); @@ -540,7 +584,7 @@ test "SlidingWindow two pages match across boundary" { const testing = std.testing; const alloc = testing.allocator; - var w = try SlidingWindow.init(alloc, "hello, world"); + var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); defer w.deinit(); var s = try Screen.init(alloc, 80, 24, 1000); @@ -584,7 +628,7 @@ test "SlidingWindow two pages no match prunes first page" { const testing = std.testing; const alloc = testing.allocator; - var w = try SlidingWindow.init(alloc, "nope!"); + var w: SlidingWindow = try .init(alloc, .forward, "nope!"); defer w.deinit(); var s = try Screen.init(alloc, 80, 24, 1000); @@ -639,7 +683,7 @@ test "SlidingWindow two pages no match keeps both pages" { try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); const needle: []const u8 = needle_list.items; - var w = try SlidingWindow.init(alloc, needle); + var w: SlidingWindow = try .init(alloc, .forward, needle); defer w.deinit(); // Add both pages @@ -659,7 +703,7 @@ test "SlidingWindow single append across circular buffer boundary" { const testing = std.testing; const alloc = testing.allocator; - var w = try SlidingWindow.init(alloc, "abc"); + var w: SlidingWindow = try .init(alloc, .forward, "abc"); defer w.deinit(); var s = try Screen.init(alloc, 80, 24, 0); @@ -687,7 +731,7 @@ test "SlidingWindow single append across circular buffer boundary" { try testing.expectEqual(1, w.meta.len()); // Change the needle, just needs to be the same length (not a real API) - w.needle = "boo"; + w.testChangeNeedle("boo"); // Add new page, now wraps try w.append(node); @@ -714,7 +758,7 @@ test "SlidingWindow single append match on boundary" { const testing = std.testing; const alloc = testing.allocator; - var w = try SlidingWindow.init(alloc, "abcd"); + var w: SlidingWindow = try .init(alloc, .forward, "abcd"); defer w.deinit(); var s = try Screen.init(alloc, 80, 24, 0); @@ -742,7 +786,359 @@ test "SlidingWindow single append match on boundary" { try testing.expectEqual(1, w.meta.len()); // Change the needle, just needs to be the same length (not a real API) - w.needle = "boo!"; + 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 sel = w.next().?; + 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, 80, 24, 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 sel = w.next().?; + 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 sel = w.next().?; + 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, 80, 24, 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, 80, 24, 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 sel = w.next().?; + 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 sel = w.next().?; + 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, 80, 24, 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 sel = w.next().?; + 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, 80, 24, 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, 80, 24, 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, 80, 24, 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 sel = w.next().?; + 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, 80, 24, 0); + defer s.deinit(); + try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); + + // 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); From 4f6c5a8d4fe3d44261a120ce5145723c6405edc2 Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Tue, 11 Nov 2025 08:10:44 -0800 Subject: [PATCH 293/702] apprt/gtk: remove explicit X11 clipboard atom Turns out this was not needed after all and GTK adds it automatically when running under X11; just having the explicit UTF-8 charset type is enough. This corrects situations where it may not be necessary to include (Wayland), in addition to removing a duplicate atom under X11. Importantly, this also corrects issues under Wayland in some scenarios, such as using Electron-based apps (e.g., VSCode/Codium under Ubuntu 24.04 LTS). --- src/apprt/gtk/class/surface.zig | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 2cd032f08..6b29c3e12 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3365,27 +3365,17 @@ const Clipboard = struct { const bytes = glib.Bytes.new(content.data.ptr, content.data.len); defer bytes.unref(); if (std.mem.eql(u8, content.mime, "text/plain")) { - // Add some extra MIME types (and X11 atoms) for - // text/plain. This can be expanded on if certain - // applications are expecting text in a particular type - // or atom that is not currently here; UTF8_STRING - // seems to be the most common one for modern X11, but - // there are some older ones, e.g., XA_STRING or just - // plain STRING. Kitty seems to get by with just - // UTF8_STRING, but I'm also adding the explicit utf-8 - // MIME parameter for correctness; technically, for - // MIME, when the charset is missing, the default - // charset is ASCII. + // Add an explicit UTF-8 encoding parameter to the + // text/plain type. The default charset when there is + // none is ASCII, and lots of things look for UTF-8 + // specifically. + // + // 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", - "UTF8_STRING", }; - // Following on the same logic as our outer union, - // looks like we only need this memory during union - // construction, so it's okay if this is just a - // static-length array and goes out of scope when we're - // done. Similarly, we don't unref these providers. var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined; for (text_provider_atoms, 0..) |atom, j| { const provider = gdk.ContentProvider.newForBytes(atom, bytes); From 0ea350a8f28eea5567ef8d3191599f549cc5d7c0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Nov 2025 10:27:52 -0800 Subject: [PATCH 294/702] terminal: ActiveSearch for searching the active area --- src/terminal/search.zig | 1 + src/terminal/search/active.zig | 168 +++++++++++++++++++++++++ src/terminal/search/sliding_window.zig | 79 +++++++----- 3 files changed, 215 insertions(+), 33 deletions(-) create mode 100644 src/terminal/search/active.zig diff --git a/src/terminal/search.zig b/src/terminal/search.zig index a375c6ece..724b5c171 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -1,5 +1,6 @@ //! Search functionality for the terminal. +pub const Active = @import("search/active.zig").ActiveSearch; pub const PageList = @import("search/pagelist.zig").PageListSearch; pub const Thread = @import("search/Thread.zig"); diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig new file mode 100644 index 000000000..b682c6df3 --- /dev/null +++ b/src/terminal/search/active.zig @@ -0,0 +1,168 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const point = @import("../point.zig"); +const size = @import("../size.zig"); +const PageList = @import("../PageList.zig"); +const Selection = @import("../Selection.zig"); +const SlidingWindow = @import("sliding_window.zig").SlidingWindow; +const Terminal = @import("../Terminal.zig"); + +/// Searches for a substring within the active area of a PageList. +/// +/// The distinction for "active area" is important because it is the +/// only part of a PageList that is mutable. Therefore, its the only part +/// of the terminal that needs to be repeatedly searched as the contents +/// change. +/// +/// This struct specializes in searching only within that active area, +/// and handling the active area moving as new lines are added to the bottom. +pub const ActiveSearch = struct { + window: SlidingWindow, + + pub fn init( + alloc: Allocator, + needle: []const u8, + ) Allocator.Error!ActiveSearch { + // We just do a forward search since the active area is usually + // pretty small so search results are instant anyways. This avoids + // a small amount of work to reverse things. + var window: SlidingWindow = try .init(alloc, .forward, needle); + errdefer window.deinit(); + return .{ .window = window }; + } + + pub fn deinit(self: *ActiveSearch) void { + self.window.deinit(); + } + + /// Update the active area to reflect the current state of the PageList. + /// + /// This doesn't do the search, it only copies the necessary data + /// to perform the search later. This lets the caller hold the lock + /// on the PageList for a minimal amount of time. + /// + /// This returns the first page (in reverse order) NOT searched by + /// this active area. This is useful for callers that want to follow up + /// with populating the scrollback searcher. The scrollback searcher + /// should start searching from the returned page backwards. + /// + /// If the return value is null it means the active area covers the entire + /// PageList, currently. + pub fn update( + self: *ActiveSearch, + list: *const PageList, + ) Allocator.Error!?*PageList.List.Node { + // Clear our previous sliding window + self.window.clearAndRetainCapacity(); + + // First up, add enough pages to cover the active area. + var rem: usize = list.rows; + var node_ = list.pages.last; + while (node_) |node| : (node_ = node.prev) { + _ = try self.window.append(node); + + // If we reached our target amount, then this is the last + // page that contains the active area. We go to the previous + // page once more since its the first page of our required + // overlap. + if (rem <= node.data.size.rows) { + node_ = node.prev; + break; + } + + rem -= node.data.size.rows; + } + + // Next, add enough overlap to cover needle.len - 1 bytes (if it + // exists) so we can cover the overlap. + rem = self.window.needle.len - 1; + while (node_) |node| : (node_ = node.prev) { + const added = try self.window.append(node); + if (added >= rem) { + node_ = node.prev; + break; + } + rem -= added; + } + + // Return the first page NOT covered by the active area. + return node_; + } + + /// 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 { + return self.window.next(); + } +}; + +test "simple search" { + 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: ActiveSearch = try .init(alloc, "Fizz"); + defer search.deinit(); + _ = try search.update(&t.screen.pages); + + { + const sel = search.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } + { + const sel = search.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 2, + } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } + try testing.expect(search.next() == null); +} + +test "clear screen and search" { + 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: ActiveSearch = try .init(alloc, "Fizz"); + defer search.deinit(); + _ = try search.update(&t.screen.pages); + + try s.nextSlice("\x1b[2J"); // Clear screen + try s.nextSlice("\x1b[H"); // Move cursor home + try s.nextSlice("Buzz\r\nFizz\r\nBuzz"); + _ = try search.update(&t.screen.pages); + + { + const sel = search.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 1, + } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } + try testing.expect(search.next() == null); +} diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index f27299db2..29c612691 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -142,6 +142,14 @@ pub const SlidingWindow = struct { /// the window moves, the window will prune itself while maintaining /// 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 { const slices = slices: { // If we have less data then the needle then we can't possibly match @@ -368,10 +376,14 @@ pub const SlidingWindow = struct { /// 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!void { + ) Allocator.Error!usize { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, @@ -422,6 +434,7 @@ pub const SlidingWindow = struct { try self.meta.append(meta); self.assertIntegrity(); + return written.len; } /// Only for tests! @@ -474,7 +487,7 @@ test "SlidingWindow single append" { // 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); + _ = try w.append(node); // We should be able to find two matches. { @@ -517,7 +530,7 @@ test "SlidingWindow single append no match" { // 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); + _ = try w.append(node); // No matches try testing.expect(w.next() == null); @@ -550,8 +563,8 @@ test "SlidingWindow two pages" { // Add both pages const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node); - try w.append(node.next.?); + _ = try w.append(node); + _ = try w.append(node.next.?); // Search should find two matches { @@ -602,8 +615,8 @@ test "SlidingWindow two pages match across boundary" { // Add both pages const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node); - try w.append(node.next.?); + _ = try w.append(node); + _ = try w.append(node.next.?); // Search should find a match { @@ -647,8 +660,8 @@ test "SlidingWindow two pages no match prunes first page" { // Add both pages const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node); - try w.append(node.next.?); + _ = try w.append(node); + _ = try w.append(node.next.?); // Search should find nothing try testing.expect(w.next() == null); @@ -688,8 +701,8 @@ test "SlidingWindow two pages no match keeps both pages" { // Add both pages const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node); - try w.append(node.next.?); + _ = try w.append(node); + _ = try w.append(node.next.?); // Search should find nothing try testing.expect(w.next() == null); @@ -717,8 +730,8 @@ test "SlidingWindow single append across circular buffer boundary" { // 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); + _ = try w.append(node); + _ = try w.append(node); { // No wrap around yet const slices = w.data.getPtrSlice(0, w.data.len()); @@ -734,7 +747,7 @@ test "SlidingWindow single append across circular buffer boundary" { w.testChangeNeedle("boo"); // Add new page, now wraps - try w.append(node); + _ = try w.append(node); { const slices = w.data.getPtrSlice(0, w.data.len()); try testing.expect(slices[0].len > 0); @@ -772,8 +785,8 @@ test "SlidingWindow single append match on boundary" { // 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); + _ = try w.append(node); + _ = try w.append(node); { // No wrap around yet const slices = w.data.getPtrSlice(0, w.data.len()); @@ -789,7 +802,7 @@ test "SlidingWindow single append match on boundary" { w.testChangeNeedle("boo!"); // Add new page, now wraps - try w.append(node); + _ = try w.append(node); { const slices = w.data.getPtrSlice(0, w.data.len()); try testing.expect(slices[0].len > 0); @@ -823,7 +836,7 @@ test "SlidingWindow single append reversed" { // 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); + _ = try w.append(node); // We should be able to find two matches. { @@ -866,7 +879,7 @@ test "SlidingWindow single append no match reversed" { // 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); + _ = try w.append(node); // No matches try testing.expect(w.next() == null); @@ -899,8 +912,8 @@ test "SlidingWindow two pages reversed" { // Add both pages in reverse order const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node.next.?); - try w.append(node); + _ = try w.append(node.next.?); + _ = try w.append(node); // Search should find two matches (in reverse order) { @@ -951,8 +964,8 @@ test "SlidingWindow two pages match across boundary reversed" { // Add both pages in reverse order const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node.next.?); - try w.append(node); + _ = try w.append(node.next.?); + _ = try w.append(node); // Search should find a match { @@ -997,8 +1010,8 @@ test "SlidingWindow two pages no match prunes first page reversed" { // Add both pages in reverse order const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node.next.?); - try w.append(node); + _ = try w.append(node.next.?); + _ = try w.append(node); // Search should find nothing try testing.expect(w.next() == null); @@ -1038,8 +1051,8 @@ test "SlidingWindow two pages no match keeps both pages reversed" { // Add both pages in reverse order const node: *PageList.List.Node = s.pages.pages.first.?; - try w.append(node.next.?); - try w.append(node); + _ = try w.append(node.next.?); + _ = try w.append(node); // Search should find nothing try testing.expect(w.next() == null); @@ -1067,8 +1080,8 @@ test "SlidingWindow single append across circular buffer boundary reversed" { // 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); + _ = try w.append(node); + _ = try w.append(node); { // No wrap around yet const slices = w.data.getPtrSlice(0, w.data.len()); @@ -1085,7 +1098,7 @@ test "SlidingWindow single append across circular buffer boundary reversed" { w.testChangeNeedle("oob"); // Add new page, now wraps - try w.append(node); + _ = try w.append(node); { const slices = w.data.getPtrSlice(0, w.data.len()); try testing.expect(slices[0].len > 0); @@ -1123,8 +1136,8 @@ test "SlidingWindow single append match on boundary reversed" { // 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); + _ = try w.append(node); + _ = try w.append(node); { // No wrap around yet const slices = w.data.getPtrSlice(0, w.data.len()); @@ -1141,7 +1154,7 @@ test "SlidingWindow single append match on boundary reversed" { w.testChangeNeedle("!oob"); // Add new page, now wraps - try w.append(node); + _ = try w.append(node); { const slices = w.data.getPtrSlice(0, w.data.len()); try testing.expect(slices[0].len > 0); From bcb5112b243ee075e43d4dad3a3d7a27a02bd979 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:30:43 +0100 Subject: [PATCH 295/702] macOS: restore visiblity state when hiding quick terminal --- macos/Sources/App/macOS/AppDelegate.swift | 13 ++++++++----- .../QuickTerminal/QuickTerminalController.swift | 8 ++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 57e0212bb..f83b438f7 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -110,7 +110,7 @@ class AppDelegate: NSObject, } /// Tracks the windows that we hid for toggleVisibility. - private var hiddenState: ToggleVisibilityState? = nil + private(set) var hiddenState: ToggleVisibilityState? = nil /// The observer for the app appearance. private var appearanceObserver: NSKeyValueObservation? = nil @@ -280,6 +280,11 @@ class AppDelegate: NSObject, } } + func applicationDidHide(_ notification: Notification) { + // Keep track of our hidden state to restore properly + self.hiddenState = .init() + } + func applicationDidBecomeActive(_ notification: Notification) { // If we're back manually then clear the hidden state because macOS handles it. self.hiddenState = nil @@ -1084,8 +1089,6 @@ class AppDelegate: NSObject, guard let keyWindow = NSApp.keyWindow, !keyWindow.styleMask.contains(.fullScreen) else { return } - // Keep track of our hidden state to restore properly - self.hiddenState = .init() NSApp.hide(nil) return } @@ -1134,11 +1137,11 @@ class AppDelegate: NSObject, } } - private struct ToggleVisibilityState { + struct ToggleVisibilityState { let hiddenWindows: [Weak] let keyWindow: Weak? - init() { + fileprivate init() { // We need to know the key window so that we can bring focus back to the // right window if it was hidden. self.keyWindow = if let keyWindow = NSApp.keyWindow { diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 4669e108a..b3ad88666 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -513,6 +513,10 @@ class QuickTerminalController: BaseTerminalController { if !window.isOnActiveSpace { self.previousApp = nil window.orderOut(self) + // If our application is hidden previously, we hide it again + if (NSApp.delegate as? AppDelegate)?.hiddenState != nil { + NSApp.hide(nil) + } return } @@ -549,6 +553,10 @@ class QuickTerminalController: BaseTerminalController { // This causes the window to be removed from the screen list and macOS // handles what should be focused next. window.orderOut(self) + // If our application is hidden previously, we hide it again + if (NSApp.delegate as? AppDelegate)?.hiddenState != nil { + NSApp.hide(nil) + } }) } From 1486be0cdf8a63116a2039a224064b439782bebc Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:17:24 +0100 Subject: [PATCH 296/702] macOS: add more cursor style and fixes #8409 --- macos/Sources/Ghostty/SurfaceScrollView.swift | 6 ++ macos/Sources/Ghostty/SurfaceView.swift | 1 - .../Sources/Ghostty/SurfaceView_AppKit.swift | 14 +-- macos/Sources/Helpers/Cursor.swift | 86 ++++++++++++++++++- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 41a3df530..fcf25f479 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -133,6 +133,12 @@ class SurfaceScrollView: NSView { } } .store(in: &cancellables) + surfaceView.$pointerStyle + .receive(on: DispatchQueue.main) + .sink { [weak self] newStyle in + self?.scrollView.documentCursor = newStyle.cursor + } + .store(in: &cancellables) } required init?(coder: NSCoder) { diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index b42e34314..0358f765b 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -76,7 +76,6 @@ extension Ghostty { .focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) #if canImport(AppKit) - .backport.pointerStyle(surfaceView.pointerStyle) .onReceive(pubBecomeKey) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 3375e47ce..063b13300 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -73,7 +73,7 @@ extension Ghostty { @Published var surfaceSize: ghostty_surface_size_s? = nil // Whether the pointer should be visible or not - @Published private(set) var pointerStyle: BackportPointerStyle = .default + @Published private(set) var pointerStyle: CursorStyle = .horizontalText /// The configuration derived from the Ghostty config so we don't need to rely on references. @Published private(set) var derivedConfig: DerivedConfig @@ -477,16 +477,16 @@ extension Ghostty { pointerStyle = .resizeLeftRight case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: - pointerStyle = .default + pointerStyle = .verticalText - // These are not yet supported. We should support them by constructing a - // PointerStyle from an NSCursor. case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: - fallthrough + pointerStyle = .contextMenu + case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: - fallthrough + pointerStyle = .crosshair + case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: - pointerStyle = .default + pointerStyle = .operationNotAllowed default: // We ignore unknown shapes. diff --git a/macos/Sources/Helpers/Cursor.swift b/macos/Sources/Helpers/Cursor.swift index fe4a148b5..f749386da 100644 --- a/macos/Sources/Helpers/Cursor.swift +++ b/macos/Sources/Helpers/Cursor.swift @@ -1,6 +1,7 @@ import Cocoa +import SwiftUI -/// This helps manage the stateful nature of NSCursor hiding and unhiding. +/// This helps manage the stateful nature of NSCursor hiding and unhiding. class Cursor { private static var counter: UInt = 0 @@ -19,7 +20,7 @@ class Cursor { // won't go negative. NSCursor.unhide() - if (counter > 0) { + if counter > 0 { counter -= 1 return true } @@ -29,10 +30,89 @@ class Cursor { static func unhideCompletely() -> UInt { let counter = self.counter - for _ in 0.. Date: Thu, 13 Nov 2025 10:07:08 -0800 Subject: [PATCH 297/702] terminal: sliding window needs to handle hard-wraps properly (tested) --- src/terminal/formatter.zig | 2 +- src/terminal/search/sliding_window.zig | 91 ++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 46cc971c8..6683b3453 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -849,7 +849,7 @@ pub const PageFormatter = struct { /// Initializes a page formatter. Other options can be set directly on the /// struct after initialization and before calling `format()`. pub fn init(page: *const Page, opts: Options) PageFormatter { - return PageFormatter{ + return .{ .page = page, .opts = opts, .start_x = 0, diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 29c612691..4a2c3eb7d 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -414,6 +414,20 @@ pub const SlidingWindow = struct { }; 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(); @@ -637,6 +651,69 @@ test "SlidingWindow two pages match across boundary" { 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, 80, 24, 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, 80, 24, 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; @@ -778,13 +855,16 @@ test "SlidingWindow single append match on boundary" { 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 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); { @@ -1129,13 +1209,16 @@ test "SlidingWindow single append match on boundary reversed" { 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 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); { From 2b647ba4cb94ff4be9c1500991c15436ae9634c5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 13 Nov 2025 09:25:27 -0800 Subject: [PATCH 298/702] terminal: PageListSearch updated to split next and feed --- src/terminal/search/pagelist.zig | 392 +++++++++++++++++++++++++++---- src/terminal/search/screen.zig | 33 +++ 2 files changed, 376 insertions(+), 49 deletions(-) create mode 100644 src/terminal/search/screen.zig diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index cb9d0ee45..b1ad88e81 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -1,29 +1,7 @@ -//! Search functionality for the terminal. -//! -//! At the time of writing this comment, this is a **work in progress**. -//! -//! Search at the time of writing is implemented using a simple -//! boyer-moore-horspool algorithm. The suboptimal part of the implementation -//! is that we need to encode each terminal page into a text buffer in order -//! to apply BMH to it. This is because the terminal page is not laid out -//! in a flat text form. -//! -//! To minimize memory usage, we use a sliding window to search for the -//! needle. The sliding window only keeps the minimum amount of page data -//! in memory to search for a needle (i.e. `needle.len - 1` bytes of overlap -//! between terminal pages). -//! -//! Future work: -//! -//! - PageListSearch on a PageList concurrently with another thread -//! - Handle pruned pages in a PageList to ensure we don't keep references -//! - Repeat search a changing active area of the screen -//! - Reverse search so that more recent matches are found first -//! - const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const testing = std.testing; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const terminal = @import("../main.zig"); const point = terminal.point; @@ -33,13 +11,24 @@ 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; /// Searches for a term in a PageList structure. /// -/// At the time of writing, this does not support searching a pagelist -/// simultaneously as its being used by another thread. This will be resolved -/// in the future. +/// This searches in reverse order starting from the given node. +/// +/// This assumes that nodes do not change contents. For nodes that change +/// contents, look at ActiveSearch, which is designed to re-search the active +/// area since it assumed to change. When integrating ActiveSearch with +/// PageListSearch, the caller should start the PageListSearch from the +/// returned node from ActiveSearch.update(). +/// +/// Concurrent access to a PageList or nodes in a PageList are not allowed, +/// so the caller should ensure that necessary locks are held. Each function +/// documents whether it accesses the PageList or not. For example, you can +/// safely call `next()` without holding a lock, but you must hold a lock +/// while calling `feed()`. pub const PageListSearch = struct { /// The list we're searching. list: *PageList, @@ -47,50 +36,355 @@ pub const PageListSearch = struct { /// The sliding window of page contents and nodes to search. window: SlidingWindow, + /// The tracked pin for our current position in the pagelist. This + /// will always point to the CURRENT node we're searching from so that + /// we can track if we move. + pin: *Pin, + /// Initialize the page list search. The needle is copied so it can /// be freed immediately. + /// + /// Accesses the PageList/Node so the caller must ensure it is safe + /// to do so if there is any concurrent access. pub fn init( alloc: Allocator, - list: *PageList, needle: []const u8, + list: *PageList, + start: *PageList.List.Node, ) Allocator.Error!PageListSearch { - var window: SlidingWindow = try .init(alloc, .forward, needle); + // We put a tracked pin into the node that we're starting from. + // By using a tracked pin, we can keep our pagelist references safe + // because if the pagelist prunes pages, the tracked pin will + // be moved somewhere safe. + const pin = try list.trackPin(.{ + .node = start, + .y = start.data.size.rows - 1, + .x = start.data.size.cols - 1, + }); + errdefer list.untrackPin(pin); + + // Create our sliding window we'll use for searching. + var window: SlidingWindow = try .init(alloc, .reverse, needle); errdefer window.deinit(); + // We always feed our initial page data into the window, because + // we have the lock anyways and this lets our `pin` point to our + // current node and feed to work properly. + _ = try window.append(start); + return .{ .list = list, .window = window, + .pin = pin, }; } + /// Modifies the PageList (to untrack a pin) so the caller must ensure + /// that it is safe to do so. pub fn deinit(self: *PageListSearch) void { self.window.deinit(); + self.list.untrackPin(self.pin); } - /// Find the next match for the needle in the pagelist. This returns - /// null when there are no more matches. - pub fn next(self: *PageListSearch) Allocator.Error!?Selection { - // Try to search for the needle in the window. If we find a match - // then we can return that and we're done. - if (self.window.next()) |sel| return sel; + /// Return the next match in the loaded page nodes. If this returns + /// null then the PageList search needs to be fed the next node(s). + /// Call, `feed` to do this. + /// + /// Beware that the selection returned may point to a node that + /// is freed if the caller does not hold necessary locks on the + /// PageList while searching. The pins should be validated prior to + /// final use. + /// + /// This does NOT access the PageList, so it can be called without + /// a lock held. + pub fn next(self: *PageListSearch) ?Selection { + return self.window.next(); + } - // Get our next node. If we have a value in our window then we - // can determine the next node. If we don't, we've never setup the - // window so we use our first node. - var node_: ?*PageList.List.Node = if (self.window.meta.last()) |meta| - meta.node.next - else - self.list.pages.first; + /// Feed more data to the sliding window from the pagelist. This will + /// feed enough data to cover at least one match (needle length) if it + /// exists; this doesn't perform the search, it only feeds data. + /// + /// This accesses nodes in the PageList, so the caller must ensure + /// it is safe to do so (i.e. hold necessary locks). + /// + /// 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 { + // Add at least enough data to find a single match. + var rem = self.window.needle.len; - // Add one pagelist node at a time, look for matches, and repeat - // until we find a match or we reach the end of the pagelist. - // This append then next pattern limits memory usage of the window. - while (node_) |node| : (node_ = node.next) { - try self.window.append(node); - if (self.window.next()) |sel| return sel; + // Start at our previous node and then continue adding until we + // get our desired amount of data. + var node_: ?*PageList.List.Node = self.pin.node.prev; + while (node_) |node| : (node_ = node.prev) { + rem -|= try self.window.append(node); + + // Move our tracked pin to the new node. + self.pin.node = node; + + if (rem == 0) break; } - // We've reached the end of the pagelist, no matches. - return null; + // True if we fed any data. + return rem < self.window.needle.len; } }; + +test "simple search" { + 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: PageListSearch = try .init( + alloc, + "Fizz", + &t.screen.pages, + t.screen.pages.pages.last.?, + ); + defer search.deinit(); + + { + const sel = search.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 2, + } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } + { + const sel = search.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } + try testing.expect(search.next() == null); + + // We should not be able to feed since we have one page + try testing.expect(!try search.feed()); +} + +test "feed multiple pages with matches" { + 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(); + + // Fill up first page + const first_page_rows = t.screen.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Fizz"); + try testing.expect(t.screen.pages.pages.first == t.screen.pages.pages.last); + + // Create second page + try s.nextSlice("\r\n"); + try testing.expect(t.screen.pages.pages.first != t.screen.pages.pages.last); + try s.nextSlice("Buzz\r\nFizz"); + + var search: PageListSearch = try .init( + alloc, + "Fizz", + &t.screen.pages, + t.screen.pages.pages.last.?, + ); + defer search.deinit(); + + // First match on the last page + const sel1 = search.next(); + try testing.expect(sel1 != null); + try testing.expect(search.next() == null); + + // Feed should succeed and load the first page + try testing.expect(try search.feed()); + + // Now we should find the match on the first page + const sel2 = search.next(); + try testing.expect(sel2 != null); + try testing.expect(search.next() == null); + + // No more pages to feed + try testing.expect(!try search.feed()); +} + +test "feed multiple pages no matches" { + 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(); + + // Fill up first page + const first_page_rows = t.screen.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Hello"); + + // Create second page + try s.nextSlice("\r\n"); + try testing.expect(t.screen.pages.pages.first != t.screen.pages.pages.last); + try s.nextSlice("World"); + + var search: PageListSearch = try .init( + alloc, + "Nope", + &t.screen.pages, + t.screen.pages.pages.last.?, + ); + defer search.deinit(); + + // No matches on last page + try testing.expect(search.next() == null); + + // Feed first page + try testing.expect(try search.feed()); + + // Still no matches + try testing.expect(search.next() == null); + + // No more pages + try testing.expect(!try search.feed()); +} + +test "feed iteratively through multiple matches" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const first_page_rows = t.screen.pages.pages.first.?.data.capacity.rows; + + // Fill first page with a match at the end + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Page1Test"); + try testing.expect(t.screen.pages.pages.first == t.screen.pages.pages.last); + + // Create second page with a match + try s.nextSlice("\r\n"); + try testing.expect(t.screen.pages.pages.first != t.screen.pages.pages.last); + try s.nextSlice("Page2Test"); + + var search: PageListSearch = try .init( + alloc, + "Test", + &t.screen.pages, + t.screen.pages.pages.last.?, + ); + defer search.deinit(); + + // Match on page 2 + try testing.expect(search.next() != null); + try testing.expect(search.next() == null); + + // Feed page 1 + try testing.expect(try search.feed()); + try testing.expect(search.next() != null); + try testing.expect(search.next() == null); + + // No more pages + try testing.expect(!try search.feed()); +} + +test "feed with match spanning page boundary" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const first_page_rows = t.screen.pages.pages.first.?.data.capacity.rows; + + // Fill first page ending with "Te" + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + for (0..t.screen.pages.cols - 2) |_| try s.nextSlice("x"); + try s.nextSlice("Te"); + try testing.expect(t.screen.pages.pages.first == t.screen.pages.pages.last); + + // Second page starts with "st" + try s.nextSlice("st"); + try testing.expect(t.screen.pages.pages.first != t.screen.pages.pages.last); + + var search: PageListSearch = try .init( + alloc, + "Test", + &t.screen.pages, + t.screen.pages.pages.last.?, + ); + defer search.deinit(); + + // No complete match on last page alone (only has "st") + try testing.expect(search.next() == null); + + // Feed first page - this should give us enough data to find "Test" + 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 str = try t.screen.selectionString( + alloc, + .{ .sel = sel }, + ); + defer alloc.free(str); + try testing.expectEqualStrings(str, "Test"); + } + + // No more matches + try testing.expect(search.next() == null); + + // No more pages + try testing.expect(!try search.feed()); +} + +test "feed with match spanning page boundary with newline" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const first_page_rows = t.screen.pages.pages.first.?.data.capacity.rows; + + // Fill first page ending with "Te" + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + for (0..t.screen.pages.cols - 2) |_| try s.nextSlice("x"); + try s.nextSlice("Te"); + try testing.expect(t.screen.pages.pages.first == t.screen.pages.pages.last); + + // Second page starts with "st" + try s.nextSlice("\r\n"); + try testing.expect(t.screen.pages.pages.first != t.screen.pages.pages.last); + try s.nextSlice("st"); + + var search: PageListSearch = try .init( + alloc, + "Test", + &t.screen.pages, + t.screen.pages.pages.last.?, + ); + defer search.deinit(); + + // Should not find any matches since we broke with an explicit newline. + try testing.expect(search.next() == null); + try testing.expect(try search.feed()); + try testing.expect(search.next() == null); + try testing.expect(!try search.feed()); +} diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig new file mode 100644 index 000000000..036b5813e --- /dev/null +++ b/src/terminal/search/screen.zig @@ -0,0 +1,33 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Screen = @import("../Screen.zig"); +const Active = @import("active.zig").ActiveSearch; + +pub const ScreenSearch = struct { + /// The active area search state + active: Active, + + /// Search state machine + const State = enum { + /// Currently searching the active area + active, + }; + + pub fn init( + alloc: Allocator, + screen: *const Screen, + needle: []const u8, + ) Allocator.Error!ScreenSearch { + _ = screen; + + // Setup our active area search + var active: Active = try .init(alloc, needle); + errdefer active.deinit(); + + // Store our screen + + return .{ + .active = active, + }; + } +}; From ec55cbc879b9874169cb9521fe446850ba969ce8 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 13 Nov 2025 14:20:19 -0600 Subject: [PATCH 299/702] wuffs: protect against crafted images that cause overflows Fixes #9579 Protect against panics caused by integer overflows by using functions that allow integer overflows to be caught instead of causing a panic. Also protect against DOS from images that might not cause an overflow but do consume an absurd amount of memory by limiting images to a maximum size of 4GiB (which is the maximum size of `image-storage-limit`). --- pkg/wuffs/src/error.zig | 2 +- pkg/wuffs/src/jpeg.zig | 20 +++++++++++++++++++- pkg/wuffs/src/main.zig | 4 ++++ pkg/wuffs/src/png.zig | 20 +++++++++++++++++++- pkg/wuffs/src/too_big.jpg | Bin 0 -> 12586488 bytes pkg/wuffs/src/too_big.png | Bin 0 -> 1045503 bytes src/terminal/kitty/graphics_image.zig | 1 + 7 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 pkg/wuffs/src/too_big.jpg create mode 100644 pkg/wuffs/src/too_big.png diff --git a/pkg/wuffs/src/error.zig b/pkg/wuffs/src/error.zig index c75188718..0be55cf4e 100644 --- a/pkg/wuffs/src/error.zig +++ b/pkg/wuffs/src/error.zig @@ -2,7 +2,7 @@ const std = @import("std"); const c = @import("c.zig").c; -pub const Error = std.mem.Allocator.Error || error{WuffsError}; +pub const Error = std.mem.Allocator.Error || error{ WuffsError, Overflow }; pub fn check(log: anytype, status: *const c.struct_wuffs_base__status__struct) error{WuffsError}!void { if (!c.wuffs_base__status__is_ok(status)) { diff --git a/pkg/wuffs/src/jpeg.zig b/pkg/wuffs/src/jpeg.zig index 700ba01b9..69d91c8a9 100644 --- a/pkg/wuffs/src/jpeg.zig +++ b/pkg/wuffs/src/jpeg.zig @@ -4,6 +4,8 @@ const c = @import("c.zig").c; const Error = @import("error.zig").Error; const check = @import("error.zig").check; const ImageData = @import("main.zig").ImageData; +const maximum_image_size = @import("main.zig").maximum_image_size; +const mul = std.math.mul; const log = std.log.scoped(.wuffs_jpeg); @@ -61,9 +63,20 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { height, ); + const size: usize = try mul( + usize, + try mul(usize, width, height), + @sizeOf(c.wuffs_base__color_u32_argb_premul), + ); + + if (size > maximum_image_size) { + log.warn("image size {d} is larger than the maximum allowed ({d})", .{ size, maximum_image_size }); + return error.Overflow; + } + const destination = try alloc.alloc( u8, - width * height * @sizeOf(c.wuffs_base__color_u32_argb_premul), + size, ); errdefer alloc.free(destination); @@ -131,3 +144,8 @@ test "jpeg_decode_FFFFFF" { try std.testing.expectEqual(1, data.height); try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data); } + +test "jpeg: too big" { + const data = decode(std.testing.allocator, @embedFile("too_big.jpg")); + try std.testing.expectError(error.Overflow, data); +} diff --git a/pkg/wuffs/src/main.zig b/pkg/wuffs/src/main.zig index 89f3c008c..207d83f9a 100644 --- a/pkg/wuffs/src/main.zig +++ b/pkg/wuffs/src/main.zig @@ -5,6 +5,10 @@ pub const jpeg = @import("jpeg.zig"); pub const swizzle = @import("swizzle.zig"); pub const Error = @import("error.zig").Error; +/// The maximum image size, based on the 4G limit of Ghostty's +/// `image-storage-limit` config. +pub const maximum_image_size = 4 * 1024 * 1024 * 1024; + pub const ImageData = struct { width: u32, height: u32, diff --git a/pkg/wuffs/src/png.zig b/pkg/wuffs/src/png.zig index d79ae5b56..57a0e63bb 100644 --- a/pkg/wuffs/src/png.zig +++ b/pkg/wuffs/src/png.zig @@ -4,6 +4,8 @@ const c = @import("c.zig").c; const Error = @import("error.zig").Error; const check = @import("error.zig").check; const ImageData = @import("main.zig").ImageData; +const maximum_image_size = @import("main.zig").maximum_image_size; +const mul = std.math.mul; const log = std.log.scoped(.wuffs_png); @@ -61,9 +63,20 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { height, ); + const size: usize = try mul( + usize, + try mul(usize, width, height), + @sizeOf(c.wuffs_base__color_u32_argb_premul), + ); + + if (size > maximum_image_size) { + log.warn("image size {d} is larger than the maximum allowed ({d})", .{ size, maximum_image_size }); + return error.Overflow; + } + const destination = try alloc.alloc( u8, - width * height * @sizeOf(c.wuffs_base__color_u32_argb_premul), + size, ); errdefer alloc.free(destination); @@ -131,3 +144,8 @@ test "png_decode_FFFFFF" { try std.testing.expectEqual(1, data.height); try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data); } + +test "png: too big" { + const data = decode(std.testing.allocator, @embedFile("too_big.png")); + try std.testing.expectError(error.Overflow, data); +} diff --git a/pkg/wuffs/src/too_big.jpg b/pkg/wuffs/src/too_big.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d7ebf7dbb0d37efdabdad9bc6fdb8443ef91a972 GIT binary patch literal 12586488 zcmeFt$!}Iw7zf~Uzqx~v4v5rZD+ODchI}m_P>h7gAeJ4dh%QJFEtDM&h$JR;U|h+p ztSSfsJ7qR598l05h=O%h6)CJ3l);4`w*%pycuwxk`JH>;y!XCO@j|gLd^KlvS7!(v z9nB#ZLdb;5C>f$S4cs+RdT?JD=SPO~;y5=A*CgUxmAGHuwLUdiA2%Fa?Mp-GPlj-B z&xG({Hl*Tr`L@=4w0S5;afi?|{Jb@vk9vkW7KpwTAznjYiO-2-DxJyZN=nP(f+HhBB1$F` zsbo5xibsjw8TkKHMY{5_)~7O6^Ot1DuB>i*ck?H?hMAv#GjhS1tK%lU{MvgZr4NmI zcy!J9#~Yg_G#4gMnc6<>iCMGfJU#cBj(N{M_x!>a7QOh=(pO&XT(-Px#p`dpx$3Re zZ?Ad(gSG3{Z`inL%hqk%ckKM=<6XP=e7bkvXZsHv{Nm8zBS()NKk?c4#D+Vva%{Co4(?K}S!dqZwuo*CiJ|C^e2y9f{< zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+z+HCeKmY&$ z007AUwGk2=IB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|&4}2Lb>9002P#uZ@u4z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^` zz<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!K zaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB) z95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c z2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*= zfddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede z;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQq zIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n? z4jede;J|?c2M!!KaNxj!0|yQqIB?*=fddB)95`^`z<~n?4jede;J|?c2M!!KaNxj! z0|yQqIB?*=fddB)95|pII-mgnfI$G%Kh5))=Js$90RsjM7%*VKfB^#r3>YwAz<>b* R1`HT5V8DO@0|wRy?o%hi$ASO= literal 0 HcmV?d00001 diff --git a/pkg/wuffs/src/too_big.png b/pkg/wuffs/src/too_big.png new file mode 100644 index 0000000000000000000000000000000000000000..86b134a0d37d9e98aa656df9bbb4d8d0b639546b GIT binary patch literal 1045503 zcmeI*dytiN83*up1x?IL#AudTsEn7!3^hlau!&M}HPHc%#gLL1j+db4D0PyCn#@a?mep8^nPU--Lc2&Bv}gCcd){}?@A6@WVfUQ(yzlS% ze9vFb)3*zcm^fjJ&EB+GQ50KrAAG>1qL|aw^S9Wv=Y?X+2d}uU=bwc$Kh%9(clX=Y zzPL&8`mSL`&oyiRzNu^Dwf`R3^ZRz|em=VA=Uvxbvr*5_qc(c=`V-C{zV`a#dS37S zv){2r@zk(a|J|f0E_qEcyyu;(ZBh?&`TiapStz_Z&O6>&BPwxModJY;@zx zckG%|6vNj2+;jf5e^x#{`*l4(j5zt=si*Y3`&94Wu0>}~I;ki|7u^SppECQ}rW479|cO{{i^WyaF#`klbw+Ij*K!5-N0t5&U zAV7csfiwg@zyE%{|C@rMc<$_3!_yea6a)wmAV7cs0RjXF5FkJxAAv<@55Kd8U4UjF zTmS2c_pbf#0{5+1G;&I_tGrHt009C72oNAZfB*pk1PCM}@US9*WU@2}0RjXF5FkK+ z009C72&60U&8_zy*TybDQy4`8O=-MAfB*pk1PBlyK!5-N0tAv2P$ZCKtcD^$fB*pk z1PBlyK!5;&NCn>U$>$%hV;7(qha!PyGF~S@fB*pk1PBlyK!5-N0*MMJ5=b;yV-X-g zfB*pk1PBlyK!8BV0*~x4rh6!N0h(GU5@<@}4FUuR5FkK+009C72oNBUq<|uUBx5xc z0RjXF5FkK+009C72!tVU?~Es}2x1qY8HXZ)W-?wUK!5-N0t5&UAV7cs0Ro8%C=y6C zSYr_&K!5-N0t5&UAV7e?5DL8QjK-9pcLADOC=zH&;|&4?2oNAZfB*pk1PBlykfeYj zfh1!!6afMR2oNAZfB*pk1PIh3F#4LEj*oK}pc#iEfo3vZCqRGz0RjXF5FkK+009Dt z3MdjtG+1L1AV7cs0RjXF5FkK+z~BY;-EGuo)7k}SYN1G=DUCM>5FkK+009C72oNAZ zfIyN0iUg93)ldWo5FkK+009C72oNC9iomlcKJt}Jb^)4kC=zHU<8=ZA2oNAZfB*pk z1PBlykf?wnfkcBf76AeT2oNAZfB*pk1PHt$@YKjvk7m9L@QTHTKPVE|P{i*92oNAZ zfB*pk1PBlyKp<@aMFMH(YdQi12oNAZfB*pk1PJ6LuxQ#&bL|3@OI(paIWJceAV7cs z0RjXF5FkK+0DSAV7cs0RjXF5FkJxKY_`YU9i$FK+U-;5~vy2?FkSd zK!5-N0t5&UAV7dXXab4^LQBMX2oNAZfB*pk1PBlykek4Hi>`mhEACr+Jb7a-l#6$zx9 zt;q-wAV7cs0RjXF5FkK+Kwkoi1o~3(8UX?X2oNAZfB*pk1o9L3)MtM_%q~EFxhoRL zFLM(UAV7cs0RjXF5FkK+K;{C91Ty~u7@q(E0t5&UAV7csf!qWR{mY&s?E>VMdO!0j z66goVTLcIYAV7cs0RjXF5FkJxK>DiY`@zlRebK!5-N0t5&UAV7csfgu-ABrxRU9!P)y0RjXF5FkK+0D(s1 z;=`uiZ5O~dApikI0s(~J2m}ZaAV7cs0RjXF5FkKcC-F2BGoKyInWHLoIpxbkvZ0t5&UAV7cs0RjXF5FpU5fFgl*1$~|X0RjXF5FkK+ z009Dx#vM2A_XWEEz6mi3C=!S;G2+zfFgk`Vm2fJ0t5&UAV7cs0RjXF5O`HUk-)1Kej-4C z009C72oNAZAUA=h&)MlVy8yYRZr=3;6bY>F;Wq*V2oNAZfB*pk1PBlykh*{(fz%T= z9{~ac2oNAZfB*pkjm8@%9i@P^rT0elk(R3e~Apb|zmAwYlt0RjXF5FkK+009E= z2q+SWCo3l7f>Y7`3#(Z009C72oNAZfWZFmX51r!OyoSw52AV7cs0RjXF5FkK+0D(aWC=wV1nvW15 zK!5-N0t5&oFL3?47CvMbAo-XLNPs|(fFc230|EpH5FkK+009C72oNBUmw+OHyz(|J z0RjXF5FkK+!1@BaJ-2d!U4ZpX{6>I4UIK~)^2*z^1PBlyK!5-N0t5&UAV8p@NWhnX z009C72oNAZAOnH#E&Snab^$U7+K2=QlowDWP~OZZ2oNAZfB*pk1PBlyK!8BZ0*VA; zPS4p15FkK+009C7HW0Yu&3FCXF2DvTekDMFKz{;?1p3qQ9svRb2oNAZfB*pk1PBmF zP(YDDf}t9T009C72oNBUyuhkkp8SSgfaGH~AOQkHBA`fMNSHl@009C72oNAZfB*pk z1PBBpphzH?C>(?U0RjXF5FkL{{{%iZ?%X@<0{kC~7YGm_P@#Y#feJC*iU0uu1PBly zK!5-N0t5)eAfQMfhLoI<009C72oNBUxWK|)ADCtrAn}lmM}R=60*VAeO~$zh5FkK+ z009C72oNAZfIvM0iUjI$byore2oNAZfB=C$1Rh`W$9?Pq^a0}~0t5(jPC$`B=j=V1 z009C72oNAZfB*pk1PF9QK#@R4*gcd00RjXF5Fn7WzysqNzp@LEbi{@uKp-IjMFI(h zX%qqk2oNAZfB*pk1PBly(1L&>ffkTlLVy4P0t5&UAke44H#a|So?U=GX}m~)0D))( z6bVEVm4gx>K!5-N0t5&UAV7csfocR42~^|gE(8b=AV7csfrJH4o4oAPb^#I&*k}X@ z@#`B8Fm4h;CPt; z0RpKCC=y6DS#uE}K!5-N0t5&UAV7csfhGcq1e(BjnE(L-1PBlykgUMsr91oo2uL~v@I4?vfB*pk1PBlyK!5-N0+|UY638rY;}Rf1fB*pk=?Sd9bjcU( z0;HF#i3kuNFhl~11cnIOV+ar+K!5-N0t5&UAV7dXAOea60tvz~2oNAZfB*pk0}0GI z>+z9x0R~d?J^=y*2$U31Bv2B}6&<1PBly zK!5-N0t5&UAkZtINWk}i009C72oOj~;Otvho?#atr9{m_fB*pkbqgpGs9V_m2@oJa zfB*pk1PBlyK!8A40*VB}%EM_05FkK+0D;y77A!yZ>vjQJvvL&y0t5&U=ubeAKz};k zBS3%v0RjXF5FkK+009CC3MdjtFjONEAV7csfrJF^xpkghfQ0fi3IPHH2!tn~NFcmS zoQMDc0t5&UAV7cs0RjXF)G44ypiW))CP07y0RjYC6!`77%RXTjphYZ~5g0Sf~5FkK+009C72oNAZAOZnJ0uhAdhy(}_AV44)f%!jLy}Mn2Wa2ak0RjXF zBq5+kAc+_aL4W`O0t5&UAV7cs0RjYC7EmP6GL;Jn5FkK+0D+bTZa(>^lkEbu%;iD? z1PBly&_Mx30v*KnXaWQX5FkK+009C72oNC92?0d{oly5o0t5&UAdrZ_2WOmkl3jpA z!ZZc}0t5);DxgRp*W}GjfB*pk1PBlyK!5-N0=Wt(638`qa}yvyfB=CK0^MKx(vfxn zN`SeP009C72*e|xNFbi9oRk0o0t5&UAV7cs0RjXFR3e~Apb|zmAwYlt0Rl+~Jp0s5 z|F#Q|M3jagK!5-N0xb(D5@?yqg#-u?AV7cs0RjXF5FkJx2?0d{NyKOf0t5&UAW%x+ ztlvMfyK!5-N0t5&UAV7dXW&(-?G7H?e z1PBly5V61;em4FCb^#&|(9sDHAV7dXkOGPXf{ewX2oNAZfB*pk1PBlyK!8AP0*VA` zqjh5f1PBlyFi3%0uAH`qU4TL2`4|BL1PBlyP*OmVKuIu{6Cgl<009C72oNAZfB=Em z1r!Oyo}lv+AV7dXv;yBf>hky41&B622PZ&)009E22q+RrB}sD-AV7cs0RjXF5FkK+ z0D;y76bZEEdfB*pk1PIhDph%!*V7DhgfB*pk1PBly zK!5-N0-*^g5(q63=OI9V0D)Kqj-NJvf?j}F({pYD1PBlykgtFufqb(!IROF$2oNAZ zfB*pk1PJ6QphzIk+)Yh@009Dn7r3>t=Ty4@gZJ}E0t5&UAV4520Yw5~<>5302oNAZ zfB*pk1PBlyK%jO3MFO=GdjJ6f1PH__@Ye0`U2Yd3&g`6;009C72oNYCph%zumP-i` zAV7cs0RjXF5FkK+Kmr1a1QH0*2m}ZaAkdD$j$fI1h+Tko1bvnO0RjXF5QthpkwDb( zIXnRZ1PBlyK!5-N0t5&UC@-K$puCw+5FkK+K#T&X@BY{-y8tmJ=gb5M5FkK+K)nKr z1nTv5cLD?m5FkK+009C72oN9;nt&pK&=PST0t5&UXiwn9J4ftq7oa^upC&+n009C7 zauQG^kW<=bB|v}x0RjXF5FkK+009C$0*VBD4G0h*Kp;wit^ah(vvvWZjLxA65FkK+ z0D-Us6bXcthtm)sK!5-N0t5&UAV7csfw~103Dhm@{sagRAkdb;^u?c=Wf!0=LLVkT zfB*pk1PJseph%!U9q$n!K!5-N0t5&UAV7csfdmB<2_zV*kq8hV5TU@fKRNX$b^#&` z&XEZaAV7csf#?Mk2}B>E0SFKvK!5-N0t5&UAV7dXDFHKr~S~C;PaRFgFq0RjXF5FkK+009C7 z2oPu@ph%zzjF$-zAP|wj>T5^--Y!5yfjKGx0t5&UAV44>0Yw4K%iQIdv}<$!Y)9yrtU_7009C72oNApQb3VFNidfa zAV7cs0RjXF5FkK+0D;&A6bZzhpz{+T5Wc{YNf&Qy7a;tcoR9zk0t5&UAdshkB7r<} zH#GqQ1PBlyK!5-N0t5);E1*aq-|S6JfI!s(b2hs7t9AjZR&_rD1PBlyK!5;&dIb~- z)a&c+1PBlyK!5-N0t5&UAV45E0Yw7AMdCmN2!t;1*0a9#Yr6oUr{sJD2oNAZfB*pk zEea?SXc5X~1PBlyK!5-N0t5&UAV44y0Yw6d1ZfNc1gaOfYVL$lb^)q)bw>gO2oNAZ zfB=D@1QZDb6^FwRAV7cs0RjXF5FkK+0D<}i6baOC>XY0-c2oNAZfB*pk1PBlyKp;c`MFJtF;!Fex)FE*7-FMHn3s8rw zdlDc(fB*pk1PH_|phzI*^qidl0RjXF5FkK+009C72n<3%k-#9(e1rgjkOdw*ZOY+x z0YXm5*$5CIK!5-N0tA8*P$Upk91cT(009C72oNAZfB*pk1Zo#hBv3oC2M{1okH8nM zeBzyU0qU`JR{{hG5FkK+0D+7I6bWP$wowTXAV7cs0RjXF5FkK+K(Bxz0p9}x1i}{h z+_u}#wF?k-Move7009C72oN9;vw$LjnA3B10t5&UAV7cs0RjXF5Fk)qK#@RsGoK(p zpbmkV$M1BVU4S}d-ID+T0t5&UAV7dXe*%gG`qS|q0RjXF5FkK+009C72oOk6K#@R# zp&E%m$O1>6{@ks00YXm5*$5CIK!5-N0t7M>P$ZC9;Kn6DfB*pk1PBlyK!5;&>;)7F zWd9WaK%gFhS&Q$RX&0a#TX!WufB*pk1PBlyP@#Y#feJC*iU0uu1PBlyK!5-N0t5)e zAfQMfhLoITU3TmYy8z+l<75N~5FkK+009C71`<#tFp!Y<2@oJafB*pk1PBly zK!8A60*VCE%F{Fi>Jm6-)H5Hq3s9G?`w}2PfB*pk1PBo5oPZ*M&e?l10RjXF5FkK+ z009C72oUIqfFgm8uzM(hPzBC=|1Yny3lM5L&P9L#0RjXF5FkLH1_4C^HK4jB0RjXF z5FkK+009C72oMNZK#@Se;W!$B`UF return error.InvalidData, error.OutOfMemory => return error.OutOfMemory, + error.Overflow => return error.InvalidData, }; defer alloc.free(result.data); From 7b26e6319e232bab7685ae235de1e290ba625795 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 13 Nov 2025 12:58:30 -0800 Subject: [PATCH 300/702] terminal: Pin.garbage tracking --- src/terminal/PageList.zig | 49 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 82c64591b..aa5e31908 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -371,6 +371,9 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { if (comptime !build_options.slow_runtime_safety) return; if (self.pause_integrity_checks > 0) return; + // Our viewport pin should never be garbage + assert(!self.viewport_pin.garbage); + // Verify that our cached total_rows matches the actual row count const actual_total = self.totalRows(); if (actual_total != self.total_rows) { @@ -528,6 +531,8 @@ pub fn reset(self: *PageList) void { self.total_rows = self.rows; // Update all our tracked pins to point to our first page top-left + // and mark them as garbage, because it got mangled in a way where + // semantically it really doesn't make sense. { var it = self.tracked_pins.iterator(); while (it.next()) |entry| { @@ -535,7 +540,11 @@ pub fn reset(self: *PageList) void { p.node = self.pages.first.?; p.x = 0; p.y = 0; + p.garbage = true; } + + // Our viewport pin is never garbage + self.viewport_pin.garbage = false; } // Move our viewport back to the active area since everything is gone. @@ -2428,7 +2437,9 @@ pub fn grow(self: *PageList) !?*List.Node { p.node = self.pages.first.?; p.y = 0; p.x = 0; + p.garbage = true; } + self.viewport_pin.garbage = false; // In this case we do NOT need to update page_size because // we're reusing an existing page so nothing has changed. @@ -3047,13 +3058,16 @@ pub fn eraseRows( fn erasePage(self: *PageList, node: *List.Node) void { assert(node.next != null or node.prev != null); - // Update any tracked pins to move to the next page. + // Update any tracked pins to move to the previous or next page. const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { if (p.node != node) continue; - p.node = node.next orelse node.prev orelse unreachable; + p.node = node.prev orelse node.next orelse unreachable; p.y = 0; p.x = 0; + + // This doesn't get marked garbage because the tracked pin + // movement is sensical. } // Remove the page from the linked list @@ -3903,6 +3917,13 @@ pub const Pin = struct { y: size.CellCountInt = 0, x: size.CellCountInt = 0, + /// This is flipped to true for tracked pins that were tracking + /// a page that got pruned for any reason and where the tracked pin + /// couldn't be moved to a sensical location. Users of the tracked + /// pin could use this data and make their own determination of + /// semantics. + garbage: bool = false, + pub inline fn rowAndCell(self: Pin) struct { row: *pagepkg.Row, cell: *pagepkg.Cell, @@ -5757,6 +5778,7 @@ test "PageList grow prune scrollback" { try testing.expect(p.node == s.pages.first.?); try testing.expect(p.x == 0); try testing.expect(p.y == 0); + try testing.expect(p.garbage); // Verify the viewport offset cache was invalidated. After pruning, // the offset should have changed because we removed rows from @@ -10641,6 +10663,29 @@ test "PageList reset across two pages" { try testing.expectEqual(@as(usize, s.rows), s.totalRows()); } +test "PageList reset moves tracked pins and marks them as garbage" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Create a tracked pin into the active area + const p = try s.trackPin(s.pin(.{ .active = .{ + .x = 42, + .y = 12, + } }).?); + defer s.untrackPin(p); + + s.reset(); + + // Our added pin should now be garbage + try testing.expect(p.garbage); + + // Viewport pin should not be garbage because it makes sense. + try testing.expect(!s.viewport_pin.garbage); +} + test "PageList clears history" { const testing = std.testing; const alloc = testing.allocator; From d349cc8932f4ffffbf7680faa9c58ca94bbe8ce6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 13 Nov 2025 11:50:35 -0800 Subject: [PATCH 301/702] terminal: ScreenSearch to search a single terminal screen --- src/terminal/PageList.zig | 5 +- src/terminal/search.zig | 1 + src/terminal/search/active.zig | 28 +- src/terminal/search/screen.zig | 607 ++++++++++++++++++++++++++++++++- 4 files changed, 614 insertions(+), 27 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index aa5e31908..a589af179 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3858,8 +3858,9 @@ fn totalRows(self: *const PageList) usize { return rows; } -/// The total number of pages in this list. -fn totalPages(self: *const PageList) usize { +/// The total number of pages in this list. This should only be used +/// for tests since it is O(N) over the list of pages. +pub fn totalPages(self: *const PageList) usize { var pages: usize = 0; var node_ = self.pages.first; while (node_) |node| { diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 724b5c171..510aac980 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -2,6 +2,7 @@ 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 Thread = @import("search/Thread.zig"); test { diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index b682c6df3..d05417747 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -42,10 +42,10 @@ pub const ActiveSearch = struct { /// to perform the search later. This lets the caller hold the lock /// on the PageList for a minimal amount of time. /// - /// This returns the first page (in reverse order) NOT searched by - /// this active area. This is useful for callers that want to follow up - /// with populating the scrollback searcher. The scrollback searcher - /// should start searching from the returned page backwards. + /// This returns the first page (in reverse order) covered by this + /// search. This allows the history search to overlap and search history. + /// There CAN BE duplicates, and this page CAN BE mutable, so the history + /// search results should prune anything that's in the active area. /// /// If the return value is null it means the active area covers the entire /// PageList, currently. @@ -59,8 +59,10 @@ pub const ActiveSearch = struct { // First up, add enough pages to cover the active area. var rem: usize = list.rows; var node_ = list.pages.last; + var last_node: ?*PageList.List.Node = null; while (node_) |node| : (node_ = node.prev) { _ = try self.window.append(node); + last_node = node; // If we reached our target amount, then this is the last // page that contains the active area. We go to the previous @@ -76,18 +78,20 @@ pub const ActiveSearch = struct { // Next, add enough overlap to cover needle.len - 1 bytes (if it // exists) so we can cover the overlap. - rem = self.window.needle.len - 1; while (node_) |node| : (node_ = node.prev) { + // If the last row of this node isn't wrapped we can't overlap. + const row = node.data.getRow(node.data.size.rows - 1); + if (!row.wrap) break; + + // We could be more accurate here and count bytes since the + // last wrap but its complicated and unlikely multiple pages + // wrap so this should be fine. const added = try self.window.append(node); - if (added >= rem) { - node_ = node.prev; - break; - } - rem -= added; + if (added >= self.window.needle.len - 1) break; } - // Return the first page NOT covered by the active area. - return node_; + // Return the last node we added to our window. + return last_node; } /// Find the next match for the needle in the active area. This returns diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 036b5813e..e291f3c2e 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -1,33 +1,614 @@ const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; const Allocator = std.mem.Allocator; +const point = @import("../point.zig"); +const PageList = @import("../PageList.zig"); +const Pin = PageList.Pin; const Screen = @import("../Screen.zig"); -const Active = @import("active.zig").ActiveSearch; +const Selection = @import("../Selection.zig"); +const Terminal = @import("../Terminal.zig"); +const ActiveSearch = @import("active.zig").ActiveSearch; +const PageListSearch = @import("pagelist.zig").PageListSearch; +const SlidingWindow = @import("sliding_window.zig").SlidingWindow; +/// Searches for a needle within a Screen, handling active area updates, +/// pages being pruned from the screen (e.g. scrollback limits), and more. +/// +/// Unlike our lower-level searchers (like ActiveSearch and PageListSearch), +/// this will cache and store all search results so the caller can re-access +/// them as needed. This structure does this because it is intended to help +/// the caller handle the case where the Screen is changing while the user +/// is searching. +/// +/// An inactive screen can continue to be searched in the background, and when +/// screen state changes, the renderer/caller can access the existing search +/// results without needing to re-search everything. This prevents a particularly +/// nasty UX where going to alt screen (e.g. neovim) and then back would +/// restart the full scrollback search. pub const ScreenSearch = struct { + /// The screen being searched. + screen: *Screen, + /// The active area search state - active: Active, + active: ActiveSearch, + + /// The history (scrollback) search state. May be null if there is + /// no history yet. + history: ?HistorySearch, + + /// Current state of the search, a state machine. + state: State, + + /// 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 + /// re-search scenario. + history_results: std.ArrayList(Selection), + active_results: std.ArrayList(Selection), + + /// History search state. + const HistorySearch = struct { + /// The actual searcher state. + searcher: PageListSearch, + + /// The pin for the first node that this searcher is searching from. + /// We use this when the active area changes to find the diff between + /// the top of the new active area and the previous start point + /// to determine if we need to search more history. + start_pin: *Pin, + + pub fn deinit(self: *HistorySearch, screen: *Screen) void { + self.searcher.deinit(); + screen.pages.untrackPin(self.start_pin); + } + }; /// Search state machine const State = enum { /// Currently searching the active area active, + + /// Currently searching the history area + history, + + /// History search is waiting for more data to be fed before + /// it can progress. + history_feed, + + /// Search is complete given the current terminal state. + complete, }; + // Initialize a screen search for the given screen and needle. pub fn init( alloc: Allocator, - screen: *const Screen, + screen: *Screen, needle: []const u8, ) Allocator.Error!ScreenSearch { - _ = screen; - - // Setup our active area search - var active: Active = try .init(alloc, needle); - errdefer active.deinit(); - - // Store our screen - - return .{ - .active = active, + var result: ScreenSearch = .{ + .screen = screen, + .active = try .init(alloc, needle), + .history = null, + .state = .active, + .active_results = .empty, + .history_results = .empty, }; + errdefer result.deinit(); + + // Update our initial active area state + try result.reloadActive(); + + return result; + } + + pub fn deinit(self: *ScreenSearch) void { + const alloc = self.allocator(); + self.active.deinit(); + if (self.history) |*h| h.deinit(self.screen); + self.active_results.deinit(alloc); + self.history_results.deinit(alloc); + } + + fn allocator(self: *ScreenSearch) Allocator { + return self.active.window.alloc; + } + + pub const TickError = Allocator.Error || error{ + FeedRequired, + SearchComplete, + }; + + /// Returns all matches as an owned slice (caller must free). + /// The matches are ordered from most recent to oldest (e.g. bottom + /// of the screen to top of the screen). + /// + /// This handles pruning overlapping results between active area + /// and the history area so you should use this instead of accessing + /// the result slices directly. + pub fn matches( + self: *ScreenSearch, + alloc: Allocator, + ) Allocator.Error![]Selection { + const active_results = self.active_results.items; + const history_results: []const Selection = if (self.history) |*h| history_results: { + // We prune all the history results that start in our first + // history page because the active area will overlap and + // get that. + for (self.history_results.items, 0..) |sel, i| { + if (sel.start().node != h.start_pin.node) { + break :history_results self.history_results.items[i..]; + } + } + + break :history_results &.{}; + } else &.{}; + + const results = try alloc.alloc( + Selection, + active_results.len + history_results.len, + ); + errdefer alloc.free(results); + + // Active does a forward search, so we add the active results then + // reverse them. There are usually not many active results so this + // is fast enough compared to adding them in reverse order. + assert(self.active.window.direction == .forward); + @memcpy( + results[0..active_results.len], + active_results, + ); + std.mem.reverse(Selection, results[0..active_results.len]); + + // History does a backward search, so we can just append them + // after. + @memcpy( + results[active_results.len..], + history_results, + ); + + return results; + } + + /// Search the full screen state. This will block until the search + /// is complete. For performance, it is recommended to use `tick` and + /// `feed` to incrementally make progress on the search instead. + pub fn searchAll(self: *ScreenSearch) Allocator.Error!void { + while (true) { + self.tick() catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.FeedRequired => try self.feed(), + error.SearchComplete => return, + }; + } + } + + /// Make incremental progress on the search without accessing any + /// screen state (so no lock is required). + /// + /// This will return error.FeedRequired if the search cannot make progress + /// without being fed more data. In this case, the caller should call + /// the `feed` function to provide more data to the searcher. + /// + /// This will return error.SearchComplete if the search is fully complete. + /// This is to signal to the caller that it can move to a more efficient + /// sleep/wait state until there is more work to do (e.g. new data to feed). + pub fn tick(self: *ScreenSearch) TickError!void { + switch (self.state) { + .active => try self.tickActive(), + .history => try self.tickHistory(), + .history_feed => return error.FeedRequired, + .complete => return error.SearchComplete, + } + } + + /// Feed more data to the searcher so it can continue searching. This + /// accesses the screen state, so the caller must hold the necessary locks. + 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. + self.state = .complete; + return; + }; + + // Future: we may want to feed multiple pages at once here to + // lower the frequency of lock acquisitions. + if (!try history.feed()) { + // No more data to feed, search is complete. + self.state = .complete; + return; + } + + // Depending on our state handle where feed goes + switch (self.state) { + // If we're searching active or history, then feeding doesn't + // change the state. + .active, .history => {}, + + // Feed goes back to searching history. + .history_feed => self.state = .history, + + // If we're complete then the feed call above should always + // return false and we can't reach this. + .complete => unreachable, + } + } + + 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. + const alloc = self.allocator(); + while (self.active.next()) |sel| { + // 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); + } + + // We've consumed the entire active area, move to history. + self.state = .history; + } + + fn tickHistory(self: *ScreenSearch) Allocator.Error!void { + const history: *PageListSearch = if (self.history) |*h| &h.searcher else { + // No history to search, we're done. + self.state = .complete; + return; + }; + + // 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.next()) |sel| { + // Same note as tickActive for error handling. + try self.history_results.append(alloc, sel); + } + + // We need to be fed more data. + self.state = .history_feed; + } + + /// Reload the active area because it has changed. + /// + /// Since it is very fast, this will also do the full active area + /// search again, too. This avoids any complexity around the search + /// state machine. + /// + /// The caller must hold the necessary locks to access the screen state. + pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void { + 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 + // cause new pages to move into our history. If there are new + // pages then we need to re-search the pages and add it to + // our history results. + + const history_: ?*HistorySearch = if (self.history) |*h| state: { + // If our start pin became garbage, it means we pruned all + // the way up through it, so we have no history anymore. + // Reset our history state. + if (h.start_pin.garbage) { + h.deinit(self.screen); + self.history = null; + self.history_results.clearRetainingCapacity(); + break :state null; + } + + break :state h; + } else null; + + const history = history_ orelse { + // No history search yet, but we now have history. So let's + // initialize. + + // Our usage of needle below depends on this + assert(self.active.window.direction == .forward); + + var search: PageListSearch = try .init( + self.allocator(), + self.active.window.needle, + list, + history_node, + ); + errdefer search.deinit(); + + const pin = try list.trackPin(.{ .node = history_node }); + errdefer list.untrackPin(pin); + + self.history = .{ + .searcher = search, + .start_pin = pin, + }; + + // We don't need to update any history since we had no history + // before, so we can break out of the whole conditional. + break :history; + }; + + if (history.start_pin.node == history_node) { + // No change in the starting node, we're done. + break :history; + } + + // We had prior history with a valid pin and our current + // starting history node doesn't match our previous. So there is + // a small delta (usually small) that we need to search and update + // our history results. + const old_node = history.start_pin.node; + + // Do a forward search from our prior node to this one. We + // 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, + self.active.window.needle, + ); + defer window.deinit(); + while (true) { + _ = try window.append(history.start_pin.node); + if (history.start_pin.node == history_node) break; + const next = history.start_pin.node.next orelse break; + history.start_pin.node = next; + } + assert(history.start_pin.node == history_node); + + var results: std.ArrayList(Selection) = try .initCapacity( + alloc, + self.history_results.items.len, + ); + errdefer results.deinit(alloc); + while (window.next()) |sel| try results.append( + alloc, + sel, + ); + + // If we have no matches then there is nothing to change + // in our history (fast path) + if (results.items.len == 0) break :history; + + // 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); + for (self.history_results.items, 0..) |sel, i| { + if (sel.start().node != old_node) { + try results.appendSlice(alloc, self.history_results.items[i..]); + break; + } + } + self.history_results.deinit(alloc); + self.history_results = results; + } + + // Reset our active search results and search again. + self.active_results.clearRetainingCapacity(); + switch (self.state) { + // If we're in the active state we run a normal tick so + // we can move into a better state. + .active => try self.tickActive(), + + // Otherwise, just tick it and move back to whatever state + // we were in. + else => { + const old_state = self.state; + defer self.state = old_state; + try self.tickActive(); + }, + } } }; + +test "simple search" { + 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.screen, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(2, search.active_results.items.len); + // We don't test history results since there is overlap + + // Get all matches + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(2, matches.len); + + { + const sel = matches[0]; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screen.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screen.pages.pointFromPin(.screen, sel.end()).?); + } + { + const sel = matches[1]; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screen.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screen.pages.pointFromPin(.screen, sel.end()).?); + } +} + +test "simple search 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.screen.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.screen, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(0, search.active_results.items.len); + + // Get all matches + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(1, matches.len); + + { + const sel = matches[0]; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screen.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screen.pages.pointFromPin(.screen, sel.end()).?); + } +} + +test "reload active with history change" { + 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.screen.pages; + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\n"); + + // Start up our search which will populate our initial active area. + var search: ScreenSearch = try .init(alloc, &t.screen, "Fizz"); + defer search.deinit(); + try search.searchAll(); + { + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(1, matches.len); + } + + // Grow into two pages so our history pin will move. + while (list.totalPages() < 2) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("2Fizz"); + + // Active area changed so reload + try search.reloadActive(); + try search.searchAll(); + + // Get all matches + { + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(2, matches.len); + { + const sel = matches[1]; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screen.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screen.pages.pointFromPin(.screen, sel.end()).?); + } + { + const sel = matches[0]; + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 1, + } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 1, + } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } + } + + // Reset the screen which will make our pin garbage. + t.fullReset(); + try s.nextSlice("WeFizzing"); + try search.reloadActive(); + try search.searchAll(); + + { + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(1, matches.len); + { + const sel = matches[0]; + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 0, + } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 5, + .y = 0, + } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } + } +} + +test "active change contents" { + 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("Fuzz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, &t.screen, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.active_results.items.len); + + // 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("Bang\r\nFizz\r\nHello!"); + + try search.reloadActive(); + try search.searchAll(); + try testing.expectEqual(1, search.active_results.items.len); + + // Get all matches + const matches = try search.matches(alloc); + defer alloc.free(matches); + try testing.expectEqual(1, matches.len); + + { + const sel = matches[0]; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, t.screen.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, t.screen.pages.pointFromPin(.screen, sel.end()).?); + } +} From 6b805a318eb506e338bf6816176d751e31fc58fb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 07:24:02 -0800 Subject: [PATCH 302/702] terminal: ScreenSearch can omit overlapped results in history tick --- src/terminal/search/screen.zig | 50 ++++++++++------------------------ 1 file changed, 15 insertions(+), 35 deletions(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index e291f3c2e..0ffeb76c4 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -122,28 +122,12 @@ pub const ScreenSearch = struct { /// Returns all matches as an owned slice (caller must free). /// The matches are ordered from most recent to oldest (e.g. bottom /// of the screen to top of the screen). - /// - /// This handles pruning overlapping results between active area - /// and the history area so you should use this instead of accessing - /// the result slices directly. pub fn matches( self: *ScreenSearch, alloc: Allocator, ) Allocator.Error![]Selection { const active_results = self.active_results.items; - const history_results: []const Selection = if (self.history) |*h| history_results: { - // We prune all the history results that start in our first - // history page because the active area will overlap and - // get that. - for (self.history_results.items, 0..) |sel, i| { - if (sel.start().node != h.start_pin.node) { - break :history_results self.history_results.items[i..]; - } - } - - break :history_results &.{}; - } else &.{}; - + const history_results = self.history_results.items; const results = try alloc.alloc( Selection, active_results.len + history_results.len, @@ -252,7 +236,7 @@ pub const ScreenSearch = struct { } fn tickHistory(self: *ScreenSearch) Allocator.Error!void { - const history: *PageListSearch = if (self.history) |*h| &h.searcher else { + const history: *HistorySearch = if (self.history) |*h| h else { // No history to search, we're done. self.state = .complete; return; @@ -261,7 +245,11 @@ 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.next()) |sel| { + while (history.searcher.next()) |sel| { + // 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; + // Same note as tickActive for error handling. try self.history_results.append(alloc, sel); } @@ -332,12 +320,6 @@ pub const ScreenSearch = struct { break :history; } - // We had prior history with a valid pin and our current - // starting history node doesn't match our previous. So there is - // a small delta (usually small) that we need to search and update - // our history results. - const old_node = history.start_pin.node; - // Do a forward search from our prior node to this one. We // collect all the results into a new list. We ASSUME that // reloadActive is being called frequently enough that there isn't @@ -362,10 +344,13 @@ pub const ScreenSearch = struct { self.history_results.items.len, ); errdefer results.deinit(alloc); - while (window.next()) |sel| try results.append( - alloc, - sel, - ); + while (window.next()) |sel| { + if (sel.start().node == history_node) continue; + try results.append( + alloc, + sel, + ); + } // If we have no matches then there is nothing to change // in our history (fast path) @@ -374,12 +359,7 @@ 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); - for (self.history_results.items, 0..) |sel, i| { - if (sel.start().node != old_node) { - try results.appendSlice(alloc, self.history_results.items[i..]); - break; - } - } + try results.appendSlice(alloc, self.history_results.items); self.history_results.deinit(alloc); self.history_results = results; } From d48f855a487f81fa61f091ac29e0d31fcb9948cd Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 9 Nov 2025 20:39:00 -0800 Subject: [PATCH 303/702] macOS: set scrollbar size to .small --- macos/Sources/Ghostty/SurfaceScrollView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index fcf25f479..68bef476f 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -176,6 +176,7 @@ class SurfaceScrollView: NSView { private func synchronizeAppearance() { let scrollbarConfig = surfaceView.derivedConfig.scrollbar scrollView.hasVerticalScroller = scrollbarConfig != .never + scrollView.verticalScroller?.controlSize = .small } /// Positions the surface view to fill the currently visible rectangle. From 49bf73458b40110a28abe99953380c9b8c280219 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Mon, 10 Nov 2025 01:05:58 -0800 Subject: [PATCH 304/702] don't autohide scrollers --- macos/Sources/Ghostty/SurfaceScrollView.swift | 28 ++----------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 68bef476f..86ec355fa 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -32,7 +32,7 @@ class SurfaceScrollView: NSView { scrollView = NSScrollView() scrollView.hasVerticalScroller = false scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true + scrollView.autohidesScrollers = false scrollView.usesPredominantAxisScrolling = true // hide default background to show blur effect properly scrollView.drawsBackground = false @@ -196,7 +196,7 @@ class SurfaceScrollView: NSView { // Only update the pty if we have a valid (non-zero) content size. The content size // can be zero when this is added early to a view, or to an invisible hierarchy. // Practically, this happened in the quick terminal. - let width = surfaceContentWidth() + let width = scrollView.contentSize.width let height = surfaceView.frame.height if width > 0 && height > 0 { surfaceView.sizeDidChange(CGSize(width: width, height: height)) @@ -333,30 +333,6 @@ class SurfaceScrollView: NSView { // MARK: Calculations - /// Calculate the content width reported to the core surface - /// - /// If we have a legacy scrollbar and its not visible, then we account for that - /// in advance, because legacy scrollbars change our contentSize and force reflow - /// of our terminal which is not desirable. - /// See: https://github.com/ghostty-org/ghostty/discussions/9254 - private func surfaceContentWidth() -> CGFloat { - let contentWidth = scrollView.contentSize.width - if scrollView.hasVerticalScroller { - let style = - scrollView.verticalScroller?.scrollerStyle - ?? NSScroller.preferredScrollerStyle - // We only subtract the scrollbar width if it's hidden or not present, - // otherwise its width is already accounted for in contentSize. - if style == .legacy && (scrollView.verticalScroller?.isHidden ?? true) { - let scrollerWidth = - scrollView.verticalScroller?.frame.width - ?? NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) - return max(0, contentWidth - scrollerWidth) - } - } - return contentWidth - } - /// Calculate the appropriate document view height given a scrollbar state private func documentHeight() -> CGFloat { let contentHeight = scrollView.contentSize.height From 368f4f565a79d322c7cca749e4ceb37d6249268d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 13:19:16 -0800 Subject: [PATCH 305/702] terminal: Screen opts is a structure --- src/Surface.zig | 4 +- src/font/shaper/coretext.zig | 58 ++-- src/font/shaper/harfbuzz.zig | 32 +-- src/renderer/cell.zig | 2 +- src/renderer/link.zig | 6 +- src/terminal/Screen.zig | 364 +++++++++++++------------ src/terminal/Selection.zig | 36 +-- src/terminal/StringMap.zig | 2 +- src/terminal/Terminal.zig | 6 +- src/terminal/search/sliding_window.zig | 36 +-- 10 files changed, 282 insertions(+), 264 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index a44563ad4..f41e2f409 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5696,7 +5696,7 @@ fn testMouseSelection( .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, .screen = .{ .width = 110, .height = 110 }, }; - var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer screen.deinit(); // We hold both ctrl and alt for rectangular @@ -5765,7 +5765,7 @@ fn testMouseSelectionIsNull( .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 }, .screen = .{ .width = 110, .height = 110 }, }; - var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0); + var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer screen.deinit(); // We hold both ctrl and alt for rectangular diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index f1368679d..d73b191b8 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -598,7 +598,7 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("ABCD"); @@ -616,7 +616,7 @@ test "run iterator" { // Spaces should be part of a run { - var screen = try terminal.Screen.init(alloc, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("ABCD EFG"); @@ -633,7 +633,7 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("A😃D"); @@ -652,7 +652,7 @@ 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, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(bad); @@ -678,7 +678,7 @@ test "run iterator: empty cells with background set" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + 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"); @@ -731,7 +731,7 @@ 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, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -764,7 +764,7 @@ 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, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -791,7 +791,7 @@ test "shape inconsolata ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(">="); @@ -814,7 +814,7 @@ test "shape inconsolata ligs" { } { - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("==="); @@ -845,7 +845,7 @@ test "shape monaspace ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("==="); @@ -877,7 +877,7 @@ test "shape left-replaced lig in last run" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("!=="); @@ -909,7 +909,7 @@ test "shape left-replaced lig in early run" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("!==X"); @@ -938,7 +938,7 @@ test "shape U+3C9 with JB Mono" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("\u{03C9} foo"); @@ -969,7 +969,7 @@ test "shape emoji width" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("👍"); @@ -998,7 +998,7 @@ 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, 30, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); var page = screen.pages.pages.first.?.data; @@ -1050,7 +1050,7 @@ 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, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -1083,7 +1083,7 @@ 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, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -1111,7 +1111,7 @@ test "shape with empty cells in between" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 30, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("A"); screen.cursorRight(5); @@ -1149,7 +1149,7 @@ 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, 30, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 30, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -1187,7 +1187,7 @@ 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, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -1219,7 +1219,7 @@ test "shape selection boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("a1b2c3d4e5"); @@ -1342,7 +1342,7 @@ test "shape cursor boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("a1b2c3d4e5"); @@ -1479,7 +1479,7 @@ test "shape cursor boundary and colored emoji" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("👍🏼"); @@ -1574,7 +1574,7 @@ test "shape cell attribute change" { // Plain >= should shape into 1 run { - var screen = try terminal.Screen.init(alloc, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(">="); @@ -1594,7 +1594,7 @@ test "shape cell attribute change" { // Bold vs regular should split { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 3, .rows = 10, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(">"); try screen.setAttribute(.{ .bold = {} }); @@ -1616,7 +1616,7 @@ test "shape cell attribute change" { // Changing fg color should split { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + 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(">"); @@ -1639,7 +1639,7 @@ test "shape cell attribute change" { // Changing bg color should NOT split { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + 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(">"); @@ -1662,7 +1662,7 @@ test "shape cell attribute change" { // Same bg color should not split { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + 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(">"); @@ -1700,7 +1700,7 @@ test "shape high plane sprite font codepoint" { var testdata = try testShaper(alloc); defer testdata.deinit(); - var screen = try terminal.Screen.init(alloc, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); // U+1FB70: Vertical One Eighth Block-2 diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index b5c96797f..634afd0de 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -207,7 +207,7 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("ABCD"); @@ -225,7 +225,7 @@ test "run iterator" { // Spaces should be part of a run { - var screen = try terminal.Screen.init(alloc, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("ABCD EFG"); @@ -242,7 +242,7 @@ test "run iterator" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("A😃D"); @@ -273,7 +273,7 @@ test "run iterator: empty cells with background set" { { // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + 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"); @@ -327,7 +327,7 @@ 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, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -355,7 +355,7 @@ test "shape inconsolata ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(">="); @@ -378,7 +378,7 @@ test "shape inconsolata ligs" { } { - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("==="); @@ -409,7 +409,7 @@ test "shape monaspace ligs" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("==="); @@ -443,7 +443,7 @@ test "shape arabic forced LTR" { var testdata = try testShaperWithFont(alloc, .arabic); defer testdata.deinit(); - var screen = try terminal.Screen.init(alloc, 120, 30, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 120, .rows = 30, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(@embedFile("testdata/arabic.txt")); @@ -478,7 +478,7 @@ test "shape emoji width" { defer testdata.deinit(); { - var screen = try terminal.Screen.init(alloc, 5, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("👍"); @@ -563,7 +563,7 @@ 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, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -598,7 +598,7 @@ 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, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -704,7 +704,7 @@ 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, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -737,7 +737,7 @@ test "shape selection boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("a1b2c3d4e5"); @@ -860,7 +860,7 @@ test "shape cursor boundary" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString("a1b2c3d4e5"); @@ -1092,7 +1092,7 @@ test "shape cell attribute change" { // Plain >= should shape into 1 run { - var screen = try terminal.Screen.init(alloc, 10, 3, 0); + var screen = try terminal.Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer screen.deinit(); try screen.testWriteString(">="); diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index d8427689b..1e371b07e 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -524,7 +524,7 @@ test "Cell constraint widths" { const testing = std.testing; const alloc = testing.allocator; - var s = try terminal.Screen.init(alloc, 4, 1, 0); + var s = try terminal.Screen.init(alloc, .{ .cols = 4, .rows = 1, .max_scrollback = 0 }); defer s.deinit(); // for each case, the numbers in the comment denote expected diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 9f489ed48..40a25ea19 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -398,7 +398,7 @@ test "matchset" { const alloc = testing.allocator; // Initialize our screen - var s = try Screen.init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -456,7 +456,7 @@ test "matchset hover links" { const alloc = testing.allocator; // Initialize our screen - var s = try Screen.init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -549,7 +549,7 @@ test "matchset mods no match" { const alloc = testing.allocator; // Initialize our screen - var s = try Screen.init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5b90bf41b..c4c3bed57 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -175,6 +175,21 @@ pub const CharsetState = struct { const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset); }; +pub const Options = struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + max_scrollback: usize = 0, + + /// A simple, default terminal. If you rely on specific dimensions or + /// scrollback (or lack of) then do not use this directly. This is just + /// for callers that need some defaults. + pub const default: Options = .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }; +}; + /// Initialize a new screen. /// /// max_scrollback is the amount of scrollback to keep in bytes. This @@ -184,12 +199,15 @@ pub const CharsetState = struct { /// If max scrollback is 0, then no scrollback is kept at all. pub fn init( alloc: Allocator, - cols: size.CellCountInt, - rows: size.CellCountInt, - max_scrollback: usize, + opts: Options, ) !Screen { // Initialize our backing pages. - var pages = try PageList.init(alloc, cols, rows, max_scrollback); + var pages = try PageList.init( + alloc, + opts.cols, + opts.rows, + opts.max_scrollback, + ); errdefer pages.deinit(); // Create our tracked pin for the cursor. @@ -200,7 +218,7 @@ pub fn init( return .{ .alloc = alloc, .pages = pages, - .no_scrollback = max_scrollback == 0, + .no_scrollback = opts.max_scrollback == 0, .cursor = .{ .x = 0, .y = 0, @@ -3028,7 +3046,7 @@ test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); @@ -3042,7 +3060,7 @@ test "Screen read and write newline" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); @@ -3056,7 +3074,7 @@ test "Screen read and write scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 2, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("hello\nworld\ntest"); @@ -3076,7 +3094,7 @@ test "Screen read and write no scrollback small" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 2, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("hello\nworld\ntest"); @@ -3096,7 +3114,7 @@ test "Screen read and write no scrollback large" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 2, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); for (0..1_000) |i| { @@ -3117,13 +3135,13 @@ test "Screen cursorCopy x/y" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); s.cursorAbsolute(2, 3); try testing.expect(s.cursor.x == 2); try testing.expect(s.cursor.y == 3); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); try s2.cursorCopy(s.cursor, .{}); try testing.expect(s2.cursor.x == 2); @@ -3141,10 +3159,10 @@ test "Screen cursorCopy style deref" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; @@ -3163,10 +3181,10 @@ test "Screen cursorCopy style deref new page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, 10, 10, 2048); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 2048 }); defer s2.deinit(); // We need to get the cursor on a new page. @@ -3236,11 +3254,11 @@ test "Screen cursorCopy style copy" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; try s2.cursorCopy(s.cursor, .{}); @@ -3252,10 +3270,10 @@ test "Screen cursorCopy hyperlink deref" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; @@ -3274,10 +3292,10 @@ test "Screen cursorCopy hyperlink deref new page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, 10, 10, 2048); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 2048 }); defer s2.deinit(); // We need to get the cursor on a new page. @@ -3347,7 +3365,7 @@ test "Screen cursorCopy hyperlink copy" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); // Create a hyperlink for the cursor. @@ -3355,7 +3373,7 @@ test "Screen cursorCopy hyperlink copy" { try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; @@ -3372,7 +3390,7 @@ test "Screen cursorCopy hyperlink copy disabled" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 10, 10, 0); + var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); // Create a hyperlink for the cursor. @@ -3380,7 +3398,7 @@ test "Screen cursorCopy hyperlink copy disabled" { try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); - var s2 = try Screen.init(alloc, 10, 10, 0); + var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; @@ -3397,7 +3415,7 @@ test "Screen style basics" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); @@ -3419,7 +3437,7 @@ test "Screen style reset to default" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); @@ -3439,7 +3457,7 @@ test "Screen style reset with unset" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); @@ -3459,7 +3477,7 @@ test "Screen clearRows active one line" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("hello, world"); @@ -3474,7 +3492,7 @@ test "Screen clearRows active multi line" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("hello\nworld"); @@ -3490,7 +3508,7 @@ test "Screen clearRows active styled line" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); @@ -3515,7 +3533,7 @@ test "Screen clearRows protected" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("UNPROTECTED"); @@ -3543,7 +3561,7 @@ test "Screen eraseRows history" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 5, 5, 1000); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("1\n2\n3\n4\n5\n6"); @@ -3577,7 +3595,7 @@ test "Screen eraseRows history with more lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 5, 5, 1000); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 1000 }); defer s.deinit(); try s.testWriteString("A\nB\nC\n1\n2\n3\n4\n5\n6"); @@ -3611,7 +3629,7 @@ test "Screen eraseRows active partial" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1\n2\n3"); @@ -3640,7 +3658,7 @@ test "Screen: clearPrompt" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); // Set one of the rows to be a prompt @@ -3661,7 +3679,7 @@ test "Screen: clearPrompt continuation" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 4, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 0 }); defer s.deinit(); // Set one of the rows to be a prompt followed by a continuation row @@ -3683,7 +3701,7 @@ test "Screen: clearPrompt consecutive inputs" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); // Set both rows to be inputs @@ -3704,7 +3722,7 @@ test "Screen: clearPrompt no prompt" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -3722,7 +3740,7 @@ test "Screen: cursorDown across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -3774,7 +3792,7 @@ test "Screen: cursorUp across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -3821,7 +3839,7 @@ test "Screen: cursorAbsolute across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -3876,7 +3894,7 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -3943,7 +3961,7 @@ test "Screen: scrolling" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -3985,7 +4003,7 @@ test "Screen: scrolling with a single-row screen no scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 1, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD"); @@ -4005,7 +4023,7 @@ test "Screen: scrolling with a single-row screen with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 1, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD"); @@ -4035,7 +4053,7 @@ test "Screen: scrolling across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4064,7 +4082,7 @@ test "Screen: scroll down from 0" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4083,7 +4101,7 @@ test "Screen: scrollback various cases" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); try s.cursorDownScroll(); @@ -4164,7 +4182,7 @@ test "Screen: scrollback with multi-row delta" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); @@ -4190,7 +4208,7 @@ test "Screen: scrollback empty" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 50); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 50 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); s.scroll(.{ .delta_row = 1 }); @@ -4205,7 +4223,7 @@ test "Screen: scrollback doesn't move viewport if not at bottom" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); @@ -4240,7 +4258,7 @@ test "Screen: scrolling moves selection" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4319,7 +4337,7 @@ test "Screen: scrolling moves viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 1); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4344,7 +4362,7 @@ test "Screen: scrolling when viewport is pruned" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 215, 3, 1); + var s = try init(alloc, .{ .cols = 215, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); // Write some to create scrollback and move back into our scrollback. @@ -4370,7 +4388,7 @@ test "Screen: scroll and clear full screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 5); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4397,7 +4415,7 @@ test "Screen: scroll and clear partial screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 5); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); @@ -4424,7 +4442,7 @@ test "Screen: scroll and clear empty screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 5); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.scrollClear(); { @@ -4443,7 +4461,7 @@ test "Screen: scroll and clear ignore blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); try s.scrollClear(); @@ -4486,7 +4504,7 @@ test "Screen: scroll above same page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4545,7 +4563,7 @@ test "Screen: scroll above same page but cursor on previous page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); defer s.deinit(); // We need to get the cursor to a new page @@ -4626,7 +4644,7 @@ test "Screen: scroll above same page but cursor on previous page last row" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); defer s.deinit(); // We need to get the cursor to a new page @@ -4716,7 +4734,7 @@ test "Screen: scroll above creates new page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); // We need to get the cursor to a new page @@ -4788,7 +4806,7 @@ test "Screen: scroll above with cursor on non-final row" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 4, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 10 }); defer s.deinit(); // Get the cursor to be 2 rows above a new page @@ -4865,7 +4883,7 @@ test "Screen: scroll above no scrollback bottom of page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const first_page_size = s.pages.pages.first.?.data.capacity.rows; @@ -4930,7 +4948,7 @@ test "Screen: clone" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { @@ -4972,7 +4990,7 @@ test "Screen: clone partial" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { @@ -5001,7 +5019,7 @@ test "Screen: clone partial cursor out of bounds" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 10); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { @@ -5034,7 +5052,7 @@ test "Screen: clone contains full selection" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5071,7 +5089,7 @@ test "Screen: clone contains none of selection" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5098,7 +5116,7 @@ test "Screen: clone contains selection start cutoff" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5135,7 +5153,7 @@ test "Screen: clone contains selection end cutoff" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5172,7 +5190,7 @@ test "Screen: clone contains selection end cutoff reversed" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5209,7 +5227,7 @@ test "Screen: clone contains subset of selection" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 4, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); @@ -5246,7 +5264,7 @@ test "Screen: clone contains subset of rectangle selection" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 4, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); @@ -5285,7 +5303,7 @@ test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5322,7 +5340,7 @@ test "Screen: clone empty viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); { @@ -5344,7 +5362,7 @@ test "Screen: clone one line viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABC"); @@ -5367,7 +5385,7 @@ test "Screen: clone empty active" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); { @@ -5389,7 +5407,7 @@ test "Screen: clone one line active with extra space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABC"); @@ -5412,7 +5430,7 @@ test "Screen: clear history with no history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); try testing.expect(s.pages.viewport == .active); @@ -5436,7 +5454,7 @@ test "Screen: clear history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); try testing.expect(s.pages.viewport == .active); @@ -5470,7 +5488,7 @@ test "Screen: clear above cursor" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); s.clearRows( @@ -5497,7 +5515,7 @@ test "Screen: clear above cursor with history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 3); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); @@ -5525,7 +5543,7 @@ test "Screen: resize (no reflow) more rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5543,7 +5561,7 @@ test "Screen: resize (no reflow) less rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5566,7 +5584,7 @@ test "Screen: resize (no reflow) less rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str); @@ -5601,7 +5619,7 @@ test "Screen: resize (no reflow) more rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str); @@ -5636,7 +5654,7 @@ test "Screen: resize (no reflow) more cols" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5653,7 +5671,7 @@ test "Screen: resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5671,7 +5689,7 @@ test "Screen: resize (no reflow) more rows with scrollback cursor end" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 7, 3, 2); + var s = try init(alloc, .{ .cols = 7, .rows = 3, .max_scrollback = 2 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -5688,7 +5706,7 @@ test "Screen: resize (no reflow) less rows with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 7, 3, 2); + var s = try init(alloc, .{ .cols = 7, .rows = 3, .max_scrollback = 2 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -5707,7 +5725,7 @@ test "Screen: resize (no reflow) less rows with empty trailing" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); @@ -5731,7 +5749,7 @@ test "Screen: resize (no reflow) more rows with soft wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 3, 3); + var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); const str = "1A2B\n3C4E\n5F6G"; try s.testWriteString(str); @@ -5772,7 +5790,7 @@ test "Screen: resize more rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5799,7 +5817,7 @@ test "Screen: resize more rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 10); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5826,7 +5844,7 @@ test "Screen: resize more rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -5871,7 +5889,7 @@ test "Screen: resize more cols no reflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -5900,7 +5918,7 @@ test "Screen: resize more cols perfect split" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); @@ -5918,7 +5936,7 @@ test "Screen: resize (no reflow) more cols with scrollback scrolled up" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); @@ -5951,7 +5969,7 @@ test "Screen: resize (no reflow) less cols with scrollback scrolled up" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); @@ -5995,7 +6013,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); // Set one of the rows to be a prompt @@ -6036,7 +6054,7 @@ test "Screen: resize more cols with reflow that fits full width" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6076,7 +6094,7 @@ test "Screen: resize more cols with reflow that ends in newline" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 6, 3, 0); + var s = try init(alloc, .{ .cols = 6, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6121,7 +6139,7 @@ test "Screen: resize more cols with reflow that forces more wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6162,7 +6180,7 @@ test "Screen: resize more cols with reflow that unwraps multiple times" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); @@ -6203,7 +6221,7 @@ test "Screen: resize more cols with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; try s.testWriteString(str); @@ -6247,7 +6265,7 @@ test "Screen: resize more cols with reflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 3, 5); + var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABC\n2DEF\n3ABC\n4DEF"; try s.testWriteString(str); @@ -6288,7 +6306,7 @@ test "Screen: resize more rows and cols with wrapping" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 4, 0); + var s = try init(alloc, .{ .cols = 2, .rows = 4, .max_scrollback = 0 }); defer s.deinit(); const str = "1A2B\n3C4D"; try s.testWriteString(str); @@ -6321,7 +6339,7 @@ test "Screen: resize less rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6352,7 +6370,7 @@ test "Screen: resize less rows moving cursor" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6392,7 +6410,7 @@ test "Screen: resize less rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 10); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6415,7 +6433,7 @@ test "Screen: resize less rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6446,7 +6464,7 @@ test "Screen: resize less rows with full scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 3); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6486,7 +6504,7 @@ test "Screen: resize less cols no reflow" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1AB\n2EF\n3IJ"; try s.testWriteString(str); @@ -6515,7 +6533,7 @@ test "Screen: resize less cols with reflow but row space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str); @@ -6553,7 +6571,7 @@ test "Screen: resize less cols with reflow with trimmed rows" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6577,7 +6595,7 @@ test "Screen: resize less cols with reflow with trimmed rows and scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 1); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6601,7 +6619,7 @@ test "Screen: resize less cols with reflow previously wrapped" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "3IJKL4ABCD5EFGH"; try s.testWriteString(str); @@ -6634,7 +6652,7 @@ test "Screen: resize less cols with reflow and scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str); @@ -6667,7 +6685,7 @@ test "Screen: resize less cols with reflow previously wrapped and scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 2); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 2 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; try s.testWriteString(str); @@ -6721,7 +6739,7 @@ test "Screen: resize less cols with scrollback keeps cursor row" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 5); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str); @@ -6750,7 +6768,7 @@ test "Screen: resize more rows, less cols with reflow with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 3); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; try s.testWriteString(str); @@ -6791,7 +6809,7 @@ test "Screen: resize more rows then shrink again" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 10); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); const str = "1ABC"; try s.testWriteString(str); @@ -6840,7 +6858,7 @@ test "Screen: resize less cols to eliminate wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 1, 0); + var s = try init(alloc, .{ .cols = 2, .rows = 1, .max_scrollback = 0 }); defer s.deinit(); const str = "😀"; try s.testWriteString(str); @@ -6875,7 +6893,7 @@ test "Screen: resize less cols to wrap wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 3, 0); + var s = try init(alloc, .{ .cols = 3, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "x😀"; try s.testWriteString(str); @@ -6914,7 +6932,7 @@ test "Screen: resize less cols to eliminate wide char with row space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 2, 0); + var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); const str = "😀"; try s.testWriteString(str); @@ -6947,7 +6965,7 @@ test "Screen: resize more cols with wide spacer head" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 2, 0); + var s = try init(alloc, .{ .cols = 3, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); const str = " 😀"; try s.testWriteString(str); @@ -7000,7 +7018,7 @@ test "Screen: resize more cols with wide spacer head multiple lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 3, 0); + var s = try init(alloc, .{ .cols = 3, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "xxxyy😀"; try s.testWriteString(str); @@ -7051,7 +7069,7 @@ test "Screen: resize more cols requiring a wide spacer head" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 2, 0); + var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); const str = "xx😀"; try s.testWriteString(str); @@ -7102,7 +7120,7 @@ test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); @@ -7122,7 +7140,7 @@ test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); { @@ -7158,7 +7176,7 @@ test "Screen: selectLine" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); @@ -7239,7 +7257,7 @@ test "Screen: selectLine across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); @@ -7265,7 +7283,7 @@ test "Screen: selectLine across full soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD2EFGH\n3IJKL"); @@ -7290,7 +7308,7 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); @@ -7350,7 +7368,7 @@ test "Screen: selectLine disabled whitespace trimming" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); @@ -7399,7 +7417,7 @@ test "Screen: selectLine with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 3, 5); + var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -7443,7 +7461,7 @@ test "Screen: selectLine semantic prompt boundary" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteSemanticString("ABCDE\n", .unknown); try s.testWriteSemanticString("A ", .prompt); @@ -7492,7 +7510,7 @@ test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); @@ -7607,7 +7625,7 @@ test "Screen: selectWord across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 1234012\n 123"); @@ -7673,7 +7691,7 @@ test "Screen: selectWord whitespace across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1 1\n 123"); @@ -7754,7 +7772,7 @@ test "Screen: selectWord with character boundary" { }; for (cases) |case| { - var s = try init(alloc, 20, 10, 0); + var s = try init(alloc, .{ .cols = 20, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(case); @@ -7834,7 +7852,7 @@ test "Screen: selectOutput" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 15, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); // zig fmt: off @@ -7908,7 +7926,7 @@ test "Screen: selectOutput" { // input / prompt at y = 0, pt.y = 0 { s.deinit(); - s = try init(alloc, 10, 5, 0); + s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); try s.testWriteSemanticString("$ ", .prompt); try s.testWriteSemanticString("input1\n", .input); try s.testWriteSemanticString("output1\n", .command); @@ -7924,7 +7942,7 @@ test "Screen: selectPrompt basics" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 15, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); // zig fmt: off @@ -7999,7 +8017,7 @@ test "Screen: selectPrompt prompt at start" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 15, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); // zig fmt: off @@ -8043,7 +8061,7 @@ test "Screen: selectPrompt prompt at end" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 15, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); // zig fmt: off @@ -8087,7 +8105,7 @@ test "Screen: promptPath" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 15, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); // zig fmt: off @@ -8162,7 +8180,7 @@ test "Screen: selectionString basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -8187,7 +8205,7 @@ test "Screen: selectionString start outside of written area" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -8212,7 +8230,7 @@ test "Screen: selectionString end outside of written area" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -8237,7 +8255,7 @@ test "Screen: selectionString trim space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1AB \n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -8274,7 +8292,7 @@ test "Screen: selectionString trim empty line" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1AB \n\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -8311,7 +8329,7 @@ test "Screen: selectionString soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); @@ -8336,7 +8354,7 @@ test "Screen: selectionString wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1A⚡"; try s.testWriteString(str); @@ -8391,7 +8409,7 @@ test "Screen: selectionString wide char with header" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 3, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABC⚡"; try s.testWriteString(str); @@ -8417,7 +8435,7 @@ test "Screen: selectionString empty with soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 2, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); // Let me describe the situation that caused this because this @@ -8450,7 +8468,7 @@ test "Screen: selectionString with zero width joiner" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 1, 0); + var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 0 }); defer s.deinit(); const str = "👨‍"; // this has a ZWJ try s.testWriteString(str); @@ -8486,7 +8504,7 @@ test "Screen: selectionString, rectangle, basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 30, 5, 0); + var s = try init(alloc, .{ .cols = 30, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -8519,7 +8537,7 @@ test "Screen: selectionString, rectangle, w/EOL" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 30, 5, 0); + var s = try init(alloc, .{ .cols = 30, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -8554,7 +8572,7 @@ test "Screen: selectionString, rectangle, more complex w/breaks" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 30, 8, 0); + var s = try init(alloc, .{ .cols = 30, .rows = 8, .max_scrollback = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -8593,7 +8611,7 @@ test "Screen: selectionString multi-page" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 3, 2048); + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 2048 }); defer s.deinit(); const first_page_size = s.pages.pages.first.?.data.capacity.rows; @@ -8627,7 +8645,7 @@ test "Screen: lineIterator" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH"; try s.testWriteString(str); @@ -8658,7 +8676,7 @@ test "Screen: lineIterator soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3ABCD"; try s.testWriteString(str); @@ -8690,7 +8708,7 @@ test "Screen: hyperlink start/end" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try testing.expect(s.cursor.hyperlink_id == 0); { @@ -8717,7 +8735,7 @@ test "Screen: hyperlink reuse" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try testing.expect(s.cursor.hyperlink_id == 0); @@ -8755,7 +8773,7 @@ test "Screen: hyperlink cursor state on resize" { // it may be invalid one day. It's here to document/verify the // current behavior. - var s = try init(alloc, 5, 10, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); // Start a hyperlink @@ -8786,7 +8804,7 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 0); + var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); // Start a hyperlink with a URI that just barely fits in the string alloc. @@ -8820,7 +8838,7 @@ test "Screen: adjustCapacity cursor style ref count" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); @@ -8854,7 +8872,7 @@ test "Screen: adjustCapacity cursor hyperlink exceeds string alloc size" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 0); + var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); // Start a hyperlink with a URI that just barely fits in the string alloc. @@ -8896,7 +8914,7 @@ test "Screen: adjustCapacity cursor style exceeds style set capacity" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 80, 24, 1000); + var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 267f223d5..59cb4ef50 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -451,7 +451,7 @@ pub fn adjust( test "Selection: adjust right" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A1234\nB5678\nC1234\nD5678"); @@ -518,7 +518,7 @@ test "Selection: adjust right" { test "Selection: adjust left" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A1234\nB5678\nC1234\nD5678"); @@ -567,7 +567,7 @@ test "Selection: adjust left" { test "Selection: adjust left skips blanks" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A1234\nB5678\nC12\nD56"); @@ -616,7 +616,7 @@ test "Selection: adjust left skips blanks" { test "Selection: adjust up" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A\nB\nC\nD\nE"); @@ -663,7 +663,7 @@ test "Selection: adjust up" { test "Selection: adjust down" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A\nB\nC\nD\nE"); @@ -710,7 +710,7 @@ test "Selection: adjust down" { test "Selection: adjust down with not full screen" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A\nB\nC"); @@ -738,7 +738,7 @@ test "Selection: adjust down with not full screen" { test "Selection: adjust home" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A\nB\nC"); @@ -766,7 +766,7 @@ test "Selection: adjust home" { test "Selection: adjust end with not full screen" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A\nB\nC"); @@ -794,7 +794,7 @@ test "Selection: adjust end with not full screen" { test "Selection: adjust beginning of line" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 8, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 8, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A12 B34\nC12 D34"); @@ -864,7 +864,7 @@ test "Selection: adjust beginning of line" { test "Selection: adjust end of line" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 8, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 8, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("A12 B34\nC12 D34"); @@ -934,7 +934,7 @@ test "Selection: order, standard" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 100, 100, 1); + var s = try Screen.init(alloc, .{ .cols = 100, .rows = 100, .max_scrollback = 1 }); defer s.deinit(); { @@ -998,7 +998,7 @@ test "Selection: order, rectangle" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 100, 100, 1); + var s = try Screen.init(alloc, .{ .cols = 100, .rows = 100, .max_scrollback = 1 }); defer s.deinit(); // Conventions: @@ -1110,7 +1110,7 @@ test "Selection: order, rectangle" { test "topLeft" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); { // forward @@ -1173,7 +1173,7 @@ test "topLeft" { test "bottomRight" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); { // forward @@ -1236,7 +1236,7 @@ test "bottomRight" { test "ordered" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); { // forward @@ -1317,7 +1317,7 @@ test "ordered" { test "Selection: contains" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 10, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); { const sel = Selection.init( @@ -1363,7 +1363,7 @@ test "Selection: contains" { test "Selection: contains, rectangle" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 15, 15, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 15, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); { const sel = Selection.init( @@ -1425,7 +1425,7 @@ test "Selection: contains, rectangle" { test "Selection: containedRow" { const testing = std.testing; - var s = try Screen.init(testing.allocator, 10, 5, 0); + var s = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); { diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig index ae34f5fc8..4ac47eeab 100644 --- a/src/terminal/StringMap.zig +++ b/src/terminal/StringMap.zig @@ -109,7 +109,7 @@ test "StringMap searchIterator" { defer re.deinit(); // Initialize our screen - var s = try Screen.init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8013110b7..d9ad62ae1 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -39,7 +39,7 @@ const log = std.log.scoped(.terminal); const TABSTOP_INTERVAL = 8; /// Screen type is an enum that tracks whether a screen is primary or alternate. -pub const ScreenType = enum { +pub const ScreenType = enum(u1) { primary, alternate, }; @@ -225,8 +225,8 @@ pub fn init( .cols = cols, .rows = rows, .active_screen = .primary, - .screen = try .init(alloc, cols, rows, opts.max_scrollback), - .secondary_screen = try .init(alloc, cols, rows, 0), + .screen = try .init(alloc, .{ .cols = cols, .rows = rows, .max_scrollback = opts.max_scrollback }), + .secondary_screen = try .init(alloc, .{ .cols = cols, .rows = rows, .max_scrollback = 0 }), .tabstops = try .init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ .top = 0, diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 4a2c3eb7d..db60a6670 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -494,7 +494,7 @@ test "SlidingWindow single append" { var w: SlidingWindow = try .init(alloc, .forward, "boo!"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("hello. boo! hello. boo!"); @@ -537,7 +537,7 @@ test "SlidingWindow single append no match" { var w: SlidingWindow = try .init(alloc, .forward, "nope!"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("hello. boo! hello. boo!"); @@ -561,7 +561,7 @@ test "SlidingWindow two pages" { var w: SlidingWindow = try .init(alloc, .forward, "boo!"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 1000); + 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 @@ -614,7 +614,7 @@ test "SlidingWindow two pages match across boundary" { var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 1000); + 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 @@ -658,7 +658,7 @@ test "SlidingWindow two pages no match across boundary with newline" { var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 1000); + 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 @@ -691,7 +691,7 @@ test "SlidingWindow two pages no match across boundary with newline reverse" { var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 1000); + 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 @@ -721,7 +721,7 @@ test "SlidingWindow two pages no match prunes first page" { var w: SlidingWindow = try .init(alloc, .forward, "nope!"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 1000); + 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 @@ -753,7 +753,7 @@ test "SlidingWindow two pages no match keeps both pages" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + 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 @@ -796,7 +796,7 @@ test "SlidingWindow single append across circular buffer boundary" { var w: SlidingWindow = try .init(alloc, .forward, "abc"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); @@ -851,7 +851,7 @@ test "SlidingWindow single append match on boundary" { var w: SlidingWindow = try .init(alloc, .forward, "abcd"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); @@ -909,7 +909,7 @@ test "SlidingWindow single append reversed" { var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("hello. boo! hello. boo!"); @@ -952,7 +952,7 @@ test "SlidingWindow single append no match reversed" { var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("hello. boo! hello. boo!"); @@ -976,7 +976,7 @@ test "SlidingWindow two pages reversed" { var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 1000); + 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 @@ -1029,7 +1029,7 @@ test "SlidingWindow two pages match across boundary reversed" { var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 1000); + 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 @@ -1074,7 +1074,7 @@ test "SlidingWindow two pages no match prunes first page reversed" { var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 1000); + 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 @@ -1106,7 +1106,7 @@ test "SlidingWindow two pages no match keeps both pages reversed" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, 80, 24, 1000); + 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 @@ -1149,7 +1149,7 @@ test "SlidingWindow single append across circular buffer boundary reversed" { var w: SlidingWindow = try .init(alloc, .reverse, "abc"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); @@ -1205,7 +1205,7 @@ test "SlidingWindow single append match on boundary reversed" { var w: SlidingWindow = try .init(alloc, .reverse, "abcd"); defer w.deinit(); - var s = try Screen.init(alloc, 80, 24, 0); + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); From 3aff5f0aff4d725e22e28aff1afb3a9f08e63371 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 13:19:16 -0800 Subject: [PATCH 306/702] ScreenSet --- src/Surface.zig | 59 +++---- src/apprt/embedded.zig | 4 +- src/inspector/Inspector.zig | 4 +- src/renderer/generic.zig | 6 +- src/renderer/link.zig | 6 +- src/terminal/Screen.zig | 21 ++- src/terminal/ScreenSet.zig | 106 +++++++++++ src/terminal/Terminal.zig | 222 +++++++++++++----------- src/terminal/formatter.zig | 20 +-- src/terminal/kitty/graphics_exec.zig | 2 +- src/terminal/kitty/graphics_storage.zig | 52 +++--- src/terminal/kitty/graphics_unicode.zig | 6 +- src/terminal/main.zig | 2 +- src/terminal/search/screen.zig | 8 +- src/terminal/stream_readonly.zig | 12 +- src/termio/Termio.zig | 40 ++--- src/termio/stream_handler.zig | 6 +- 17 files changed, 357 insertions(+), 219 deletions(-) create mode 100644 src/terminal/ScreenSet.zig diff --git a/src/Surface.zig b/src/Surface.zig index f41e2f409..db8b4474e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -186,7 +186,7 @@ const Mouse = struct { /// The point at which the left mouse click happened. This is in screen /// coordinates so that scrolling preserves the location. left_click_pin: ?*terminal.Pin = null, - left_click_screen: terminal.ScreenType = .primary, + left_click_screen: terminal.ScreenSet.Key = .primary, /// The starting xpos/ypos of the left click. Note that if scrolling occurs, /// these will point to different "cells", but the xpos/ypos will stay @@ -1065,7 +1065,7 @@ fn selectionScrollTick(self: *Surface) !void { // If our screen changed while this is happening, we stop our // selection scroll. - if (self.mouse.left_click_screen != t.active_screen) { + if (self.mouse.left_click_screen != t.screens.active_key) { self.io.queueMessage( .{ .selection_scroll = false }, .locked, @@ -1703,7 +1703,7 @@ pub fn dumpTextLocked( // If our bottom right pin is before the viewport, then we can't // possibly have this text be within the viewport. const vp_tl_pin = self.io.terminal.screen.pages.getTopLeft(.viewport); - const br_pin = sel.bottomRight(&self.io.terminal.screen); + const br_pin = sel.bottomRight(self.io.terminal.screen); if (br_pin.before(vp_tl_pin)) break :viewport null; // If our top-left pin is after the viewport, then we can't possibly @@ -1714,7 +1714,7 @@ pub fn dumpTextLocked( log.warn("viewport bottom-right pin not found, bug?", .{}); break :viewport null; }; - const tl_pin = sel.topLeft(&self.io.terminal.screen); + const tl_pin = sel.topLeft(self.io.terminal.screen); if (vp_br_pin.before(tl_pin)) break :viewport null; // We established that our top-left somewhere before the viewport @@ -1984,7 +1984,7 @@ fn copySelectionToClipboards( var contents: std.ArrayList(apprt.ClipboardContent) = .empty; switch (format) { .plain => { - var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts); + var formatter: ScreenFormatter = .init(self.io.terminal.screen, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); try contents.append(alloc, .{ @@ -1994,7 +1994,7 @@ fn copySelectionToClipboards( }, .vt => { - var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts: { + var formatter: ScreenFormatter = .init(self.io.terminal.screen, opts: { var copy = opts; copy.emit = .vt; break :opts copy; @@ -2011,7 +2011,7 @@ fn copySelectionToClipboards( }, .html => { - var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts: { + var formatter: ScreenFormatter = .init(self.io.terminal.screen, opts: { var copy = opts; copy.emit = .html; break :opts copy; @@ -2029,7 +2029,7 @@ fn copySelectionToClipboards( .mixed => { // First, generate plain text with codepoint mappings applied - var formatter: ScreenFormatter = .init(&self.io.terminal.screen, opts); + var formatter: ScreenFormatter = .init(self.io.terminal.screen, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); try contents.append(alloc, .{ @@ -2039,7 +2039,7 @@ fn copySelectionToClipboards( assert(aw.written().len == 0); // Second, generate HTML without codepoint mappings - formatter = .init(&self.io.terminal.screen, opts: { + formatter = .init(self.io.terminal.screen, opts: { var copy = opts; copy.emit = .html; @@ -3098,7 +3098,7 @@ pub fn scrollCallback( // we convert to cursor keys. This only happens if we're: // (1) alt screen (2) no explicit mouse reporting and (3) alt // scroll mode enabled. - if (self.io.terminal.active_screen == .alternate and + if (self.io.terminal.screens.active_key == .alternate and self.io.terminal.flags.mouse_event == .none and self.io.terminal.modes.get(.mouse_alternate_scroll)) { @@ -3506,7 +3506,7 @@ pub fn mouseButtonCallback( { const pos = try self.rt_surface.getCursorPos(); const point = self.posToViewport(pos.x, pos.y); - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screen; const p = screen.pages.pin(.{ .viewport = point }) orelse { log.warn("failed to get pin for clicked point", .{}); return false; @@ -3681,7 +3681,7 @@ pub fn mouseButtonCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screen; const pos = try self.rt_surface.getCursorPos(); const pin = pin: { @@ -3717,14 +3717,15 @@ pub fn mouseButtonCallback( } if (self.mouse.left_click_pin) |prev| { - const pin_screen = t.getScreen(self.mouse.left_click_screen); - pin_screen.pages.untrackPin(prev); + if (t.screens.get(self.mouse.left_click_screen)) |pin_screen| { + pin_screen.pages.untrackPin(prev); + } self.mouse.left_click_pin = null; } // Store it self.mouse.left_click_pin = pin; - self.mouse.left_click_screen = t.active_screen; + self.mouse.left_click_screen = t.screens.active_key; self.mouse.left_click_xpos = pos.x; self.mouse.left_click_ypos = pos.y; @@ -3808,7 +3809,7 @@ pub fn mouseButtonCallback( defer self.renderer_state.mutex.unlock(); // Get our viewport pin - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screen; const pin = pin: { const pos = try self.rt_surface.getCursorPos(); const pt_viewport = self.posToViewport(pos.x, pos.y); @@ -3911,7 +3912,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { // Click to move cursor only works on the primary screen where prompts // exist. This means that alt screen multiplexers like tmux will not // support this feature. It is just too messy. - if (t.active_screen != .primary) return; + if (t.screens.active_key != .primary) return; // This flag is only set if we've seen at least one semantic prompt // OSC sequence. If we've never seen that sequence, we can't possibly @@ -3964,7 +3965,7 @@ fn linkAtPos( terminal.Selection, } { // Convert our cursor position to a screen point. - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screen; const mouse_pin: terminal.Pin = mouse_pin: { const point = self.posToViewport(pos.x, pos.y); const pin = screen.pages.pin(.{ .viewport = point }) orelse { @@ -4237,7 +4238,7 @@ pub fn cursorPosCallback( insp.mouse.last_xpos = pos.x; insp.mouse.last_ypos = pos.y; - const screen = &self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screen; insp.mouse.last_point = screen.pages.pin(.{ .viewport = .{ .x = pos_vp.x, .y = pos_vp.y, @@ -4303,7 +4304,7 @@ pub fn cursorPosCallback( // invalidate our pin or mouse state because if the screen switches // back then we can continue our selection. const t: *terminal.Terminal = self.renderer_state.terminal; - if (self.mouse.left_click_screen != t.active_screen) break :select; + if (self.mouse.left_click_screen != t.screens.active_key) break :select; // All roads lead to requiring a re-render at this point. try self.queueRender(); @@ -4328,7 +4329,7 @@ pub fn cursorPosCallback( } // Convert to points - const screen = &t.screen; + const screen: *terminal.Screen = t.screen; const pin = screen.pages.pin(.{ .viewport = .{ .x = pos_vp.x, @@ -4357,7 +4358,7 @@ fn dragLeftClickDouble( self: *Surface, drag_pin: terminal.Pin, ) !void { - const screen = &self.io.terminal.screen; + const screen: *terminal.Screen = self.io.terminal.screen; const click_pin = self.mouse.left_click_pin.?.*; // Get the word closest to our starting click. @@ -4397,7 +4398,7 @@ fn dragLeftClickTriple( self: *Surface, drag_pin: terminal.Pin, ) !void { - const screen = &self.io.terminal.screen; + const screen: *terminal.Screen = self.io.terminal.screen; const click_pin = self.mouse.left_click_pin.?.*; // Get the line selection under our current drag point. If there isn't a @@ -4930,7 +4931,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - if (self.io.terminal.active_screen == .alternate) return false; + if (self.io.terminal.screens.active_key == .alternate) return false; } self.io.queueMessage(.{ @@ -4966,7 +4967,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const sel = self.io.terminal.screen.selection orelse return false; - const tl = sel.topLeft(&self.io.terminal.screen); + const tl = sel.topLeft(self.io.terminal.screen); self.io.terminal.screen.scroll(.{ .pin = tl }); } @@ -5220,7 +5221,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - const screen = &self.io.terminal.screen; + const screen: *terminal.Screen = self.io.terminal.screen; const sel = if (screen.selection) |*sel| sel else { // If we don't have a selection we do not perform this // action, allowing the keybind to fall through to the @@ -5340,7 +5341,7 @@ fn writeScreenFile( .history => history: { // We do not support this for alternate screens // because they don't have scrollback anyways. - if (self.io.terminal.active_screen == .alternate) { + if (self.io.terminal.screens.active_key == .alternate) { break :history null; } @@ -5371,7 +5372,7 @@ fn writeScreenFile( }; const ScreenFormatter = terminal.formatter.ScreenFormatter; - var formatter: ScreenFormatter = .init(&self.io.terminal.screen, .{ + var formatter: ScreenFormatter = .init(self.io.terminal.screen, .{ .emit = switch (write_screen.emit) { .plain => .plain, .vt => .vt, @@ -5384,7 +5385,7 @@ fn writeScreenFile( .palette = &self.io.terminal.colors.palette.current, }); formatter.content = .{ .selection = sel.ordered( - &self.io.terminal.screen, + self.io.terminal.screen, .forward, ) }; try formatter.format(buf_writer); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 1faa0b9c6..1a713310f 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1578,7 +1578,7 @@ pub const CAPI = struct { defer surface.core_surface.renderer_state.mutex.unlock(); const core_sel = sel.core( - &surface.core_surface.renderer_state.terminal.screen, + surface.core_surface.renderer_state.terminal.screen, ) orelse return false; return readTextLocked(surface, core_sel, result); @@ -2137,7 +2137,7 @@ pub const CAPI = struct { // Get our word selection const sel = sel: { - const screen = &surface.renderer_state.terminal.screen; + const screen: *terminal.Screen = surface.renderer_state.terminal.screen; const pos = try ptr.getCursorPos(); const pt_viewport = surface.posToViewport(pos.x, pos.y); const pin = screen.pages.pin(.{ diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 92da5a362..0f2371400 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -304,7 +304,7 @@ fn renderScreenWindow(self: *Inspector) void { )) return; const t = self.surface.renderer_state.terminal; - const screen = &t.screen; + const screen: *terminal.Screen = t.screen; { _ = cimgui.c.igBeginTable( @@ -324,7 +324,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(t.active_screen).ptr); + cimgui.c.igText("%s", @tagName(t.screens.active_key).ptr); } } } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 0b4c55896..905261b9f 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1066,7 +1066,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { bg: terminal.color.RGB, fg: terminal.color.RGB, screen: terminal.Screen, - screen_type: terminal.ScreenType, + screen_type: terminal.ScreenSet.Key, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_color: ?terminal.color.RGB, @@ -1207,7 +1207,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .bg = bg, .fg = fg, .screen = screen_copy, - .screen_type = state.terminal.active_screen, + .screen_type = state.terminal.screens.active_key, .mouse = state.mouse, .preedit = preedit, .cursor_color = state.terminal.colors.cursor.get(), @@ -2317,7 +2317,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, wants_rebuild: bool, screen: *terminal.Screen, - screen_type: terminal.ScreenType, + screen_type: terminal.ScreenSet.Key, mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 40a25ea19..ec4000f65 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -609,7 +609,7 @@ test "matchset osc8" { // Initialize our terminal var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - const s = &t.screen; + const s: *terminal.Screen = t.screen; try t.printString("ABC"); try t.screen.startHyperlink("http://example.com", null); @@ -624,7 +624,7 @@ test "matchset osc8" { { var match = try set.matchSet( alloc, - &t.screen, + t.screen, .{ .x = 2, .y = 0 }, inputpkg.ctrlOrSuper(.{}), ); @@ -635,7 +635,7 @@ test "matchset osc8" { // Match over link var match = try set.matchSet( alloc, - &t.screen, + t.screen, .{ .x = 3, .y = 0 }, inputpkg.ctrlOrSuper(.{}), ); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index c4c3bed57..8ed256869 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -178,8 +178,15 @@ pub const CharsetState = struct { pub const Options = struct { cols: size.CellCountInt, rows: size.CellCountInt, + + /// The maximum size of scrollback in bytes. Zero means unlimited. Any + /// other value will be clamped to support a minimum of the active area. max_scrollback: usize = 0, + /// The total storage limit for Kitty images in bytes for this + /// screen. Kitty image storage is per-screen. + kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + /// A simple, default terminal. If you rely on specific dimensions or /// scrollback (or lack of) then do not use this directly. This is just /// for callers that need some defaults. @@ -215,7 +222,7 @@ pub fn init( errdefer pages.untrackPin(page_pin); const page_rac = page_pin.rowAndCell(); - return .{ + var result: Screen = .{ .alloc = alloc, .pages = pages, .no_scrollback = opts.max_scrollback == 0, @@ -227,6 +234,18 @@ pub fn init( .page_cell = page_rac.cell, }, }; + + if (comptime build_options.kitty_graphics) { + // This can't fail because the storage is always empty at this point + // and the only fail-able case is that we have to evict images. + result.kitty_images.setLimit( + alloc, + &result, + opts.kitty_image_storage_limit, + ) catch unreachable; + } + + return result; } pub fn deinit(self: *Screen) void { diff --git a/src/terminal/ScreenSet.zig b/src/terminal/ScreenSet.zig new file mode 100644 index 000000000..1b6b053fe --- /dev/null +++ b/src/terminal/ScreenSet.zig @@ -0,0 +1,106 @@ +/// A ScreenSet holds multiple terminal screens. This is initially created +/// to handle simple primary vs alternate screens, but could be extended +/// in the future to handle N screens. +/// +/// One of the goals of this is to allow lazy initialization of screens +/// as needed. The primary screen is always initialized, but the alternate +/// screen may not be until first used. +const ScreenSet = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const Allocator = std.mem.Allocator; +const Screen = @import("Screen.zig"); + +/// The possible keys for screens in the screen set. +pub const Key = enum(u1) { + primary, + alternate, +}; + +/// The key value of the currently active screen. Useful for simple +/// comparisons, e.g. "is this screen the primary screen". +active_key: Key, + +/// The active screen pointer. +active: *Screen, + +/// All screens that are initialized. +all: std.EnumMap(Key, *Screen), + +pub fn init( + alloc: Allocator, + opts: Screen.Options, +) !ScreenSet { + // We need to initialize our initial primary screen + const screen = try alloc.create(Screen); + errdefer alloc.destroy(screen); + screen.* = try .init(alloc, opts); + return .{ + .active_key = .primary, + .active = screen, + .all = .init(.{ .primary = screen }), + }; +} + +pub fn deinit(self: *ScreenSet, alloc: Allocator) void { + // Destroy all initialized screens + var it = self.all.iterator(); + while (it.next()) |entry| { + entry.value.*.deinit(); + alloc.destroy(entry.value.*); + } +} + +/// Get the screen for the given key, if it is initialized. +pub fn get(self: *const ScreenSet, key: Key) ?*Screen { + return self.all.get(key); +} + +/// Get the screen for the given key, initializing it if necessary. +pub fn getInit( + self: *ScreenSet, + alloc: Allocator, + key: Key, + opts: Screen.Options, +) !*Screen { + if (self.get(key)) |screen| return screen; + const screen = try alloc.create(Screen); + errdefer alloc.destroy(screen); + screen.* = try .init(alloc, opts); + self.all.put(key, screen); + return screen; +} + +/// Remove a key from the set. The primary screen cannot be removed (asserted). +pub fn remove( + self: *ScreenSet, + alloc: Allocator, + key: Key, +) void { + assert(key != .primary); + if (self.all.fetchRemove(key)) |screen| { + screen.deinit(); + alloc.destroy(screen); + } +} + +/// Switch the active screen to the given key. Requires that the +/// screen is initialized. +pub fn switchTo(self: *ScreenSet, key: Key) void { + self.active_key = key; + self.active = self.all.get(key).?; +} + +test ScreenSet { + const alloc = testing.allocator; + var set: ScreenSet = try .init(alloc, .default); + defer set.deinit(alloc); + try testing.expectEqual(.primary, set.active_key); + + // Initialize a secondary screen + _ = try set.getInit(alloc, .alternate, .default); + set.switchTo(.alternate); + try testing.expectEqual(.alternate, set.active_key); +} diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d9ad62ae1..185537115 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -29,6 +29,7 @@ const size = @import("size.zig"); const pagepkg = @import("page.zig"); const style = @import("style.zig"); const Screen = @import("Screen.zig"); +const ScreenSet = @import("ScreenSet.zig"); const Page = pagepkg.Page; const Cell = pagepkg.Cell; const Row = pagepkg.Row; @@ -38,18 +39,17 @@ const log = std.log.scoped(.terminal); /// Default tabstop interval const TABSTOP_INTERVAL = 8; -/// Screen type is an enum that tracks whether a screen is primary or alternate. -pub const ScreenType = enum(u1) { - primary, - alternate, -}; +/// The currently active screen. To get the type of screen this is, +/// inspect screens.active_key instead. +/// +/// Note: long term I'd like to get rid of this and force everyone +/// to go through screens instead but there's SO MUCH code that relies +/// on this property existing and it was really nasty to change all of +/// that today. +screen: *Screen, -/// Screen is the current screen state. The "active_screen" field says what -/// the current screen is. The backup screen is the opposite of the active -/// screen. -active_screen: ScreenType, -screen: Screen, -secondary_screen: Screen, +/// The set of screens behind this terminal (e.g. primary vs alternate). +screens: ScreenSet, /// Whether we're currently writing to the status line (DECSASD and DECSSDT). /// We don't support a status line currently so we just black hole this @@ -221,12 +221,19 @@ pub fn init( ) !Terminal { const cols = opts.cols; const rows = opts.rows; + + var screen_set: ScreenSet = try .init(alloc, .{ + .cols = cols, + .rows = rows, + .max_scrollback = opts.max_scrollback, + }); + errdefer screen_set.deinit(alloc); + return .{ .cols = cols, .rows = rows, - .active_screen = .primary, - .screen = try .init(alloc, .{ .cols = cols, .rows = rows, .max_scrollback = opts.max_scrollback }), - .secondary_screen = try .init(alloc, .{ .cols = cols, .rows = rows, .max_scrollback = 0 }), + .screen = screen_set.active, + .screens = screen_set, .tabstops = try .init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ .top = 0, @@ -245,8 +252,7 @@ pub fn init( pub fn deinit(self: *Terminal, alloc: Allocator) void { self.tabstops.deinit(alloc); - self.screen.deinit(); - self.secondary_screen.deinit(); + self.screens.deinit(alloc); self.pwd.deinit(alloc); self.* = undefined; } @@ -266,7 +272,7 @@ pub fn vtHandler(self: *Terminal) ReadonlyHandler { /// The general allocator we should use for this terminal. fn gpa(self: *Terminal) Allocator { - return self.screen.alloc; + return self.screens.active.alloc; } /// Print UTF-8 encoded string to the terminal. @@ -1074,7 +1080,7 @@ pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { /// If the shell integration doesn't exist, this will always return false. pub fn cursorIsAtPrompt(self: *Terminal) bool { // If we're on the secondary screen, we're never at a prompt. - if (self.active_screen == .alternate) return false; + if (self.screens.active_key == .alternate) return false; // Reverse through the active const start_x, const start_y = .{ self.screen.cursor.x, self.screen.cursor.y }; @@ -2202,7 +2208,7 @@ pub fn eraseDisplay( // at a prompt scrolls the screen contents prior to clearing. // Most shells send `ESC [ H ESC [ 2 J` so we can't just check // our current cursor position. See #905 - if (self.active_screen == .primary) at_prompt: { + if (self.screens.active_key == .primary) at_prompt: { // Go from the bottom of the active up and see if we're // at a prompt. const active_br = self.screen.pages.getBottomRight( @@ -2531,25 +2537,22 @@ pub fn resize( self.tabstops = try .init(alloc, cols, 8); } - // If we're making the screen smaller, dealloc the unused items. - if (self.active_screen == .primary) { - if (self.flags.shell_redraws_prompt) { - self.screen.clearPrompt(); - } - - if (self.modes.get(.wraparound)) { - try self.screen.resize(cols, rows); - } else { - try self.screen.resizeWithoutReflow(cols, rows); - } - try self.secondary_screen.resizeWithoutReflow(cols, rows); + // Resize primary screen, which supports reflow + const primary = self.screens.get(.primary).?; + if (self.screens.active_key == .primary and + self.flags.shell_redraws_prompt) + { + primary.clearPrompt(); + } + if (self.modes.get(.wraparound)) { + try primary.resize(cols, rows); } else { - try self.screen.resizeWithoutReflow(cols, rows); - if (self.modes.get(.wraparound)) { - try self.secondary_screen.resize(cols, rows); - } else { - try self.secondary_screen.resizeWithoutReflow(cols, rows); - } + try primary.resizeWithoutReflow(cols, rows); + } + + // Alternate screen, if it exists, doesn't reflow + if (self.screens.get(.alternate)) |alt| { + try alt.resizeWithoutReflow(cols, rows); } // Whenever we resize we just mark it as a screen clear @@ -2581,14 +2584,6 @@ pub fn getPwd(self: *const Terminal) ?[]const u8 { return self.pwd.items; } -/// Get the screen pointer for the given type. -pub fn getScreen(self: *Terminal, t: ScreenType) *Screen { - return if (self.active_screen == t) - &self.screen - else - &self.secondary_screen; -} - /// Switch to the given screen type (alternate or primary). /// /// This does NOT handle behaviors such as clearing the screen, @@ -2604,40 +2599,60 @@ pub fn getScreen(self: *Terminal, t: ScreenType) *Screen { /// more than two screens in the future if needed. There isn't /// currently a spec for this, but it is something I think might /// be useful in the future. -pub fn switchScreen(self: *Terminal, t: ScreenType) ?*Screen { +pub fn switchScreen(self: *Terminal, key: ScreenSet.Key) !?*Screen { // If we're already on the requested screen we do nothing. - if (self.active_screen == t) return null; + if (self.screens.active_key == key) return null; + const old = self.screens.active; // We always end hyperlink state when switching screens. // We need to do this on the original screen. - self.screen.endHyperlink(); + old.endHyperlink(); - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = t; + // Switch the screens/ + const new = self.screens.get(key) orelse new: { + const primary = self.screens.get(.primary).?; + break :new try self.screens.getInit( + old.alloc, + key, + .{ + .cols = self.cols, + .rows = self.rows, + .max_scrollback = switch (key) { + .primary => primary.pages.explicit_max_size, + .alternate => 0, + }, + + // Inherit our Kitty image storage limit from the primary + // screen if we have to initialize. + .kitty_image_storage_limit = primary.kitty_images.total_limit, + }, + ); + }; // The new screen should not have any hyperlinks set - assert(self.screen.cursor.hyperlink_id == 0); + assert(new.cursor.hyperlink_id == 0); // Bring our charset state with us - self.screen.charset = old.charset; + new.charset = old.charset; // Clear our selection - self.screen.clearSelection(); + new.clearSelection(); if (comptime build_options.kitty_graphics) { // Mark kitty images as dirty so they redraw. Without this set // the images will remain where they were (the dirty bit on // the screen only tracks the terminal grid, not the images). - self.screen.kitty_images.dirty = true; + new.kitty_images.dirty = true; } // Mark our terminal as dirty to redraw the grid. self.flags.dirty.clear = true; - return &self.secondary_screen; + // Finalize the switch + self.screens.switchTo(key); + self.screen = new; + + return old; } /// Switch screen via a mode switch (e.g. mode 47, 1047, 1049). @@ -2653,7 +2668,7 @@ pub fn switchScreenMode( self: *Terminal, mode: SwitchScreenMode, enabled: bool, -) void { +) !void { // The behavior in this function is completely based on reading // the xterm source, specifically "charproc.c" for // `srm_ALTBUF`, `srm_OPT_ALTBUF`, and `srm_OPT_ALTBUF_CURSOR`. @@ -2665,7 +2680,7 @@ pub fn switchScreenMode( // If we're disabling 1047 and we're on alt screen then // we clear the screen. - .@"1047" => if (!enabled and self.active_screen == .alternate) { + .@"1047" => if (!enabled and self.screens.active_key == .alternate) { self.eraseDisplay(.complete, false); }, @@ -2675,8 +2690,8 @@ pub fn switchScreenMode( } // Switch screens first to whatever we're going to. - const to: ScreenType = if (enabled) .alternate else .primary; - const old_ = self.switchScreen(to); + const to: ScreenSet.Key = if (enabled) .alternate else .primary; + const old_ = try self.switchScreen(to); switch (mode) { // For these modes, we need to copy the cursor. We only copy @@ -2697,7 +2712,7 @@ pub fn switchScreenMode( // Mode 1049 restores cursor on the primary screen when // we disable it. .@"1049" => if (enabled) { - assert(self.active_screen == .alternate); + assert(self.screens.active_key == .alternate); self.eraseDisplay(.complete, false); // When we enter alt screen with 1049, we always copy the @@ -2714,7 +2729,7 @@ pub fn switchScreenMode( }; } } else { - assert(self.active_screen == .primary); + assert(self.screens.active_key == .primary); self.restoreCursor() catch |err| { log.warn( "restore cursor on switch screen failed to={} err={}", @@ -2765,17 +2780,16 @@ pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 { /// this will reuse the existing memory. In the latter case, memory may /// be wasted (since its unused) but it isn't leaked. pub fn fullReset(self: *Terminal) void { - // Reset our screens - self.screen.reset(); - self.secondary_screen.reset(); - // Ensure we're back on primary screen - if (self.active_screen != .primary) { - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .primary; - } + self.screens.switchTo(.primary); + self.screens.remove( + self.screens.active.alloc, + .alternate, + ); + self.screen = self.screens.active; + + // Reset our screens + self.screens.active.reset(); // Rest our basic state self.modes.reset(); @@ -10757,7 +10771,7 @@ test "Terminal: cursorIsAtPrompt alternate screen" { try testing.expect(t.cursorIsAtPrompt()); // Secondary screen is never a prompt - t.switchScreenMode(.@"1049", true); + try t.switchScreenMode(.@"1049", true); try testing.expect(!t.cursorIsAtPrompt()); t.markSemanticPrompt(.prompt); try testing.expect(!t.cursorIsAtPrompt()); @@ -10841,7 +10855,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); - t.switchScreenMode(.@"1049", true); + try t.switchScreenMode(.@"1049", true); t.screen.kitty_keyboard.push(.{ .disambiguate = true, .report_events = false, @@ -10849,10 +10863,10 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { .report_all = true, .report_associated = true, }); - t.switchScreenMode(.@"1049", false); + try t.switchScreenMode(.@"1049", false); t.fullReset(); - try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int()); + try testing.expect(t.screens.get(.alternate) == null); } test "Terminal: fullReset default modes" { @@ -11164,8 +11178,8 @@ test "Terminal: mode 47 alt screen plain" { try t.printString("1A"); // Go to alt screen with mode 47 - t.switchScreenMode(.@"47", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"47", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should be empty { @@ -11184,8 +11198,8 @@ test "Terminal: mode 47 alt screen plain" { } // Go back to primary - t.switchScreenMode(.@"47", false); - try testing.expectEqual(ScreenType.primary, t.active_screen); + try t.switchScreenMode(.@"47", false); + try testing.expectEqual(.primary, t.screens.active_key); // Primary screen should still have the original content { @@ -11195,8 +11209,8 @@ test "Terminal: mode 47 alt screen plain" { } // Go back to alt screen with mode 47 - t.switchScreenMode(.@"47", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"47", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should retain content { @@ -11215,8 +11229,8 @@ test "Terminal: mode 47 copies cursor both directions" { try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); // Go to alt screen with mode 47 - t.switchScreenMode(.@"47", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"47", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Verify that our style is set { @@ -11230,8 +11244,8 @@ test "Terminal: mode 47 copies cursor both directions" { try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); // Go back to primary - t.switchScreenMode(.@"47", false); - try testing.expectEqual(ScreenType.primary, t.active_screen); + try t.switchScreenMode(.@"47", false); + try testing.expectEqual(.primary, t.screens.active_key); // Verify that our style is still set { @@ -11251,8 +11265,8 @@ test "Terminal: mode 1047 alt screen plain" { try t.printString("1A"); // Go to alt screen with mode 47 - t.switchScreenMode(.@"1047", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"1047", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should be empty { @@ -11271,8 +11285,8 @@ test "Terminal: mode 1047 alt screen plain" { } // Go back to primary - t.switchScreenMode(.@"1047", false); - try testing.expectEqual(ScreenType.primary, t.active_screen); + try t.switchScreenMode(.@"1047", false); + try testing.expectEqual(.primary, t.screens.active_key); // Primary screen should still have the original content { @@ -11282,8 +11296,8 @@ test "Terminal: mode 1047 alt screen plain" { } // Go back to alt screen with mode 1047 - t.switchScreenMode(.@"1047", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"1047", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should be empty { @@ -11302,8 +11316,8 @@ test "Terminal: mode 1047 copies cursor both directions" { try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); // Go to alt screen with mode 47 - t.switchScreenMode(.@"1047", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"1047", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Verify that our style is set { @@ -11317,8 +11331,8 @@ test "Terminal: mode 1047 copies cursor both directions" { try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); // Go back to primary - t.switchScreenMode(.@"1047", false); - try testing.expectEqual(ScreenType.primary, t.active_screen); + try t.switchScreenMode(.@"1047", false); + try testing.expectEqual(.primary, t.screens.active_key); // Verify that our style is still set { @@ -11338,8 +11352,8 @@ test "Terminal: mode 1049 alt screen plain" { try t.printString("1A"); // Go to alt screen with mode 47 - t.switchScreenMode(.@"1049", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"1049", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should be empty { @@ -11358,8 +11372,8 @@ test "Terminal: mode 1049 alt screen plain" { } // Go back to primary - t.switchScreenMode(.@"1049", false); - try testing.expectEqual(ScreenType.primary, t.active_screen); + try t.switchScreenMode(.@"1049", false); + try testing.expectEqual(.primary, t.screens.active_key); // Primary screen should still have the original content { @@ -11377,8 +11391,8 @@ test "Terminal: mode 1049 alt screen plain" { } // Go back to alt screen with mode 1049 - t.switchScreenMode(.@"1049", true); - try testing.expectEqual(ScreenType.alternate, t.active_screen); + try t.switchScreenMode(.@"1049", true); + try testing.expectEqual(.alternate, t.screens.active_key); // Screen should be empty { diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 6683b3453..0a742ccb1 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -331,7 +331,7 @@ pub const TerminalFormatter = struct { } } - var screen_formatter: ScreenFormatter = .init(&self.terminal.screen, self.opts); + var screen_formatter: ScreenFormatter = .init(self.terminal.screen, self.opts); screen_formatter.content = self.content; screen_formatter.extra = self.extra.screen; screen_formatter.pin_map = self.pin_map; @@ -4231,7 +4231,7 @@ test "Screen plain single line" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(&t.screen, .plain); + var formatter: ScreenFormatter = .init(t.screen, .plain); formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); @@ -4268,7 +4268,7 @@ test "Screen plain multiline" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(&t.screen, .plain); + var formatter: ScreenFormatter = .init(t.screen, .plain); formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); @@ -4316,7 +4316,7 @@ test "Screen plain with selection" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(&t.screen, .plain); + var formatter: ScreenFormatter = .init(t.screen, .plain); formatter.content = .{ .selection = .init( t.screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, t.screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, @@ -4361,7 +4361,7 @@ test "Screen vt with cursor position" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(&t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screen, .vt); formatter.extra.cursor = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4420,7 +4420,7 @@ test "Screen vt with style" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(&t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screen, .vt); formatter.extra.style = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4472,7 +4472,7 @@ test "Screen vt with hyperlink" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(&t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screen, .vt); formatter.extra.hyperlink = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4532,7 +4532,7 @@ test "Screen vt with protection" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(&t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screen, .vt); formatter.extra.protection = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4584,7 +4584,7 @@ test "Screen vt with kitty keyboard" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(&t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screen, .vt); formatter.extra.kitty_keyboard = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4638,7 +4638,7 @@ test "Screen vt with charsets" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(&t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screen, .vt); formatter.extra.charsets = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index f917c104a..9cf0bda0c 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -252,7 +252,7 @@ fn display( result.placement_id, p, ) catch |err| { - p.deinit(&terminal.screen); + p.deinit(terminal.screen); encodeError(&result, err); return result; }; diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 8aef0ece5..34b9a6e85 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -232,7 +232,7 @@ pub const ImageStorage = struct { // Deinit the placement and remove it const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screen); self.placements.removeByPtr(entry.key_ptr); if (delete_images) self.deleteIfUnused(alloc, image_id); } @@ -247,7 +247,7 @@ pub const ImageStorage = struct { .id => |v| self.deleteById( alloc, - &t.screen, + t.screen, v.image_id, v.placement_id, v.delete, @@ -257,7 +257,7 @@ pub const ImageStorage = struct { const img = self.imageByNumber(v.image_number) orelse break :newest; self.deleteById( alloc, - &t.screen, + t.screen, img.id, v.placement_id, v.delete, @@ -332,7 +332,7 @@ pub const ImageStorage = struct { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t) orelse continue; if (rect.top_left.x <= x and rect.bottom_right.x >= x) { - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -364,7 +364,7 @@ pub const ImageStorage = struct { var target_pin_copy = target_pin; target_pin_copy.x = rect.top_left.x; if (target_pin_copy.isBetween(rect.top_left, rect.bottom_right)) { - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -387,7 +387,7 @@ pub const ImageStorage = struct { if (entry.value_ptr.z == v.z) { const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -411,7 +411,7 @@ pub const ImageStorage = struct { while (it.next()) |entry| { if (entry.key_ptr.image_id >= v.first or entry.key_ptr.image_id <= v.last) { const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -498,7 +498,7 @@ pub const ImageStorage = struct { const rect = entry.value_ptr.rect(img, t) orelse continue; if (target_pin.isBetween(rect.top_left, rect.bottom_right)) { if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; - entry.value_ptr.deinit(&t.screen); + entry.value_ptr.deinit(t.screen); self.placements.removeByPtr(entry.key_ptr); if (delete_unused) self.deleteIfUnused(alloc, img.id); } @@ -825,7 +825,7 @@ test "storage: add placement with zero placement id" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); @@ -853,7 +853,7 @@ test "storage: delete all placements and images" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -876,7 +876,7 @@ test "storage: delete all placements and images preserves limit" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); s.total_limit = 5000; try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); @@ -901,7 +901,7 @@ test "storage: delete all placements" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -924,7 +924,7 @@ test "storage: delete all placements by image id" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -947,7 +947,7 @@ test "storage: delete all placements by image id and unused images" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -970,7 +970,7 @@ test "storage: delete placement by specific id" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1000,7 +1000,7 @@ test "storage: delete intersecting cursor" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); @@ -1032,7 +1032,7 @@ test "storage: delete intersecting cursor plus unused" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); @@ -1064,7 +1064,7 @@ test "storage: delete intersecting cursor hits multiple" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); @@ -1090,7 +1090,7 @@ test "storage: delete by column" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); @@ -1122,7 +1122,7 @@ test "storage: delete by column 1x1" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 0 }) } }); @@ -1156,7 +1156,7 @@ test "storage: delete by row" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); @@ -1188,7 +1188,7 @@ test "storage: delete by row 1x1" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 1 }) } }); @@ -1220,7 +1220,7 @@ test "storage: delete images by range 1" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1245,7 +1245,7 @@ test "storage: delete images by range 2" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1270,7 +1270,7 @@ test "storage: delete images by range 3" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1295,7 +1295,7 @@ test "storage: delete images by range 4" { const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); diff --git a/src/terminal/kitty/graphics_unicode.zig b/src/terminal/kitty/graphics_unicode.zig index a4a25e751..491c3e110 100644 --- a/src/terminal/kitty/graphics_unicode.zig +++ b/src/terminal/kitty/graphics_unicode.zig @@ -1180,7 +1180,7 @@ test "unicode render placement: dog 4x2" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; try s.addImage(alloc, image); @@ -1247,7 +1247,7 @@ test "unicode render placement: dog 2x2 with blank cells" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; try s.addImage(alloc, image); @@ -1313,7 +1313,7 @@ test "unicode render placement: dog 1x1" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc, t.screen); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; try s.addImage(alloc, image); diff --git a/src/terminal/main.zig b/src/terminal/main.zig index bdcbfe77f..3f67b78b3 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -41,7 +41,7 @@ pub const Point = point.Point; pub const ReadonlyHandler = stream_readonly.Handler; pub const ReadonlyStream = stream_readonly.Stream; pub const Screen = @import("Screen.zig"); -pub const ScreenType = Terminal.ScreenType; +pub const ScreenSet = @import("ScreenSet.zig"); pub const Scrollbar = PageList.Scrollbar; pub const Selection = @import("Selection.zig"); pub const SizeReportStyle = csi.SizeReportStyle; diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 0ffeb76c4..674f08b8c 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -391,7 +391,7 @@ test "simple search" { defer s.deinit(); try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); - var search: ScreenSearch = try .init(alloc, &t.screen, "Fizz"); + var search: ScreenSearch = try .init(alloc, t.screen, "Fizz"); defer search.deinit(); try search.searchAll(); try testing.expectEqual(2, search.active_results.items.len); @@ -444,7 +444,7 @@ test "simple search with history" { for (0..list.rows) |_| try s.nextSlice("\r\n"); try s.nextSlice("hello."); - var search: ScreenSearch = try .init(alloc, &t.screen, "Fizz"); + var search: ScreenSearch = try .init(alloc, t.screen, "Fizz"); defer search.deinit(); try search.searchAll(); try testing.expectEqual(0, search.active_results.items.len); @@ -482,7 +482,7 @@ test "reload active with history change" { try s.nextSlice("Fizz\r\n"); // Start up our search which will populate our initial active area. - var search: ScreenSearch = try .init(alloc, &t.screen, "Fizz"); + var search: ScreenSearch = try .init(alloc, t.screen, "Fizz"); defer search.deinit(); try search.searchAll(); { @@ -562,7 +562,7 @@ test "active change contents" { defer s.deinit(); try s.nextSlice("Fuzz\r\nBuzz\r\nFizz\r\nBang"); - var search: ScreenSearch = try .init(alloc, &t.screen, "Fizz"); + var search: ScreenSearch = try .init(alloc, t.screen, "Fizz"); defer search.deinit(); try search.searchAll(); try testing.expectEqual(1, search.active_results.items.len); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 907c48762..d34e4c84c 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -233,9 +233,9 @@ pub const Handler = struct { self.terminal.scrolling_region.right = self.terminal.cols - 1; }, - .alt_screen_legacy => self.terminal.switchScreenMode(.@"47", enabled), - .alt_screen => self.terminal.switchScreenMode(.@"1047", enabled), - .alt_screen_save_cursor_clear_enter => self.terminal.switchScreenMode(.@"1049", enabled), + .alt_screen_legacy => try self.terminal.switchScreenMode(.@"47", enabled), + .alt_screen => try self.terminal.switchScreenMode(.@"1047", enabled), + .alt_screen_save_cursor_clear_enter => try self.terminal.switchScreenMode(.@"1049", enabled), .save_cursor => if (enabled) { self.terminal.saveCursor(); @@ -527,18 +527,18 @@ test "alt screen" { // Write to primary screen try s.nextSlice("Primary"); - try testing.expectEqual(Terminal.ScreenType.primary, t.active_screen); + try testing.expectEqual(.primary, t.screens.active_key); // Switch to alt screen try s.nextSlice("\x1B[?1049h"); - try testing.expectEqual(Terminal.ScreenType.alternate, t.active_screen); + try testing.expectEqual(.alternate, t.screens.active_key); // Write to alt screen try s.nextSlice("Alt"); // Switch back to primary try s.nextSlice("\x1B[?1049l"); - try testing.expectEqual(Terminal.ScreenType.primary, t.active_screen); + try testing.expectEqual(.primary, t.screens.active_key); const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 1e181a137..6371722b5 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -246,16 +246,15 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { errdefer term.deinit(alloc); // Set the image size limits - try term.screen.kitty_images.setLimit( - alloc, - &term.screen, - opts.config.image_storage_limit, - ); - try term.secondary_screen.kitty_images.setLimit( - alloc, - &term.secondary_screen, - opts.config.image_storage_limit, - ); + var it = term.screens.all.iterator(); + while (it.next()) |entry| { + const screen: *terminalpkg.Screen = entry.value.*; + try screen.kitty_images.setLimit( + alloc, + screen, + opts.config.image_storage_limit, + ); + } // Set our default cursor style term.screen.cursor.cursor_style = opts.config.cursor_style; @@ -451,16 +450,15 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi }; // Set the image size limits - try self.terminal.screen.kitty_images.setLimit( - self.alloc, - &self.terminal.screen, - config.image_storage_limit, - ); - try self.terminal.secondary_screen.kitty_images.setLimit( - self.alloc, - &self.terminal.secondary_screen, - config.image_storage_limit, - ); + var it = self.terminal.screens.all.iterator(); + while (it.next()) |entry| { + const screen: *terminalpkg.Screen = entry.value.*; + try screen.kitty_images.setLimit( + self.alloc, + screen, + config.image_storage_limit, + ); + } } /// Resize the terminal. @@ -578,7 +576,7 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // emulator-level screen clear, this messes up the running programs // knowledge of where the cursor is and causes rendering issues. So, // for alt screen, we do nothing. - if (self.terminal.active_screen == .alternate) return; + if (self.terminal.screens.active_key == .alternate) return; // Clear our selection self.terminal.screen.clearSelection(); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 0131ff2e1..4b40ff3cf 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -583,15 +583,15 @@ pub const StreamHandler = struct { }, .alt_screen_legacy => { - self.terminal.switchScreenMode(.@"47", enabled); + try self.terminal.switchScreenMode(.@"47", enabled); }, .alt_screen => { - self.terminal.switchScreenMode(.@"1047", enabled); + try self.terminal.switchScreenMode(.@"1047", enabled); }, .alt_screen_save_cursor_clear_enter => { - self.terminal.switchScreenMode(.@"1049", enabled); + try self.terminal.switchScreenMode(.@"1049", enabled); }, // Mode 1048 is xterm's conditional save cursor depending From 580f9f057bc8f2bad5bd93442a96158de4ca8b9d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 15:10:54 -0800 Subject: [PATCH 307/702] convert t.screen to t.screens.active --- src/Surface.zig | 136 +- src/apprt/embedded.zig | 8 +- src/input/key_encode.zig | 2 +- src/inspector/Inspector.zig | 6 +- src/inspector/termio.zig | 2 +- src/renderer/cursor.zig | 10 +- src/renderer/generic.zig | 32 +- src/renderer/link.zig | 10 +- src/terminal/Terminal.zig | 1514 +++++++++++------------ src/terminal/formatter.zig | 234 ++-- src/terminal/kitty/graphics_exec.zig | 20 +- src/terminal/kitty/graphics_storage.zig | 128 +- src/terminal/kitty/graphics_unicode.zig | 38 +- src/terminal/search/active.zig | 18 +- src/terminal/search/pagelist.zig | 66 +- src/terminal/search/screen.zig | 40 +- src/terminal/stream_readonly.zig | 82 +- src/termio/Termio.zig | 16 +- src/termio/stream_handler.zig | 60 +- 19 files changed, 1205 insertions(+), 1217 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index db8b4474e..308b6d1f7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1077,7 +1077,7 @@ fn selectionScrollTick(self: *Surface) !void { try t.scrollViewport(.{ .delta = delta }); // Next, trigger our drag behavior - const pin = t.screen.pages.pin(.{ + const pin = t.screens.active.pages.pin(.{ .viewport = .{ .x = pos_vp.x, .y = pos_vp.y, @@ -1161,7 +1161,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { // so that we can close the terminal. We close the terminal on // any key press that encodes a character. t.modes.set(.disable_keyboard, false); - t.screen.kitty_keyboard.set(.set, .disabled); + t.screens.active.kitty_keyboard.set(.set, .disabled); } // Waiting after command we stop here. The terminal is updated, our @@ -1372,7 +1372,7 @@ fn mouseRefreshLinks( const left_idx = @intFromEnum(input.MouseButton.left); if (self.mouse.click_state[left_idx] == .press) click: { const pin = self.mouse.left_click_pin orelse break :click; - const click_pt = self.io.terminal.screen.pages.pointFromPin( + const click_pt = self.io.terminal.screens.active.pages.pointFromPin( .viewport, pin.*, ) orelse break :click; @@ -1386,7 +1386,7 @@ fn mouseRefreshLinks( const link = (try self.linkAtPos(pos)) orelse break :link .{ null, false }; switch (link[0]) { .open => { - const str = try self.io.terminal.screen.selectionString(alloc, .{ + const str = try self.io.terminal.screens.active.selectionString(alloc, .{ .sel = link[1], .trim = false, }); @@ -1416,7 +1416,7 @@ fn mouseRefreshLinks( if (link_) |link| { self.renderer_state.mouse.point = pos_vp; self.mouse.over_link = true; - self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; + self.renderer_state.terminal.screens.active.dirty.hyperlink_hover = true; _ = try self.rt_app.performAction( .{ .surface = self }, .mouse_shape, @@ -1692,7 +1692,7 @@ pub fn dumpTextLocked( sel: terminal.Selection, ) !Text { // Read out the text - const text = try self.io.terminal.screen.selectionString(alloc, .{ + const text = try self.io.terminal.screens.active.selectionString(alloc, .{ .sel = sel, .trim = false, }); @@ -1702,19 +1702,19 @@ pub fn dumpTextLocked( const vp: ?Text.Viewport = viewport: { // If our bottom right pin is before the viewport, then we can't // possibly have this text be within the viewport. - const vp_tl_pin = self.io.terminal.screen.pages.getTopLeft(.viewport); - const br_pin = sel.bottomRight(self.io.terminal.screen); + const vp_tl_pin = self.io.terminal.screens.active.pages.getTopLeft(.viewport); + const br_pin = sel.bottomRight(self.io.terminal.screens.active); if (br_pin.before(vp_tl_pin)) break :viewport null; // If our top-left pin is after the viewport, then we can't possibly // have this text be within the viewport. - const vp_br_pin = self.io.terminal.screen.pages.getBottomRight(.viewport) orelse { + const vp_br_pin = self.io.terminal.screens.active.pages.getBottomRight(.viewport) orelse { // I don't think this is possible but I don't want to crash on // that assertion so let's just break out... log.warn("viewport bottom-right pin not found, bug?", .{}); break :viewport null; }; - const tl_pin = sel.topLeft(self.io.terminal.screen); + const tl_pin = sel.topLeft(self.io.terminal.screens.active); if (vp_br_pin.before(tl_pin)) break :viewport null; // We established that our top-left somewhere before the viewport @@ -1724,7 +1724,7 @@ pub fn dumpTextLocked( // Our top-left point. If it doesn't exist in the viewport it must // be before and we can return (0,0). - const tl_pt: terminal.Point = self.io.terminal.screen.pages.pointFromPin( + const tl_pt: terminal.Point = self.io.terminal.screens.active.pages.pointFromPin( .viewport, tl_pin, ) orelse tl: { @@ -1737,7 +1737,7 @@ pub fn dumpTextLocked( // Our bottom-right point. If it doesn't exist in the viewport // it must be the bottom-right of the viewport. - const br_pt = self.io.terminal.screen.pages.pointFromPin( + const br_pt = self.io.terminal.screens.active.pages.pointFromPin( .viewport, br_pin, ) orelse br: { @@ -1745,7 +1745,7 @@ pub fn dumpTextLocked( assert(vp_br_pin.before(br_pin)); } - break :br self.io.terminal.screen.pages.pointFromPin( + break :br self.io.terminal.screens.active.pages.pointFromPin( .viewport, vp_br_pin, ).?; @@ -1786,8 +1786,8 @@ pub fn dumpTextLocked( }; // Utilize viewport sizing to convert to offsets - const start = tl_coord.y * self.io.terminal.screen.pages.cols + tl_coord.x; - const end = br_coord.y * self.io.terminal.screen.pages.cols + br_coord.x; + const start = tl_coord.y * self.io.terminal.screens.active.pages.cols + tl_coord.x; + const end = br_coord.y * self.io.terminal.screens.active.pages.cols + br_coord.x; break :viewport .{ .tl_px_x = x, @@ -1807,15 +1807,15 @@ pub fn dumpTextLocked( pub fn hasSelection(self: *const Surface) bool { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - return self.io.terminal.screen.selection != null; + return self.io.terminal.screens.active.selection != null; } /// Returns the selected text. This is allocated. pub fn selectionString(self: *Surface, alloc: Allocator) !?[:0]const u8 { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - const sel = self.io.terminal.screen.selection orelse return null; - return try self.io.terminal.screen.selectionString(alloc, .{ + const sel = self.io.terminal.screens.active.selection orelse return null; + return try self.io.terminal.screens.active.selectionString(alloc, .{ .sel = sel, .trim = false, }); @@ -1838,7 +1838,7 @@ pub fn pwd( /// keyboard should be rendered. pub fn imePoint(self: *const Surface) apprt.IMEPos { self.renderer_state.mutex.lock(); - const cursor = self.renderer_state.terminal.screen.cursor; + const cursor = self.renderer_state.terminal.screens.active.cursor; const preedit_width: usize = if (self.renderer_state.preedit) |preedit| preedit.width() else 0; self.renderer_state.mutex.unlock(); @@ -1984,7 +1984,7 @@ fn copySelectionToClipboards( var contents: std.ArrayList(apprt.ClipboardContent) = .empty; switch (format) { .plain => { - var formatter: ScreenFormatter = .init(self.io.terminal.screen, opts); + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); try contents.append(alloc, .{ @@ -1994,7 +1994,7 @@ fn copySelectionToClipboards( }, .vt => { - var formatter: ScreenFormatter = .init(self.io.terminal.screen, opts: { + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts: { var copy = opts; copy.emit = .vt; break :opts copy; @@ -2011,7 +2011,7 @@ fn copySelectionToClipboards( }, .html => { - var formatter: ScreenFormatter = .init(self.io.terminal.screen, opts: { + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts: { var copy = opts; copy.emit = .html; break :opts copy; @@ -2029,7 +2029,7 @@ fn copySelectionToClipboards( .mixed => { // First, generate plain text with codepoint mappings applied - var formatter: ScreenFormatter = .init(self.io.terminal.screen, opts); + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); try contents.append(alloc, .{ @@ -2039,7 +2039,7 @@ fn copySelectionToClipboards( assert(aw.written().len == 0); // Second, generate HTML without codepoint mappings - formatter = .init(self.io.terminal.screen, opts: { + formatter = .init(self.io.terminal.screens.active, opts: { var copy = opts; copy.emit = .html; @@ -2079,8 +2079,8 @@ fn copySelectionToClipboards( /// /// This must be called with the renderer mutex held. fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { - const prev_ = self.io.terminal.screen.selection; - try self.io.terminal.screen.select(sel_); + const prev_ = self.io.terminal.screens.active.selection; + try self.io.terminal.screens.active.select(sel_); // If copy on select is false then exit early. if (self.config.copy_on_select == .false) return; @@ -3506,7 +3506,7 @@ pub fn mouseButtonCallback( { const pos = try self.rt_surface.getCursorPos(); const point = self.posToViewport(pos.x, pos.y); - const screen: *terminal.Screen = self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; const p = screen.pages.pin(.{ .viewport = point }) orelse { log.warn("failed to get pin for clicked point", .{}); return false; @@ -3594,7 +3594,7 @@ pub fn mouseButtonCallback( if (self.config.copy_on_select != .false) { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - const prev_ = self.io.terminal.screen.selection; + const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { try self.setSelection(terminal.Selection.init( prev.start(), @@ -3666,7 +3666,7 @@ pub fn mouseButtonCallback( // If we have a selection then we do not do click to move because // it means that we moved our cursor while pressing the mouse button. - if (self.io.terminal.screen.selection != null) break :click_move; + if (self.io.terminal.screens.active.selection != null) break :click_move; // Moving always resets the click count so that we don't highlight. self.mouse.left_click_count = 0; @@ -3681,7 +3681,7 @@ pub fn mouseButtonCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; - const screen: *terminal.Screen = self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; const pos = try self.rt_surface.getCursorPos(); const pin = pin: { @@ -3758,17 +3758,17 @@ pub fn mouseButtonCallback( // Single click 1 => { // If we have a selection, clear it. This always happens. - if (self.io.terminal.screen.selection != null) { - try self.io.terminal.screen.select(null); + if (self.io.terminal.screens.active.selection != null) { + try self.io.terminal.screens.active.select(null); try self.queueRender(); } }, // Double click, select the word under our mouse 2 => { - const sel_ = self.io.terminal.screen.selectWord(pin.*); + const sel_ = self.io.terminal.screens.active.selectWord(pin.*); if (sel_) |sel| { - try self.io.terminal.screen.select(sel); + try self.io.terminal.screens.active.select(sel); try self.queueRender(); } }, @@ -3776,11 +3776,11 @@ pub fn mouseButtonCallback( // Triple click, select the line under our mouse 3 => { const sel_ = if (mods.ctrlOrSuper()) - self.io.terminal.screen.selectOutput(pin.*) + self.io.terminal.screens.active.selectOutput(pin.*) else - self.io.terminal.screen.selectLine(.{ .pin = pin.* }); + self.io.terminal.screens.active.selectLine(.{ .pin = pin.* }); if (sel_) |sel| { - try self.io.terminal.screen.select(sel); + try self.io.terminal.screens.active.select(sel); try self.queueRender(); } }, @@ -3809,7 +3809,7 @@ pub fn mouseButtonCallback( defer self.renderer_state.mutex.unlock(); // Get our viewport pin - const screen: *terminal.Screen = self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; const pin = pin: { const pos = try self.rt_surface.getCursorPos(); const pt_viewport = self.posToViewport(pos.x, pos.y); @@ -3835,7 +3835,7 @@ pub fn mouseButtonCallback( .@"context-menu" => { // If we already have a selection and the selection contains // where we clicked then we don't want to modify the selection. - if (self.io.terminal.screen.selection) |prev_sel| { + if (self.io.terminal.screens.active.selection) |prev_sel| { if (prev_sel.contains(screen, pin)) break :sel; // The selection doesn't contain our pin, so we create a new @@ -3850,7 +3850,7 @@ pub fn mouseButtonCallback( return false; }, .copy => { - if (self.io.terminal.screen.selection) |sel| { + if (self.io.terminal.screens.active.selection) |sel| { try self.copySelectionToClipboards( sel, &.{.standard}, @@ -3861,7 +3861,7 @@ pub fn mouseButtonCallback( try self.setSelection(null); try self.queueRender(); }, - .@"copy-or-paste" => if (self.io.terminal.screen.selection) |sel| { + .@"copy-or-paste" => if (self.io.terminal.screens.active.selection) |sel| { try self.copySelectionToClipboards( sel, &.{.standard}, @@ -3920,8 +3920,8 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { if (!t.flags.shell_redraws_prompt) return; // Get our path - const from = t.screen.cursor.page_pin.*; - const path = t.screen.promptPath(from, to); + const from = t.screens.active.cursor.page_pin.*; + const path = t.screens.active.promptPath(from, to); log.debug("click-to-move-cursor from={} to={} path={}", .{ from, to, path }); // If we aren't moving at all, fast path out of here. @@ -3965,7 +3965,7 @@ fn linkAtPos( terminal.Selection, } { // Convert our cursor position to a screen point. - const screen: *terminal.Screen = self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; const mouse_pin: terminal.Pin = mouse_pin: { const point = self.posToViewport(pos.x, pos.y); const pin = screen.pages.pin(.{ .viewport = point }) orelse { @@ -4053,7 +4053,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { const action, const sel = try self.linkAtPos(pos) orelse return false; switch (action) { .open => { - const str = try self.io.terminal.screen.selectionString(self.alloc, .{ + const str = try self.io.terminal.screens.active.selectionString(self.alloc, .{ .sel = sel, .trim = false, }); @@ -4139,8 +4139,8 @@ pub fn mousePressureCallback( // This should always be set in this state but we don't want // to handle state inconsistency here. const pin = self.mouse.left_click_pin orelse break :select; - const sel = self.io.terminal.screen.selectWord(pin.*) orelse break :select; - try self.io.terminal.screen.select(sel); + const sel = self.io.terminal.screens.active.selectWord(pin.*) orelse break :select; + try self.io.terminal.screens.active.select(sel); try self.queueRender(); } } @@ -4197,7 +4197,7 @@ pub fn cursorPosCallback( // Mark the link's row as dirty, but continue with updating the // mouse state below so we can scroll when our position is negative. - self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; + self.renderer_state.terminal.screens.active.dirty.hyperlink_hover = true; } // Always show the mouse again if it is hidden @@ -4238,7 +4238,7 @@ pub fn cursorPosCallback( insp.mouse.last_xpos = pos.x; insp.mouse.last_ypos = pos.y; - const screen: *terminal.Screen = self.renderer_state.terminal.screen; + const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; insp.mouse.last_point = screen.pages.pin(.{ .viewport = .{ .x = pos_vp.x, .y = pos_vp.y, @@ -4329,7 +4329,7 @@ pub fn cursorPosCallback( } // Convert to points - const screen: *terminal.Screen = t.screen; + const screen: *terminal.Screen = t.screens.active; const pin = screen.pages.pin(.{ .viewport = .{ .x = pos_vp.x, @@ -4358,7 +4358,7 @@ fn dragLeftClickDouble( self: *Surface, drag_pin: terminal.Pin, ) !void { - const screen: *terminal.Screen = self.io.terminal.screen; + const screen: *terminal.Screen = self.io.terminal.screens.active; const click_pin = self.mouse.left_click_pin.?.*; // Get the word closest to our starting click. @@ -4379,13 +4379,13 @@ fn dragLeftClickDouble( // If our current mouse position is before the starting position, // then the selection start is the word nearest our current position. if (drag_pin.before(click_pin)) { - try self.io.terminal.screen.select(.init( + try self.io.terminal.screens.active.select(.init( word_current.start(), word_start.end(), false, )); } else { - try self.io.terminal.screen.select(.init( + try self.io.terminal.screens.active.select(.init( word_start.start(), word_current.end(), false, @@ -4398,7 +4398,7 @@ fn dragLeftClickTriple( self: *Surface, drag_pin: terminal.Pin, ) !void { - const screen: *terminal.Screen = self.io.terminal.screen; + const screen: *terminal.Screen = self.io.terminal.screens.active; const click_pin = self.mouse.left_click_pin.?.*; // Get the line selection under our current drag point. If there isn't a @@ -4417,7 +4417,7 @@ fn dragLeftClickTriple( } else { sel.endPtr().* = line.end(); } - try self.io.terminal.screen.select(sel); + try self.io.terminal.screens.active.select(sel); } fn dragLeftClickSingle( @@ -4426,7 +4426,7 @@ fn dragLeftClickSingle( drag_x: f64, ) !void { // This logic is in a separate function so that it can be unit tested. - try self.io.terminal.screen.select(mouseSelection( + try self.io.terminal.screens.active.select(mouseSelection( self.mouse.left_click_pin.?.*, drag_pin, @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), @@ -4773,7 +4773,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .copy_to_clipboard => |format| { // We can read from the renderer state without holding // the lock because only we will write to this field. - if (self.io.terminal.screen.selection) |sel| { + if (self.io.terminal.screens.active.selection) |sel| { try self.copySelectionToClipboards( sel, &.{.standard}, @@ -4806,7 +4806,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const url_text = switch (link_info[0]) { .open => url_text: { // For regex links, get the text from selection - break :url_text (self.io.terminal.screen.selectionString(self.alloc, .{ + break :url_text (self.io.terminal.screens.active.selectionString(self.alloc, .{ .sel = link_info[1], .trim = self.config.clipboard_trim_trailing_spaces, })) catch |err| { @@ -4956,7 +4956,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; - t.screen.scroll(.{ .row = n }); + t.screens.active.scroll(.{ .row = n }); } try self.queueRender(); @@ -4966,9 +4966,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - const sel = self.io.terminal.screen.selection orelse return false; - const tl = sel.topLeft(self.io.terminal.screen); - self.io.terminal.screen.scroll(.{ .pin = tl }); + const sel = self.io.terminal.screens.active.selection orelse return false; + const tl = sel.topLeft(self.io.terminal.screens.active); + self.io.terminal.screens.active.scroll(.{ .pin = tl }); } try self.queueRender(); @@ -5177,7 +5177,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ), .select_all => { - const sel = self.io.terminal.screen.selectAll(); + const sel = self.io.terminal.screens.active.selectAll(); if (sel) |s| { try self.setSelection(s); try self.queueRender(); @@ -5221,7 +5221,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - const screen: *terminal.Screen = self.io.terminal.screen; + const screen: *terminal.Screen = self.io.terminal.screens.active; const sel = if (screen.selection) |*sel| sel else { // If we don't have a selection we do not perform this // action, allowing the keybind to fall through to the @@ -5336,7 +5336,7 @@ fn writeScreenFile( // We only dump history if we have history. We still keep // the file and write the empty file to the pty so that this // command always works on the primary screen. - const pages = &self.io.terminal.screen.pages; + const pages = &self.io.terminal.screens.active.pages; const sel_: ?terminal.Selection = switch (loc) { .history => history: { // We do not support this for alternate screens @@ -5362,7 +5362,7 @@ fn writeScreenFile( ); }, - .selection => self.io.terminal.screen.selection, + .selection => self.io.terminal.screens.active.selection, }; const sel = sel_ orelse { @@ -5372,7 +5372,7 @@ fn writeScreenFile( }; const ScreenFormatter = terminal.formatter.ScreenFormatter; - var formatter: ScreenFormatter = .init(self.io.terminal.screen, .{ + var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, .{ .emit = switch (write_screen.emit) { .plain => .plain, .vt => .vt, @@ -5385,7 +5385,7 @@ fn writeScreenFile( .palette = &self.io.terminal.colors.palette.current, }); formatter.content = .{ .selection = sel.ordered( - self.io.terminal.screen, + self.io.terminal.screens.active, .forward, ) }; try formatter.format(buf_writer); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 1a713310f..25d09271e 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1558,7 +1558,7 @@ pub const CAPI = struct { defer core_surface.renderer_state.mutex.unlock(); // If we don't have a selection, do nothing. - const core_sel = core_surface.io.terminal.screen.selection orelse return false; + const core_sel = core_surface.io.terminal.screens.active.selection orelse return false; // Read the text from the selection. return readTextLocked(surface, core_sel, result); @@ -1578,7 +1578,7 @@ pub const CAPI = struct { defer surface.core_surface.renderer_state.mutex.unlock(); const core_sel = sel.core( - surface.core_surface.renderer_state.terminal.screen, + surface.core_surface.renderer_state.terminal.screens.active, ) orelse return false; return readTextLocked(surface, core_sel, result); @@ -2137,7 +2137,7 @@ pub const CAPI = struct { // Get our word selection const sel = sel: { - const screen: *terminal.Screen = surface.renderer_state.terminal.screen; + const screen: *terminal.Screen = surface.renderer_state.terminal.screens.active; const pos = try ptr.getCursorPos(); const pt_viewport = surface.posToViewport(pos.x, pos.y); const pin = screen.pages.pin(.{ @@ -2149,7 +2149,7 @@ pub const CAPI = struct { if (comptime std.debug.runtime_safety) unreachable; return false; }; - break :sel surface.io.terminal.screen.selectWord(pin) orelse return false; + break :sel surface.io.terminal.screens.active.selectWord(pin) orelse return false; }; // Read the selection diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index f3dfee0b6..b63de6f6d 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -57,7 +57,7 @@ pub const Options = struct { .keypad_key_application = t.modes.get(.keypad_keys), .ignore_keypad_with_numlock = t.modes.get(.ignore_keypad_with_numlock), .modify_other_keys_state_2 = t.flags.modify_other_keys_2, - .kitty_flags = t.screen.kitty_keyboard.current(), + .kitty_flags = t.screens.active.kitty_keyboard.current(), // These can't be known from the terminal state. .macos_option_as_alt = .false, diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 0f2371400..3f9888841 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -304,7 +304,7 @@ fn renderScreenWindow(self: *Inspector) void { )) return; const t = self.surface.renderer_state.terminal; - const screen: *terminal.Screen = t.screen; + const screen: *terminal.Screen = t.screens.active; { _ = cimgui.c.igBeginTable( @@ -774,7 +774,7 @@ fn renderSizeWindow(self: *Inspector) void { { const hover_point: terminal.point.Coordinate = pt: { const p = self.mouse.last_point orelse break :pt .{}; - const pt = t.screen.pages.pointFromPin( + const pt = t.screens.active.pages.pointFromPin( .active, p, ) orelse break :pt .{}; @@ -861,7 +861,7 @@ fn renderSizeWindow(self: *Inspector) void { { const left_click_point: terminal.point.Coordinate = pt: { const p = mouse.left_click_pin orelse break :pt .{}; - const pt = t.screen.pages.pointFromPin( + const pt = t.screens.active.pages.pointFromPin( .active, p.*, ) orelse break :pt .{}; diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 840a587bf..7e2b51ee1 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -64,7 +64,7 @@ pub const VTEvent = struct { return .{ .kind = kind, .str = str, - .cursor = t.screen.cursor, + .cursor = t.screens.active.cursor, .scrolling_region = t.scrolling_region, .metadata = md.unmanaged, }; diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index 287b83450..ee79ead29 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -41,7 +41,7 @@ pub fn style( // 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.screen.viewportIsBottom()) return null; + if (!state.terminal.screens.active.viewportIsBottom()) 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 @@ -62,7 +62,7 @@ pub fn style( } // Otherwise, we use whatever style the terminal wants. - return .fromTerminal(state.terminal.screen.cursor.cursor_style); + return .fromTerminal(state.terminal.screens.active.cursor.cursor_style); } test "cursor: default uses configured style" { @@ -71,7 +71,7 @@ test "cursor: default uses configured style" { var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); - term.screen.cursor.cursor_style = .bar; + term.screens.active.cursor.cursor_style = .bar; term.modes.set(.cursor_blinking, true); var state: State = .{ @@ -92,7 +92,7 @@ test "cursor: blinking disabled" { var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); - term.screen.cursor.cursor_style = .bar; + term.screens.active.cursor.cursor_style = .bar; term.modes.set(.cursor_blinking, false); var state: State = .{ @@ -113,7 +113,7 @@ test "cursor: explicitly not visible" { var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer term.deinit(alloc); - term.screen.cursor.cursor_style = .bar; + term.screens.active.cursor.cursor_style = .bar; term.modes.set(.cursor_visible, false); term.modes.set(.cursor_blinking, false); diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 905261b9f..912dcc457 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1102,7 +1102,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // naturally limits the number of calls to this method (it // can be expensive) and also makes it so we don't need another // cross-thread mailbox message within the IO path. - const scrollbar = state.terminal.screen.pages.scrollbar(); + const scrollbar = state.terminal.screens.active.pages.scrollbar(); // Get our bg/fg, swap them if reversed. const RGB = terminal.color.RGB; @@ -1116,12 +1116,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; // Get the viewport pin so that we can compare it to the current. - const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?; + 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.screen.clone( + var screen_copy = try state.terminal.screens.active.clone( self.alloc, .{ .viewport = .{} }, null, @@ -1153,7 +1153,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If we have any virtual references, we must also rebuild our // kitty state on every frame because any cell change can move // an image. - if (state.terminal.screen.kitty_images.dirty or + if (state.terminal.screens.active.kitty_images.dirty or self.image_virtual) { try self.prepKittyGraphics(state.terminal); @@ -1169,7 +1169,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } { const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?; - const v: Int = @bitCast(state.terminal.screen.dirty); + const v: Int = @bitCast(state.terminal.screens.active.dirty); if (v > 0) break :rebuild true; } @@ -1187,9 +1187,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // 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.screen.dirty = .{}; + state.terminal.screens.active.dirty = .{}; { - var it = state.terminal.screen.pages.pageIterator( + var it = state.terminal.screens.active.pages.pageIterator( .right_down, .{ .screen = .{} }, null, @@ -1633,7 +1633,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.draw_mutex.lock(); defer self.draw_mutex.unlock(); - const storage = &t.screen.kitty_images; + const storage = &t.screens.active.kitty_images; defer storage.dirty = false; // We always clear our previous placements no matter what because @@ -1657,10 +1657,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // The top-left and bottom-right corners of our viewport in screen // points. This lets us determine offsets and containment of placements. - const top = t.screen.pages.getTopLeft(.viewport); - const bot = t.screen.pages.getBottomRight(.viewport).?; - const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; - const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; + const top = t.screens.active.pages.getTopLeft(.viewport); + const bot = t.screens.active.pages.getBottomRight(.viewport).?; + const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y; + const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y; // Go through the placements and ensure the image is // on the GPU or else is ready to be sent to the GPU. @@ -1751,7 +1751,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { t: *terminal.Terminal, p: *const terminal.kitty.graphics.unicode.Placement, ) !void { - const storage = &t.screen.kitty_images; + const storage = &t.screens.active.kitty_images; const image = storage.imageById(p.image_id) orelse { log.warn( "missing image for virtual placement, ignoring image_id={}", @@ -1773,7 +1773,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If our placement is zero sized then we don't do anything. if (rp.dest_width == 0 or rp.dest_height == 0) return; - const viewport: terminal.point.Point = t.screen.pages.pointFromPin( + const viewport: terminal.point.Point = t.screens.active.pages.pointFromPin( .viewport, rp.top_left, ) orelse { @@ -1817,8 +1817,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const rect = p.rect(image.*, t) orelse return; // This is expensive but necessary. - const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; // If the selection isn't within our viewport then skip it. if (img_top_y > bot_y) return; diff --git a/src/renderer/link.zig b/src/renderer/link.zig index ec4000f65..39283cf5f 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -609,12 +609,12 @@ test "matchset osc8" { // Initialize our terminal var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - const s: *terminal.Screen = t.screen; + const s: *terminal.Screen = t.screens.active; try t.printString("ABC"); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("123"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); // Get a set var set = try Set.fromConfig(alloc, &.{}); @@ -624,7 +624,7 @@ test "matchset osc8" { { var match = try set.matchSet( alloc, - t.screen, + t.screens.active, .{ .x = 2, .y = 0 }, inputpkg.ctrlOrSuper(.{}), ); @@ -635,7 +635,7 @@ test "matchset osc8" { // Match over link var match = try set.matchSet( alloc, - t.screen, + t.screens.active, .{ .x = 3, .y = 0 }, inputpkg.ctrlOrSuper(.{}), ); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 185537115..f67cb119c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -39,15 +39,6 @@ const log = std.log.scoped(.terminal); /// Default tabstop interval const TABSTOP_INTERVAL = 8; -/// The currently active screen. To get the type of screen this is, -/// inspect screens.active_key instead. -/// -/// Note: long term I'd like to get rid of this and force everyone -/// to go through screens instead but there's SO MUCH code that relies -/// on this property existing and it was really nasty to change all of -/// that today. -screen: *Screen, - /// The set of screens behind this terminal (e.g. primary vs alternate). screens: ScreenSet, @@ -232,7 +223,6 @@ pub fn init( return .{ .cols = cols, .rows = rows, - .screen = screen_set.active, .screens = screen_set, .tabstops = try .init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ @@ -300,17 +290,17 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { } pub fn print(self: *Terminal, c: u21) !void { - // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); + // 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; // After doing any printing, wrapping, scrolling, etc. we want to ensure // that our screen remains in a consistent state. - defer self.screen.assertIntegrity(); + defer self.screens.active.assertIntegrity(); // Our right margin depends where our cursor is now. - const right_limit = if (self.screen.cursor.x > self.scrolling_region.right) + const right_limit = if (self.screens.active.cursor.x > self.scrolling_region.right) self.cols else self.scrolling_region.right + 1; @@ -321,7 +311,7 @@ pub fn print(self: *Terminal, c: u21) !void { // as quickly as possible. if (c > 255 and self.modes.get(.grapheme_cluster) and - self.screen.cursor.x > 0) + self.screens.active.cursor.x > 0) grapheme: { // 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 @@ -336,18 +326,18 @@ pub fn print(self: *Terminal, c: u21) !void { // we're not on the last column, then we just use the previous // column. Otherwise, we need to check if there is text to // figure out if we're attaching to the prev or current. - if (self.screen.cursor.x != right_limit - 1) break :left 1; - break :left @intFromBool(self.screen.cursor.page_cell.codepoint() == 0); + if (self.screens.active.cursor.x != right_limit - 1) break :left 1; + break :left @intFromBool(self.screens.active.cursor.page_cell.codepoint() == 0); }; // If the previous cell is a wide spacer tail, then we actually // want to use the cell before that because that has the actual // content. - const immediate = self.screen.cursorCellLeft(left); + const immediate = self.screens.active.cursorCellLeft(left); break :prev switch (immediate.wide) { else => .{ .cell = immediate, .left = left }, .spacer_tail => .{ - .cell = self.screen.cursorCellLeft(left + 1), + .cell = self.screens.active.cursorCellLeft(left + 1), .left = left + 1, }, }; @@ -361,7 +351,7 @@ pub fn print(self: *Terminal, c: u21) !void { var state: unicode.GraphemeBreakState = .{}; var cp1: u21 = prev.cell.content.codepoint; if (prev.cell.hasGrapheme()) { - const cps = self.screen.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?; + const cps = self.screens.active.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?; for (cps) |cp2| { // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); assert(!unicode.graphemeBreak(cp1, cp2, &state)); @@ -401,12 +391,12 @@ pub fn print(self: *Terminal, c: u21) !void { // Move our cursor back to the previous. We'll move // the cursor within this block to the proper location. - self.screen.cursorLeft(prev.left); + self.screens.active.cursorLeft(prev.left); // If we don't have space for the wide char, we need // to insert spacers and wrap. Then we just print the wide // char as normal. - if (self.screen.cursor.x == right_limit - 1) { + if (self.screens.active.cursor.x == right_limit - 1) { if (!self.modes.get(.wraparound)) return; self.printCell( 0, @@ -418,14 +408,14 @@ pub fn print(self: *Terminal, c: u21) !void { self.printCell(prev.cell.content.codepoint, .wide); // Write our spacer - self.screen.cursorRight(1); + self.screens.active.cursorRight(1); self.printCell(0, .spacer_tail); // Move the cursor again so we're beyond our spacer - if (self.screen.cursor.x == right_limit - 1) { - self.screen.cursor.pending_wrap = true; + if (self.screens.active.cursor.x == right_limit - 1) { + self.screens.active.cursor.pending_wrap = true; } else { - self.screen.cursorRight(1); + self.screens.active.cursorRight(1); } }, @@ -435,7 +425,7 @@ pub fn print(self: *Terminal, c: u21) !void { prev.cell.wide = .narrow; // Remove the wide spacer tail - const cell = self.screen.cursorCellLeft(prev.left - 1); + const cell = self.screens.active.cursorCellLeft(prev.left - 1); cell.wide = .narrow; // Back track the cursor so that we don't end up with @@ -445,15 +435,15 @@ pub fn print(self: *Terminal, c: u21) !void { // least surprise, and also matches the behavior that // can be observed in Kitty, which is one of the only // other VS aware terminals. - if (self.screen.cursor.x == right_limit - 1) { + if (self.screens.active.cursor.x == right_limit - 1) { // If we're already at the right edge, we stay // here and set the pending wrap to false since // when we pend a wrap, we only move our cursor once // even for wide chars (tests verify). - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; } else { // Otherwise, move back. - self.screen.cursorLeft(1); + self.screens.active.cursorLeft(1); } break :narrow; @@ -468,8 +458,8 @@ pub fn print(self: *Terminal, c: u21) !void { prev.left, prev.cell.codepoint(), }); - self.screen.cursorMarkDirty(); - try self.screen.appendGrapheme(prev.cell, c); + self.screens.active.cursorMarkDirty(); + try self.screens.active.appendGrapheme(prev.cell, c); return; } } @@ -497,16 +487,16 @@ pub fn print(self: *Terminal, c: u21) !void { // print anything or even store this. Zero-width characters are ALWAYS // attached to some other non-zero-width character at the time of // writing. - if (self.screen.cursor.x == 0) { + if (self.screens.active.cursor.x == 0) { log.warn("zero-width character with no prior character, ignoring", .{}); return; } // Find our previous cell const prev = prev: { - const immediate = self.screen.cursorCellLeft(1); + const immediate = self.screens.active.cursorCellLeft(1); if (immediate.wide != .spacer_tail) break :prev immediate; - break :prev self.screen.cursorCellLeft(2); + break :prev self.screens.active.cursorCellLeft(2); }; // If our previous cell has no text, just ignore the zero-width character @@ -522,7 +512,7 @@ pub fn print(self: *Terminal, c: u21) !void { if (!emoji) return; } - try self.screen.appendGrapheme(prev, c); + try self.screens.active.appendGrapheme(prev, c); return; } @@ -530,14 +520,14 @@ pub fn print(self: *Terminal, c: u21) !void { self.previous_char = c; // If we're soft-wrapping, then handle that first. - if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) { + if (self.screens.active.cursor.pending_wrap and self.modes.get(.wraparound)) { try self.printWrap(); } // If we have insert mode enabled then we need to handle that. We // only do insert mode if we're not at the end of the line. if (self.modes.get(.insert) and - self.screen.cursor.x + width < self.cols) + self.screens.active.cursor.x + width < self.cols) { self.insertBlanks(width); } @@ -545,7 +535,7 @@ pub fn print(self: *Terminal, c: u21) !void { switch (width) { // Single cell is very easy: just write in the cell 1 => { - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); @call(.always_inline, printCell, .{ self, c, .narrow }); }, @@ -557,7 +547,7 @@ pub fn print(self: *Terminal, c: u21) !void { // If we don't have space for the wide char, we need // to insert spacers and wrap. Then we just print the wide // char as normal. - if (self.screen.cursor.x == right_limit - 1) { + if (self.screens.active.cursor.x == right_limit - 1) { // If we don't have wraparound enabled then we don't print // this character at all and don't move the cursor. This is // how xterm behaves. @@ -570,14 +560,14 @@ pub fn print(self: *Terminal, c: u21) !void { try self.printWrap(); } - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); self.printCell(c, .wide); - self.screen.cursorRight(1); + self.screens.active.cursorRight(1); self.printCell(0, .spacer_tail); } else { // This is pretty broken, terminals should never be only 1-wide. // We should prevent this downstream. - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); self.printCell(0, .narrow); }, @@ -586,13 +576,13 @@ pub fn print(self: *Terminal, c: u21) !void { // If we're at the column limit, then we need to wrap the next time. // In this case, we don't move the cursor. - if (self.screen.cursor.x == right_limit - 1) { - self.screen.cursor.pending_wrap = true; + if (self.screens.active.cursor.x == right_limit - 1) { + self.screens.active.cursor.pending_wrap = true; return; } // Move the cursor - self.screen.cursorRight(1); + self.screens.active.cursorRight(1); } fn printCell( @@ -600,7 +590,7 @@ fn printCell( unmapped_c: u21, wide: Cell.Wide, ) void { - defer self.screen.assertIntegrity(); + defer self.screens.active.assertIntegrity(); // TODO: spacers should use a bgcolor only cell @@ -608,11 +598,11 @@ fn printCell( // TODO: non-utf8 handling, gr // If we're single shifting, then we use the key exactly once. - const key = if (self.screen.charset.single_shift) |key_once| blk: { - self.screen.charset.single_shift = null; + const key = if (self.screens.active.charset.single_shift) |key_once| blk: { + self.screens.active.charset.single_shift = null; break :blk key_once; - } else self.screen.charset.gl; - const set = self.screen.charset.charsets.get(key); + } 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; @@ -626,7 +616,7 @@ fn printCell( break :c @intCast(table[@intCast(unmapped_c)]); }; - const cell = self.screen.cursor.page_cell; + const cell = self.screens.active.cursor.page_cell; // If the wide property of this cell is the same, then we don't // need to do the special handling here because the structure will @@ -639,22 +629,22 @@ fn printCell( // Previous cell was wide. We need to clear the tail and head. .wide => wide: { - if (self.screen.cursor.x >= self.cols - 1) break :wide; + if (self.screens.active.cursor.x >= self.cols - 1) break :wide; - const spacer_cell = self.screen.cursorCellRight(1); - self.screen.clearCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + const spacer_cell = self.screens.active.cursorCellRight(1); + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, spacer_cell[0..1], ); - if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - const head_cell = self.screen.cursorCellEndOfPrev(); + if (self.screens.active.cursor.y > 0 and self.screens.active.cursor.x <= 1) { + const head_cell = self.screens.active.cursorCellEndOfPrev(); head_cell.wide = .narrow; } }, .spacer_tail => { - assert(self.screen.cursor.x > 0); + assert(self.screens.active.cursor.x > 0); // So integrity checks pass. We fix this up later so we don't // need to do this without safety checks. @@ -662,14 +652,14 @@ fn printCell( cell.wide = .narrow; } - const wide_cell = self.screen.cursorCellLeft(1); - self.screen.clearCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + const wide_cell = self.screens.active.cursorCellLeft(1); + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, wide_cell[0..1], ); - if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - const head_cell = self.screen.cursorCellEndOfPrev(); + if (self.screens.active.cursor.y > 0 and self.screens.active.cursor.x <= 1) { + const head_cell = self.screens.active.cursorCellEndOfPrev(); head_cell.wide = .narrow; } }, @@ -683,21 +673,21 @@ fn printCell( // If the prior value had graphemes, clear those if (cell.hasGrapheme()) { - self.screen.cursor.page_pin.node.data.clearGrapheme( - self.screen.cursor.page_row, + self.screens.active.cursor.page_pin.node.data.clearGrapheme( + self.screens.active.cursor.page_row, cell, ); } // We don't need to update the style refs unless the // cell's new style will be different after writing. - const style_changed = cell.style_id != self.screen.cursor.style_id; + const style_changed = cell.style_id != self.screens.active.cursor.style_id; if (style_changed) { - var page = &self.screen.cursor.page_pin.node.data; + var page = &self.screens.active.cursor.page_pin.node.data; // Release the old style. if (cell.style_id != style.default_id) { - assert(self.screen.cursor.page_row.styled); + assert(self.screens.active.cursor.page_row.styled); page.styles.release(page.memory, cell.style_id); } } @@ -709,18 +699,18 @@ fn printCell( cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = c }, - .style_id = self.screen.cursor.style_id, + .style_id = self.screens.active.cursor.style_id, .wide = wide, - .protected = self.screen.cursor.protected, + .protected = self.screens.active.cursor.protected, }; if (style_changed) { - var page = &self.screen.cursor.page_pin.node.data; + var page = &self.screens.active.cursor.page_pin.node.data; // Use the new style. if (cell.style_id != style.default_id) { page.styles.use(page.memory, cell.style_id); - self.screen.cursor.page_row.styled = true; + self.screens.active.cursor.page_row.styled = true; } } @@ -728,22 +718,22 @@ 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) { - self.screen.cursor.page_row.kitty_virtual_placeholder = true; + self.screens.active.cursor.page_row.kitty_virtual_placeholder = true; } } // We check for an active hyperlink first because setHyperlink // handles clearing the old hyperlink and an optimization if we're // overwriting the same hyperlink. - if (self.screen.cursor.hyperlink_id > 0) { - self.screen.cursorSetHyperlink() catch |err| { + if (self.screens.active.cursor.hyperlink_id > 0) { + self.screens.active.cursorSetHyperlink() catch |err| { log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err}); assert(!cell.hyperlink); }; } else if (had_hyperlink) { // If the previous cell had a hyperlink then we need to clear it. - var page = &self.screen.cursor.page_pin.node.data; - page.clearHyperlink(self.screen.cursor.page_row, cell); + var page = &self.screens.active.cursor.page_pin.node.data; + page.clearHyperlink(self.screens.active.cursor.page_row, cell); } } @@ -751,31 +741,31 @@ fn printWrap(self: *Terminal) !void { // We only mark that we soft-wrapped if we're at the edge of our // full screen. We don't mark the row as wrapped if we're in the // middle due to a right margin. - const mark_wrap = self.screen.cursor.x == self.cols - 1; - if (mark_wrap) self.screen.cursor.page_row.wrap = true; + const mark_wrap = self.screens.active.cursor.x == self.cols - 1; + if (mark_wrap) self.screens.active.cursor.page_row.wrap = true; // Get the old semantic prompt so we can extend it to the next // line. We need to do this before we index() because we may // modify memory. - const old_prompt = self.screen.cursor.page_row.semantic_prompt; + const old_prompt = self.screens.active.cursor.page_row.semantic_prompt; // Move to the next line try self.index(); - self.screen.cursorHorizontalAbsolute(self.scrolling_region.left); + self.screens.active.cursorHorizontalAbsolute(self.scrolling_region.left); if (mark_wrap) { // New line must inherit semantic prompt of the old line - self.screen.cursor.page_row.semantic_prompt = old_prompt; - self.screen.cursor.page_row.wrap_continuation = true; + self.screens.active.cursor.page_row.semantic_prompt = old_prompt; + self.screens.active.cursor.page_row.wrap_continuation = true; } // Assure that our screen is consistent - self.screen.assertIntegrity(); + self.screens.active.assertIntegrity(); } /// Set the charset into the given slot. pub fn configureCharset(self: *Terminal, slot: charsets.Slots, set: charsets.Charset) void { - self.screen.charset.charsets.set(slot, set); + self.screens.active.charset.charsets.set(slot, set); } /// Invoke the charset in slot into the active slot. If single is true, @@ -788,25 +778,25 @@ pub fn invokeCharset( ) void { if (single) { assert(active == .GL); - self.screen.charset.single_shift = slot; + self.screens.active.charset.single_shift = slot; return; } switch (active) { - .GL => self.screen.charset.gl = slot, - .GR => self.screen.charset.gr = slot, + .GL => self.screens.active.charset.gl = slot, + .GR => self.screens.active.charset.gr = slot, } } /// Carriage return moves the cursor to the first column. pub fn carriageReturn(self: *Terminal) void { // Always reset pending wrap state - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // In origin mode we always move to the left margin - self.screen.cursorHorizontalAbsolute(if (self.modes.get(.origin)) + self.screens.active.cursorHorizontalAbsolute(if (self.modes.get(.origin)) self.scrolling_region.left - else if (self.screen.cursor.x >= self.scrolling_region.left) + else if (self.screens.active.cursor.x >= self.scrolling_region.left) self.scrolling_region.left else 0); @@ -828,17 +818,17 @@ pub fn backspace(self: *Terminal) void { /// 0, adjust it to 1. pub fn cursorUp(self: *Terminal, count_req: usize) void { // Always resets pending wrap - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // The maximum amount the cursor can move up depends on scrolling regions - const max = if (self.screen.cursor.y >= self.scrolling_region.top) - self.screen.cursor.y - self.scrolling_region.top + const max = if (self.screens.active.cursor.y >= self.scrolling_region.top) + self.screens.active.cursor.y - self.scrolling_region.top else - self.screen.cursor.y; + self.screens.active.cursor.y; const count = @min(max, @max(count_req, 1)); // We can safely intCast below because of the min/max clamping we did above. - self.screen.cursorUp(@intCast(count)); + self.screens.active.cursorUp(@intCast(count)); } /// Move the cursor down amount lines. If amount is greater than the maximum @@ -846,15 +836,15 @@ pub fn cursorUp(self: *Terminal, count_req: usize) void { /// will not scroll the screen or scroll region. If amount is 0, adjust it to 1. pub fn cursorDown(self: *Terminal, count_req: usize) void { // Always resets pending wrap - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // The max the cursor can move to depends where the cursor currently is - const max = if (self.screen.cursor.y <= self.scrolling_region.bottom) - self.scrolling_region.bottom - self.screen.cursor.y + const max = if (self.screens.active.cursor.y <= self.scrolling_region.bottom) + self.scrolling_region.bottom - self.screens.active.cursor.y else - self.rows - self.screen.cursor.y - 1; + self.rows - self.screens.active.cursor.y - 1; const count = @min(max, @max(count_req, 1)); - self.screen.cursorDown(@intCast(count)); + self.screens.active.cursorDown(@intCast(count)); } /// Move the cursor right amount columns. If amount is greater than the @@ -863,15 +853,15 @@ pub fn cursorDown(self: *Terminal, count_req: usize) void { /// 0, adjust it to 1. pub fn cursorRight(self: *Terminal, count_req: usize) void { // Always resets pending wrap - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // The max the cursor can move to depends where the cursor currently is - const max = if (self.screen.cursor.x <= self.scrolling_region.right) - self.scrolling_region.right - self.screen.cursor.x + const max = if (self.screens.active.cursor.x <= self.scrolling_region.right) + self.scrolling_region.right - self.screens.active.cursor.x else - self.cols - self.screen.cursor.x - 1; + self.cols - self.screens.active.cursor.x - 1; const count = @min(max, @max(count_req, 1)); - self.screen.cursorRight(@intCast(count)); + self.screens.active.cursorRight(@intCast(count)); } /// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. @@ -890,34 +880,34 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { // If we are in no wrap mode, then we move the cursor left and exit // since this is the fastest and most typical path. if (wrap_mode == .none) { - self.screen.cursorLeft(@min(count, self.screen.cursor.x)); - self.screen.cursor.pending_wrap = false; + self.screens.active.cursorLeft(@min(count, self.screens.active.cursor.x)); + self.screens.active.cursor.pending_wrap = false; return; } // If we have a pending wrap state and we are in either reverse wrap // modes then we decrement the amount we move by one to match xterm. - if (self.screen.cursor.pending_wrap) { + if (self.screens.active.cursor.pending_wrap) { count -= 1; - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; } // The margins we can move to. const top = self.scrolling_region.top; const bottom = self.scrolling_region.bottom; const right_margin = self.scrolling_region.right; - const left_margin = if (self.screen.cursor.x < self.scrolling_region.left) + const left_margin = if (self.screens.active.cursor.x < self.scrolling_region.left) 0 else self.scrolling_region.left; // Handle some edge cases when our cursor is already on the left margin. - if (self.screen.cursor.x == left_margin) { + if (self.screens.active.cursor.x == left_margin) { switch (wrap_mode) { // In reverse mode, if we're already before the top margin // then we just set our cursor to the top-left and we're done. - .reverse => if (self.screen.cursor.y <= top) { - self.screen.cursorAbsolute(left_margin, top); + .reverse => if (self.screens.active.cursor.y <= top) { + self.screens.active.cursorAbsolute(left_margin, top); return; }, @@ -931,22 +921,22 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { while (true) { // We can move at most to the left margin. - const max = self.screen.cursor.x - left_margin; + const max = self.screens.active.cursor.x - left_margin; // We want to move at most the number of columns we have left // or our remaining count. Do the move. const amount = @min(max, count); count -= amount; - self.screen.cursorLeft(amount); + self.screens.active.cursorLeft(amount); // If we have no more to move, then we're done. if (count == 0) break; // If we are at the top, then we are done. - if (self.screen.cursor.y == top) { + if (self.screens.active.cursor.y == top) { if (wrap_mode != .reverse_extended) break; - self.screen.cursorAbsolute(right_margin, bottom); + self.screens.active.cursorAbsolute(right_margin, bottom); count -= 1; continue; } @@ -958,18 +948,18 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { // up to the (0, 0) and stopping there. My reasoning is that for an // appropriately sized value of "count" this is the behavior that xterm // would have. This is unit tested. - if (self.screen.cursor.y == 0) { - assert(self.screen.cursor.x == left_margin); + if (self.screens.active.cursor.y == 0) { + assert(self.screens.active.cursor.x == left_margin); break; } // If our previous line is not wrapped then we are done. if (wrap_mode != .reverse_extended) { - const prev_row = self.screen.cursorRowUp(1); + const prev_row = self.screens.active.cursorRowUp(1); if (!prev_row.wrap) break; } - self.screen.cursorAbsolute(right_margin, self.screen.cursor.y - 1); + self.screens.active.cursorAbsolute(right_margin, self.screens.active.cursor.y - 1); count -= 1; } } @@ -980,14 +970,14 @@ pub fn cursorLeft(self: *Terminal, count_req: usize) void { /// is kept per screen (main / alternative). If for the current screen state /// was already saved it is overwritten. pub fn saveCursor(self: *Terminal) void { - self.screen.saved_cursor = .{ - .x = self.screen.cursor.x, - .y = self.screen.cursor.y, - .style = self.screen.cursor.style, - .protected = self.screen.cursor.protected, - .pending_wrap = self.screen.cursor.pending_wrap, + self.screens.active.saved_cursor = .{ + .x = self.screens.active.cursor.x, + .y = self.screens.active.cursor.y, + .style = self.screens.active.cursor.style, + .protected = self.screens.active.cursor.protected, + .pending_wrap = self.screens.active.cursor.pending_wrap, .origin = self.modes.get(.origin), - .charset = self.screen.charset, + .charset = self.screens.active.charset, }; } @@ -996,7 +986,7 @@ pub fn saveCursor(self: *Terminal) void { /// The primary and alternate screen have distinct save state. /// If no save was done before values are reset to their initial values. pub fn restoreCursor(self: *Terminal) !void { - const saved: Screen.SavedCursor = self.screen.saved_cursor orelse .{ + const saved: Screen.SavedCursor = self.screens.active.saved_cursor orelse .{ .x = 0, .y = 0, .style = .{}, @@ -1007,29 +997,29 @@ pub fn restoreCursor(self: *Terminal) !void { }; // Set the style first because it can fail - const old_style = self.screen.cursor.style; - self.screen.cursor.style = saved.style; - errdefer self.screen.cursor.style = old_style; - try self.screen.manualStyleUpdate(); + const old_style = self.screens.active.cursor.style; + self.screens.active.cursor.style = saved.style; + errdefer self.screens.active.cursor.style = old_style; + try self.screens.active.manualStyleUpdate(); - self.screen.charset = saved.charset; + self.screens.active.charset = saved.charset; self.modes.set(.origin, saved.origin); - self.screen.cursor.pending_wrap = saved.pending_wrap; - self.screen.cursor.protected = saved.protected; - self.screen.cursorAbsolute( + self.screens.active.cursor.pending_wrap = saved.pending_wrap; + self.screens.active.cursor.protected = saved.protected; + self.screens.active.cursorAbsolute( @min(saved.x, self.cols - 1), @min(saved.y, self.rows - 1), ); // Ensure our screen is consistent - self.screen.assertIntegrity(); + self.screens.active.assertIntegrity(); } /// Set the character protection mode for the terminal. pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { switch (mode) { .off => { - self.screen.cursor.protected = false; + self.screens.active.cursor.protected = false; // screen.protected_mode is NEVER reset to ".off" because // logic such as eraseChars depends on knowing what the @@ -1037,13 +1027,13 @@ pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { }, .iso => { - self.screen.cursor.protected = true; - self.screen.protected_mode = .iso; + self.screens.active.cursor.protected = true; + self.screens.active.protected_mode = .iso; }, .dec => { - self.screen.cursor.protected = true; - self.screen.protected_mode = .dec; + self.screens.active.cursor.protected = true; + self.screens.active.protected_mode = .dec; }, } } @@ -1064,8 +1054,8 @@ pub const SemanticPrompt = enum { /// (OSC 133) only allow setting this for wherever the current active cursor /// is located. pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { - //log.debug("semantic_prompt y={} p={}", .{ self.screen.cursor.y, p }); - self.screen.cursor.page_row.semantic_prompt = switch (p) { + //log.debug("semantic_prompt y={} p={}", .{ self.screens.active.cursor.y, p }); + self.screens.active.cursor.page_row.semantic_prompt = switch (p) { .prompt => .prompt, .prompt_continuation => .prompt_continuation, .input => .input, @@ -1083,12 +1073,12 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { if (self.screens.active_key == .alternate) return false; // Reverse through the active - const start_x, const start_y = .{ self.screen.cursor.x, self.screen.cursor.y }; - defer self.screen.cursorAbsolute(start_x, start_y); + const start_x, const start_y = .{ self.screens.active.cursor.x, self.screens.active.cursor.y }; + defer self.screens.active.cursorAbsolute(start_x, start_y); for (0..start_y + 1) |i| { - if (i > 0) self.screen.cursorUp(1); - switch (self.screen.cursor.page_row.semantic_prompt) { + if (i > 0) self.screens.active.cursorUp(1); + switch (self.screens.active.cursor.page_row.semantic_prompt) { // If we're at a prompt or input area, then we are at a prompt. .prompt, .prompt_continuation, @@ -1110,14 +1100,14 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { /// Horizontal tab moves the cursor to the next tabstop, clearing /// the screen to the left the tabstop. pub fn horizontalTab(self: *Terminal) !void { - while (self.screen.cursor.x < self.scrolling_region.right) { + while (self.screens.active.cursor.x < self.scrolling_region.right) { // Move the cursor right - self.screen.cursorRight(1); + self.screens.active.cursorRight(1); // If the last cursor position was a tabstop we return. We do // "last cursor position" because we want a space to be written // at the tabstop unless we're at the end (the while condition). - if (self.tabstops.get(self.screen.cursor.x)) return; + if (self.tabstops.get(self.screens.active.cursor.x)) return; } } @@ -1128,18 +1118,18 @@ pub fn horizontalTabBack(self: *Terminal) !void { while (true) { // If we're already at the edge of the screen, then we're done. - if (self.screen.cursor.x <= left_limit) return; + if (self.screens.active.cursor.x <= left_limit) return; // Move the cursor left - self.screen.cursorLeft(1); - if (self.tabstops.get(self.screen.cursor.x)) return; + self.screens.active.cursorLeft(1); + if (self.tabstops.get(self.screens.active.cursor.x)) return; } } /// Clear tab stops. pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { switch (cmd) { - .current => self.tabstops.unset(self.screen.cursor.x), + .current => self.tabstops.unset(self.screens.active.cursor.x), .all => self.tabstops.reset(0), else => log.warn("invalid or unknown tab clear setting: {}", .{cmd}), } @@ -1148,7 +1138,7 @@ pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { /// Set a tab stop on the current cursor. /// TODO: test pub fn tabSet(self: *Terminal) void { - self.tabstops.set(self.screen.cursor.x); + self.tabstops.set(self.screens.active.cursor.x); } /// TODO: test @@ -1170,16 +1160,16 @@ pub fn tabReset(self: *Terminal) void { /// This unsets the pending wrap state without wrapping. pub fn index(self: *Terminal) !void { // Unset pending wrap state - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // Outside of the scroll region we move the cursor one line down. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom) + if (self.screens.active.cursor.y < self.scrolling_region.top or + self.screens.active.cursor.y > self.scrolling_region.bottom) { // We only move down if we're not already at the bottom of // the screen. - if (self.screen.cursor.y < self.rows - 1) { - self.screen.cursorDown(1); + if (self.screens.active.cursor.y < self.rows - 1) { + self.screens.active.cursorDown(1); } return; @@ -1188,13 +1178,13 @@ pub fn index(self: *Terminal) !void { // If the cursor is inside the scrolling region and on the bottom-most // line, then we scroll up. If our scrolling region is the full screen // we create scrollback. - if (self.screen.cursor.y == self.scrolling_region.bottom and - self.screen.cursor.x >= self.scrolling_region.left and - self.screen.cursor.x <= self.scrolling_region.right) + if (self.screens.active.cursor.y == self.scrolling_region.bottom and + self.screens.active.cursor.x >= self.scrolling_region.left and + self.screens.active.cursor.x <= self.scrolling_region.right) { if (comptime build_options.kitty_graphics) { // Scrolling dirties the images because it updates their placements pins. - self.screen.kitty_images.dirty = true; + self.screens.active.kitty_images.dirty = true; } // If our scrolling region is at the top, we create scrollback. @@ -1202,7 +1192,7 @@ pub fn index(self: *Terminal) !void { self.scrolling_region.left == 0 and self.scrolling_region.right == self.cols - 1) { - try self.screen.cursorScrollAbove(); + try self.screens.active.cursorScrollAbove(); return; } @@ -1216,7 +1206,7 @@ pub fn index(self: *Terminal) !void { // However, scrollUp is WAY slower. We should optimize this // case to work in the eraseRowBounded codepath and remove // this check. - !self.screen.blankCell().isZero()) + !self.screens.active.blankCell().isZero()) { self.scrollUp(1); return; @@ -1226,9 +1216,9 @@ pub fn index(self: *Terminal) !void { // scroll the contents of the scrolling region. // Preserve old cursor just for assertions - const old_cursor = self.screen.cursor; + const old_cursor = self.screens.active.cursor; - try self.screen.pages.eraseRowBounded( + try self.screens.active.pages.eraseRowBounded( .{ .active = .{ .y = self.scrolling_region.top } }, self.scrolling_region.bottom - self.scrolling_region.top, ); @@ -1237,26 +1227,26 @@ pub fn index(self: *Terminal) !void { // up by 1, so we need to move it back down. A `cursorReload` // would be better option but this is more efficient and this is // a super hot path so we do this instead. - assert(self.screen.cursor.x == old_cursor.x); - assert(self.screen.cursor.y == old_cursor.y); - self.screen.cursor.y -= 1; - self.screen.cursorDown(1); + assert(self.screens.active.cursor.x == old_cursor.x); + assert(self.screens.active.cursor.y == old_cursor.y); + self.screens.active.cursor.y -= 1; + self.screens.active.cursorDown(1); // The operations above can prune our cursor style so we need to // update. This should never fail because the above can only FREE // memory. - self.screen.manualStyleUpdate() catch |err| { + self.screens.active.manualStyleUpdate() catch |err| { std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); - self.screen.cursor.style = .{}; - self.screen.manualStyleUpdate() catch unreachable; + self.screens.active.cursor.style = .{}; + self.screens.active.manualStyleUpdate() catch unreachable; }; return; } // Increase cursor by 1, maximum to bottom of scroll region - if (self.screen.cursor.y < self.scrolling_region.bottom) { - self.screen.cursorDown(1); + if (self.screens.active.cursor.y < self.scrolling_region.bottom) { + self.screens.active.cursorDown(1); } } @@ -1273,9 +1263,9 @@ pub fn index(self: *Terminal) !void { /// * If the cursor is not on the top-most line of the scrolling region: /// move the cursor one line up pub fn reverseIndex(self: *Terminal) void { - if (self.screen.cursor.y != self.scrolling_region.top or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) + if (self.screens.active.cursor.y != self.scrolling_region.top or + self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) { self.cursorUp(1); return; @@ -1314,7 +1304,7 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { }; // Unset pending wrap state - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // Calculate our new x/y const row = if (row_req == 0) 1 else row_req; @@ -1323,19 +1313,19 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { const y = @min(params.y_max, row + params.y_offset) -| 1; // If the y is unchanged then this is fast pointer math - if (y == self.screen.cursor.y) { - if (x > self.screen.cursor.x) { - self.screen.cursorRight(x - self.screen.cursor.x); + if (y == self.screens.active.cursor.y) { + if (x > self.screens.active.cursor.x) { + self.screens.active.cursorRight(x - self.screens.active.cursor.x); } else { - self.screen.cursorLeft(self.screen.cursor.x - x); + self.screens.active.cursorLeft(self.screens.active.cursor.x - x); } return; } // If everything changed we do an absolute change which is slightly slower - self.screen.cursorAbsolute(x, y); - // log.info("set cursor position: col={} row={}", .{ self.screen.cursor.x, self.screen.cursor.y }); + self.screens.active.cursorAbsolute(x, y); + // log.info("set cursor position: col={} row={}", .{ self.screens.active.cursor.x, self.screens.active.cursor.y }); } /// Set Top and Bottom Margins If bottom is not specified, 0 or bigger than @@ -1377,16 +1367,16 @@ pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) /// Scroll the text down by one row. pub fn scrollDown(self: *Terminal, count: usize) void { // Preserve our x/y to restore. - const old_x = self.screen.cursor.x; - const old_y = self.screen.cursor.y; - const old_wrap = self.screen.cursor.pending_wrap; + const old_x = self.screens.active.cursor.x; + const old_y = self.screens.active.cursor.y; + const old_wrap = self.screens.active.cursor.pending_wrap; defer { - self.screen.cursorAbsolute(old_x, old_y); - self.screen.cursor.pending_wrap = old_wrap; + self.screens.active.cursorAbsolute(old_x, old_y); + self.screens.active.cursor.pending_wrap = old_wrap; } // Move to the top of the scroll region - self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); self.insertLines(count); } @@ -1399,16 +1389,16 @@ pub fn scrollDown(self: *Terminal, count: usize) void { /// Does not change the (absolute) cursor position. pub fn scrollUp(self: *Terminal, count: usize) void { // Preserve our x/y to restore. - const old_x = self.screen.cursor.x; - const old_y = self.screen.cursor.y; - const old_wrap = self.screen.cursor.pending_wrap; + const old_x = self.screens.active.cursor.x; + const old_y = self.screens.active.cursor.y; + const old_wrap = self.screens.active.cursor.pending_wrap; defer { - self.screen.cursorAbsolute(old_x, old_y); - self.screen.cursor.pending_wrap = old_wrap; + self.screens.active.cursorAbsolute(old_x, old_y); + self.screens.active.cursor.pending_wrap = old_wrap; } // Move to the top of the scroll region - self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); self.deleteLines(count); } @@ -1426,7 +1416,7 @@ pub const ScrollViewport = union(enum) { /// Scroll the viewport of the terminal grid. pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void { - self.screen.scroll(switch (behavior) { + self.screens.active.scroll(switch (behavior) { .top => .{ .top = {} }, .bottom => .{ .active = {} }, .delta => |delta| .{ .delta_row = delta }, @@ -1518,23 +1508,23 @@ pub fn insertLines(self: *Terminal, count: usize) void { if (count == 0) return; // If the cursor is outside the scroll region we do nothing. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; + if (self.screens.active.cursor.y < self.scrolling_region.top or + self.screens.active.cursor.y > self.scrolling_region.bottom or + self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; if (comptime build_options.kitty_graphics) { // Scrolling dirties the images because it updates their placements pins. - self.screen.kitty_images.dirty = true; + self.screens.active.kitty_images.dirty = true; } // At the end we need to return the cursor to the row it started on. - const start_y = self.screen.cursor.y; + const start_y = self.screens.active.cursor.y; defer { - self.screen.cursorAbsolute(self.scrolling_region.left, start_y); + self.screens.active.cursorAbsolute(self.scrolling_region.left, start_y); // Always unset pending wrap - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; } // We have a slower path if we have left or right scroll margins. @@ -1542,7 +1532,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { self.scrolling_region.right < self.cols - 1; // Remaining rows from our cursor to the bottom of the scroll region. - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + const rem = self.scrolling_region.bottom - self.screens.active.cursor.y + 1; // We can only insert lines up to our remaining lines in the scroll // region. So we take whichever is smaller. @@ -1550,8 +1540,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { // Create a new tracked pin which we'll use to navigate the page list // so that if we need to adjust capacity it will be properly tracked. - var cur_p = self.screen.pages.trackPin( - self.screen.cursor.page_pin.down(rem - 1).?, + var cur_p = self.screens.active.pages.trackPin( + self.screens.active.cursor.page_pin.down(rem - 1).?, ) catch |err| { comptime assert(@TypeOf(err) == error{OutOfMemory}); @@ -1563,7 +1553,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { log.err("insertLines trackPin error err={}", .{err}); @panic("insertLines trackPin OOM"); }; - defer self.screen.pages.untrackPin(cur_p); + defer self.screens.active.pages.untrackPin(cur_p); // Our current y position relative to the cursor var y: usize = rem; @@ -1611,7 +1601,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { const cap = dst_p.node.data.capacity; // Adjust our page capacity to make // room for we didn't have space for - _ = self.screen.adjustCapacity( + _ = self.screens.active.adjustCapacity( dst_p.node, switch (err) { // Rehash the sets @@ -1689,7 +1679,7 @@ pub fn insertLines(self: *Terminal, count: usize) void { // Clear the cells for this row, it has been shifted. const page = &cur_p.node.data; const cells = page.getCells(cur_row); - self.screen.clearCells( + self.screens.active.clearCells( page, cur_row, cells[self.scrolling_region.left .. self.scrolling_region.right + 1], @@ -1724,22 +1714,22 @@ pub fn deleteLines(self: *Terminal, count: usize) void { if (count == 0) return; // If the cursor is outside the scroll region we do nothing. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; + if (self.screens.active.cursor.y < self.scrolling_region.top or + self.screens.active.cursor.y > self.scrolling_region.bottom or + self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; if (comptime build_options.kitty_graphics) { // Scrolling dirties the images because it updates their placements pins. - self.screen.kitty_images.dirty = true; + self.screens.active.kitty_images.dirty = true; } // At the end we need to return the cursor to the row it started on. - const start_y = self.screen.cursor.y; + const start_y = self.screens.active.cursor.y; defer { - self.screen.cursorAbsolute(self.scrolling_region.left, start_y); + self.screens.active.cursorAbsolute(self.scrolling_region.left, start_y); // Always unset pending wrap - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; } // We have a slower path if we have left or right scroll margins. @@ -1747,7 +1737,7 @@ pub fn deleteLines(self: *Terminal, count: usize) void { self.scrolling_region.right < self.cols - 1; // Remaining rows from our cursor to the bottom of the scroll region. - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + const rem = self.scrolling_region.bottom - self.screens.active.cursor.y + 1; // We can only insert lines up to our remaining lines in the scroll // region. So we take whichever is smaller. @@ -1755,15 +1745,15 @@ pub fn deleteLines(self: *Terminal, count: usize) void { // Create a new tracked pin which we'll use to navigate the page list // so that if we need to adjust capacity it will be properly tracked. - var cur_p = self.screen.pages.trackPin( - self.screen.cursor.page_pin.*, + var cur_p = self.screens.active.pages.trackPin( + self.screens.active.cursor.page_pin.*, ) catch |err| { // See insertLines comptime assert(@TypeOf(err) == error{OutOfMemory}); log.err("deleteLines trackPin error err={}", .{err}); @panic("deleteLines trackPin OOM"); }; - defer self.screen.pages.untrackPin(cur_p); + defer self.screens.active.pages.untrackPin(cur_p); // Our current y position relative to the cursor var y: usize = 0; @@ -1811,7 +1801,7 @@ pub fn deleteLines(self: *Terminal, count: usize) void { const cap = dst_p.node.data.capacity; // Adjust our page capacity to make // room for we didn't have space for - _ = self.screen.adjustCapacity( + _ = self.screens.active.adjustCapacity( dst_p.node, switch (err) { // Rehash the sets @@ -1884,7 +1874,7 @@ pub fn deleteLines(self: *Terminal, count: usize) void { // Clear the cells for this row, it's from out of bounds. const page = &cur_p.node.data; const cells = page.getCells(cur_row); - self.screen.clearCells( + self.screens.active.clearCells( page, cur_row, cells[self.scrolling_region.left .. self.scrolling_region.right + 1], @@ -1909,35 +1899,35 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // Unset pending wrap state without wrapping. Note: this purposely // happens BEFORE the scroll region check below, because that's what // xterm does. - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // If our cursor is outside the margins then do nothing. We DO reset // wrap state still so this must remain below the above logic. - if (self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; + if (self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; // If our count is larger than the remaining amount, we just erase right. // We only do this if we can erase the entire line (no right margin). // if (right_limit == self.cols and - // count > right_limit - self.screen.cursor.x) + // count > right_limit - self.screens.active.cursor.x) // { // self.eraseLine(.right, false); // return; // } // left is just the cursor position but as a multi-pointer - const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - var page = &self.screen.cursor.page_pin.node.data; + const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); + var page = &self.screens.active.cursor.page_pin.node.data; // If our X is a wide spacer tail then we need to erase the // previous cell too so we don't split a multi-cell character. - if (self.screen.cursor.page_cell.wide == .spacer_tail) { - assert(self.screen.cursor.x > 0); - self.screen.clearCells(page, self.screen.cursor.page_row, (left - 1)[0..2]); + if (self.screens.active.cursor.page_cell.wide == .spacer_tail) { + assert(self.screens.active.cursor.x > 0); + self.screens.active.clearCells(page, self.screens.active.cursor.page_row, (left - 1)[0..2]); } // Remaining cols from our cursor to the right margin. - const rem = self.scrolling_region.right - self.screen.cursor.x + 1; + const rem = self.scrolling_region.right - self.screens.active.cursor.x + 1; // We can only insert blanks up to our remaining cols const adjusted_count = @min(count, rem); @@ -1958,9 +1948,9 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { if (end.wide == .wide) { const end_multi: [*]Cell = @ptrCast(end); assert(end_multi[1].wide == .spacer_tail); - self.screen.clearCells( + self.screens.active.clearCells( page, - self.screen.cursor.page_row, + self.screens.active.cursor.page_row, end_multi[0..2], ); } @@ -1974,10 +1964,10 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { } // Insert blanks. The blanks preserve the background color. - self.screen.clearCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); + self.screens.active.clearCells(page, self.screens.active.cursor.page_row, left[0..adjusted_count]); // Our row is always dirty - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); } /// Removes amount characters from the current cursor position to the right. @@ -1993,22 +1983,22 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void { // If our cursor is outside the margins then do nothing. We DO reset // wrap state still so this must remain below the above logic. - if (self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; + if (self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; // left is just the cursor position but as a multi-pointer - const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - var page = &self.screen.cursor.page_pin.node.data; + const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); + var page = &self.screens.active.cursor.page_pin.node.data; // Remaining cols from our cursor to the right margin. - const rem = self.scrolling_region.right - self.screen.cursor.x + 1; + const rem = self.scrolling_region.right - self.screens.active.cursor.x + 1; // We can only insert blanks up to our remaining cols const count = @min(count_req, rem); - self.screen.splitCellBoundary(self.screen.cursor.x); - self.screen.splitCellBoundary(self.screen.cursor.x + count); - self.screen.splitCellBoundary(self.scrolling_region.right + 1); + self.screens.active.splitCellBoundary(self.screens.active.cursor.x); + self.screens.active.splitCellBoundary(self.screens.active.cursor.x + count); + self.screens.active.splitCellBoundary(self.scrolling_region.right + 1); // This is the amount of space at the right of the scroll region // that will NOT be blank, so we need to shift the correct cols right. @@ -2029,24 +2019,24 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void { } // Insert blanks. The blanks preserve the background color. - self.screen.clearCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); + self.screens.active.clearCells(page, self.screens.active.cursor.page_row, x[0 .. rem - scroll_amount]); // Our row's soft-wrap is always reset. - self.screen.cursorResetWrap(); + self.screens.active.cursorResetWrap(); // Our row is always dirty - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); } pub fn eraseChars(self: *Terminal, count_req: usize) void { const count = end: { - const remaining = self.cols - self.screen.cursor.x; + const remaining = self.cols - self.screens.active.cursor.x; var end = @min(remaining, @max(count_req, 1)); // If our last cell is a wide char then we need to also clear the // cell beyond it since we can't just split a wide char. if (end != remaining) { - const last = self.screen.cursorCellRight(end - 1); + const last = self.screens.active.cursorCellRight(end - 1); if (last.wide == .wide) end += 1; } @@ -2058,33 +2048,33 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // TODO(qwerasd): This isn't actually correct if you take in to account // protected modes. We need to figure out how to make `clearCells` or at // least `clearUnprotectedCells` handle boundary conditions... - self.screen.splitCellBoundary(self.screen.cursor.x); - self.screen.splitCellBoundary(self.screen.cursor.x + count); + self.screens.active.splitCellBoundary(self.screens.active.cursor.x); + self.screens.active.splitCellBoundary(self.screens.active.cursor.x + count); // Reset our row's soft-wrap. - self.screen.cursorResetWrap(); + self.screens.active.cursorResetWrap(); // Mark our cursor row as dirty - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); // Clear the cells - const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + const cells: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); // If we never had a protection mode, then we can assume no cells // are protected and go with the fast path. If the last protection // mode was not ISO we also always ignore protection attributes. - if (self.screen.protected_mode != .iso) { - self.screen.clearCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + if (self.screens.active.protected_mode != .iso) { + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, cells[0..count], ); return; } - self.screen.clearUnprotectedCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + self.screens.active.clearUnprotectedCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, cells[0..count], ); } @@ -2098,25 +2088,25 @@ pub fn eraseLine( // Get our start/end positions depending on mode. const start, const end = switch (mode) { .right => right: { - var x = self.screen.cursor.x; + var x = self.screens.active.cursor.x; // If our X is a wide spacer tail then we need to erase the // previous cell too so we don't split a multi-cell character. - if (x > 0 and self.screen.cursor.page_cell.wide == .spacer_tail) { + if (x > 0 and self.screens.active.cursor.page_cell.wide == .spacer_tail) { x -= 1; } // Reset our row's soft-wrap. - self.screen.cursorResetWrap(); + self.screens.active.cursorResetWrap(); break :right .{ x, self.cols }; }, .left => left: { - var x = self.screen.cursor.x; + var x = self.screens.active.cursor.x; // If our x is a wide char we need to delete the tail too. - if (self.screen.cursor.page_cell.wide == .wide) { + if (self.screens.active.cursor.page_cell.wide == .wide) { x += 1; } @@ -2135,36 +2125,36 @@ pub fn eraseLine( // All modes will clear the pending wrap state and we know we have // a valid mode at this point. - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; // We always mark our row as dirty - self.screen.cursorMarkDirty(); + self.screens.active.cursorMarkDirty(); // Start of our cells const cells: [*]Cell = cells: { - const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - break :cells cells - self.screen.cursor.x; + const cells: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); + break :cells cells - self.screens.active.cursor.x; }; // We respect protected attributes if explicitly requested (probably // a DECSEL sequence) or if our last protected mode was ISO even if its // not currently set. - const protected = self.screen.protected_mode == .iso or protected_req; + const protected = self.screens.active.protected_mode == .iso or protected_req; // If we're not respecting protected attributes, we can use a fast-path // to fill the entire line. if (!protected) { - self.screen.clearCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, cells[start..end], ); return; } - self.screen.clearUnprotectedCells( - &self.screen.cursor.page_pin.node.data, - self.screen.cursor.page_row, + self.screens.active.clearUnprotectedCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, cells[start..end], ); } @@ -2178,23 +2168,23 @@ pub fn eraseDisplay( // We respect protected attributes if explicitly requested (probably // a DECSEL sequence) or if our last protected mode was ISO even if its // not currently set. - const protected = self.screen.protected_mode == .iso or protected_req; + const protected = self.screens.active.protected_mode == .iso or protected_req; switch (mode) { .scroll_complete => { - self.screen.scrollClear() catch |err| { + self.screens.active.scrollClear() catch |err| { log.warn("scroll clear failed, doing a normal clear err={}", .{err}); self.eraseDisplay(.complete, protected_req); return; }; // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; if (comptime build_options.kitty_graphics) { // Clear all Kitty graphics state for this screen - self.screen.kitty_images.delete( - self.screen.alloc, + self.screens.active.kitty_images.delete( + self.screens.active.alloc, self, .{ .all = true }, ); @@ -2211,12 +2201,12 @@ pub fn eraseDisplay( if (self.screens.active_key == .primary) at_prompt: { // Go from the bottom of the active up and see if we're // at a prompt. - const active_br = self.screen.pages.getBottomRight( + const active_br = self.screens.active.pages.getBottomRight( .active, ) orelse break :at_prompt; var it = active_br.rowIterator( .left_up, - self.screen.pages.getTopLeft(.active), + self.screens.active.pages.getTopLeft(.active), ); while (it.next()) |p| { const row = p.rowAndCell().row; @@ -2236,26 +2226,26 @@ pub fn eraseDisplay( } } else break :at_prompt; - self.screen.scrollClear() catch { + self.screens.active.scrollClear() catch { // If we fail, we just fall back to doing a normal clear // so we don't worry about the error. }; } // All active area - self.screen.clearRows( + self.screens.active.clearRows( .{ .active = .{} }, null, protected, ); // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; + self.screens.active.cursor.pending_wrap = false; if (comptime build_options.kitty_graphics) { // Clear all Kitty graphics state for this screen - self.screen.kitty_images.delete( - self.screen.alloc, + self.screens.active.kitty_images.delete( + self.screens.active.alloc, self, .{ .all = true }, ); @@ -2270,16 +2260,16 @@ pub fn eraseDisplay( self.eraseLine(.right, protected_req); // All lines below - if (self.screen.cursor.y + 1 < self.rows) { - self.screen.clearRows( - .{ .active = .{ .y = self.screen.cursor.y + 1 } }, + if (self.screens.active.cursor.y + 1 < self.rows) { + self.screens.active.clearRows( + .{ .active = .{ .y = self.screens.active.cursor.y + 1 } }, null, protected, ); } // Unsets pending wrap state. Should be done by eraseLine. - assert(!self.screen.cursor.pending_wrap); + assert(!self.screens.active.cursor.pending_wrap); }, .above => { @@ -2287,19 +2277,19 @@ pub fn eraseDisplay( self.eraseLine(.left, protected_req); // All lines above - if (self.screen.cursor.y > 0) { - self.screen.clearRows( + if (self.screens.active.cursor.y > 0) { + self.screens.active.clearRows( .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = self.screen.cursor.y - 1 } }, + .{ .active = .{ .y = self.screens.active.cursor.y - 1 } }, protected, ); } // Unsets pending wrap state - assert(!self.screen.cursor.pending_wrap); + assert(!self.screens.active.cursor.pending_wrap); }, - .scrollback => self.screen.eraseRows(.{ .history = .{} }, null), + .scrollback => self.screens.active.eraseRows(.{ .history = .{} }, null), } } @@ -2309,13 +2299,13 @@ pub fn eraseDisplay( pub fn decaln(self: *Terminal) !void { // Clear our stylistic attributes. This is the only thing that can // fail so we do it first so we can undo it. - const old_style = self.screen.cursor.style; - self.screen.cursor.style = .{ - .bg_color = self.screen.cursor.style.bg_color, - .fg_color = self.screen.cursor.style.fg_color, + const old_style = self.screens.active.cursor.style; + self.screens.active.cursor.style = .{ + .bg_color = self.screens.active.cursor.style.bg_color, + .fg_color = self.screens.active.cursor.style.fg_color, }; - errdefer self.screen.cursor.style = old_style; - try self.screen.manualStyleUpdate(); + errdefer self.screens.active.cursor.style = old_style; + try self.screens.active.manualStyleUpdate(); // Reset margins, also sets cursor to top-left self.scrolling_region = .{ @@ -2333,7 +2323,7 @@ pub fn decaln(self: *Terminal) !void { // Use clearRows instead of eraseDisplay because we must NOT respect // protected attributes here. - self.screen.clearRows( + self.screens.active.clearRows( .{ .active = .{} }, null, false, @@ -2341,24 +2331,24 @@ pub fn decaln(self: *Terminal) !void { // Fill with Es by moving the cursor but reset it after. while (true) { - const page = &self.screen.cursor.page_pin.node.data; - const row = self.screen.cursor.page_row; + const page = &self.screens.active.cursor.page_pin.node.data; + const row = self.screens.active.cursor.page_row; const cells_multi: [*]Cell = row.cells.ptr(page.memory); const cells = cells_multi[0..page.size.cols]; @memset(cells, .{ .content_tag = .codepoint, .content = .{ .codepoint = 'E' }, - .style_id = self.screen.cursor.style_id, + .style_id = self.screens.active.cursor.style_id, // DECALN does not respect protected state. Verified with xterm. .protected = false, }); // If we have a ref-counted style, increase - if (self.screen.cursor.style_id != style.default_id) { + if (self.screens.active.cursor.style_id != style.default_id) { page.styles.useMultiple( page.memory, - self.screen.cursor.style_id, + self.screens.active.cursor.style_id, @intCast(cells.len), ); row.styled = true; @@ -2367,9 +2357,9 @@ pub fn decaln(self: *Terminal) !void { // We messed with the page so assert its integrity here. page.assertIntegrity(); - self.screen.cursorMarkDirty(); - if (self.screen.cursor.y == self.rows - 1) break; - self.screen.cursorDown(1); + self.screens.active.cursorMarkDirty(); + if (self.screens.active.cursor.y == self.rows - 1) break; + self.screens.active.cursorDown(1); } // Reset the cursor to the top-left @@ -2393,7 +2383,7 @@ pub fn kittyGraphics( /// Set a style attribute. pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { - try self.screen.setAttribute(attr); + try self.screens.active.setAttribute(attr); } /// Print the active attributes as a string. This is used to respond to DECRQSS @@ -2408,7 +2398,7 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS try writer.writeByte('0'); - const pen = self.screen.cursor.style; + const pen = self.screens.active.cursor.style; var attrs: [8]u8 = @splat(0); var i: usize = 0; @@ -2650,7 +2640,6 @@ pub fn switchScreen(self: *Terminal, key: ScreenSet.Key) !?*Screen { // Finalize the switch self.screens.switchTo(key); - self.screen = new; return old; } @@ -2699,7 +2688,7 @@ pub fn switchScreenMode( // cursor is already copied. The cursor is copied regardless // of destination screen. .@"47", .@"1047" => if (old_) |old| { - self.screen.cursorCopy(old.cursor, .{ + self.screens.active.cursorCopy(old.cursor, .{ .hyperlink = false, }) catch |err| { log.warn( @@ -2719,7 +2708,7 @@ pub fn switchScreenMode( // cursor from the primary screen (if we weren't already // on it). if (old_) |old| { - self.screen.cursorCopy(old.cursor, .{ + self.screens.active.cursorCopy(old.cursor, .{ .hyperlink = false, }) catch |err| { log.warn( @@ -2766,12 +2755,12 @@ pub const SwitchScreenMode = enum { /// /// The caller must free the string. pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 { - return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); + return try self.screens.active.dumpStringAlloc(alloc, .{ .viewport = .{} }); } /// Same as plainString, but respects row wrap state when building the string. pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 { - return try self.screen.dumpStringAllocUnwrapped(alloc, .{ .viewport = .{} }); + return try self.screens.active.dumpStringAllocUnwrapped(alloc, .{ .viewport = .{} }); } /// Full reset. @@ -2786,7 +2775,6 @@ pub fn fullReset(self: *Terminal) void { self.screens.active.alloc, .alternate, ); - self.screen = self.screens.active; // Reset our screens self.screens.active.reset(); @@ -2811,12 +2799,12 @@ pub fn fullReset(self: *Terminal) void { /// Returns true if the point is dirty, used for testing. fn isDirty(t: *const Terminal, pt: point.Point) bool { - return t.screen.pages.getCell(pt).?.isDirty(); + return t.screens.active.pages.getCell(pt).?.isDirty(); } /// Clear all dirty bits. Testing only. fn clearDirty(t: *Terminal) void { - t.screen.pages.clearDirty(); + t.screens.active.pages.clearDirty(); } test "Terminal: input with no control characters" { @@ -2826,8 +2814,8 @@ test "Terminal: input with no control characters" { // Basic grid writing for ("hello") |c| try t.print(c); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); { const str = try t.plainString(alloc); defer alloc.free(str); @@ -2846,9 +2834,9 @@ test "Terminal: input with basic wraparound" { // Basic grid writing for ("helloworldabc12") |c| try t.print(c); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); { const str = try t.plainString(alloc); defer alloc.free(str); @@ -2878,8 +2866,8 @@ test "Terminal: input that forces scroll" { // Basic grid writing for ("abcdef") |c| try t.print(c); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); { const str = try t.plainString(alloc); defer alloc.free(str); @@ -2913,19 +2901,19 @@ test "Terminal: input glitch text" { // Get our initial grapheme capacity. const grapheme_cap = cap: { - const page = t.screen.pages.pages.first.?; + const page = t.screens.active.pages.pages.first.?; break :cap page.data.capacity.grapheme_bytes; }; // Print glitch text until our capacity changes while (true) { - const page = t.screen.pages.pages.first.?; + const page = t.screens.active.pages.pages.first.?; if (page.data.capacity.grapheme_bytes != grapheme_cap) break; try t.printString(glitch); } // We're testing to make sure that grapheme capacity gets increased. - const page = t.screen.pages.pages.first.?; + const page = t.screens.active.pages.pages.first.?; try testing.expect(page.data.capacity.grapheme_bytes > grapheme_cap); } @@ -2937,8 +2925,8 @@ test "Terminal: zero-width character at start" { // just ignore it. try t.print(0x200D); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); // Should not be dirty since we changed nothing. try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); @@ -2959,17 +2947,17 @@ test "Terminal: print wide char" { defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } @@ -2983,22 +2971,22 @@ test "Terminal: print wide char at edge creates spacer head" { t.setCursorPos(1, 10); try t.print(0x1F600); // Smiley face - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 9, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 9, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } @@ -3027,12 +3015,12 @@ test "Terminal: print wide char in single-width terminal" { defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -3049,17 +3037,17 @@ test "Terminal: print over wide char at 0,0" { t.setCursorPos(0, 0); try t.print('A'); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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); @@ -3078,13 +3066,13 @@ test "Terminal: print over wide spacer tail" { try t.print('X'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'X'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -3107,7 +3095,7 @@ test "Terminal: print over wide char with bold" { try t.print(0x1F600); // Smiley face // verify we have styles in our style map { - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -3118,7 +3106,7 @@ test "Terminal: print over wide char with bold" { // verify our style is gone { - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -3137,7 +3125,7 @@ test "Terminal: print over wide char with bg color" { try t.print(0x1F600); // Smiley face // verify we have styles in our style map { - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -3148,7 +3136,7 @@ test "Terminal: print over wide char with bg color" { // verify our style is gone { - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -3168,13 +3156,13 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try t.print(0x1F467); // We should have 6 cells taken up - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 6), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 6), t.screens.active.cursor.x); // Assert various properties about our screen to verify // we have all expected cells. { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3183,7 +3171,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(usize, 1), cps.len); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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.expect(!cell.hasGrapheme()); @@ -3191,7 +3179,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3200,7 +3188,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(usize, 1), cps.len); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3208,7 +3196,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3216,7 +3204,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3244,7 +3232,7 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3278,20 +3266,20 @@ test "Terminal: print invalid VS16 non-grapheme" { try t.print(0xFE0F); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + 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.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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); } @@ -3328,8 +3316,8 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { try t.print(0x1F467); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // Row should be dirty try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); @@ -3337,7 +3325,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { // Assert various properties about our screen to verify // we have all expected cells. { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3346,7 +3334,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { try testing.expectEqual(@as(usize, 4), cps.len); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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.expect(!cell.hasGrapheme()); @@ -3367,15 +3355,15 @@ test "Terminal: keypad sequence VS15" { // VS15 should combine with the base character into a single grapheme cluster, // taking 1 cell (narrow character). - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); // Row should be dirty try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); // The base emoji should be in cell 0 with the skin tone as a grapheme { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x23), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3396,15 +3384,15 @@ test "Terminal: keypad sequence VS16" { // VS16 should combine with the base character into a single grapheme cluster, // taking 2 cells (wide character). - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // Row should be dirty try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); // The base emoji should be in cell 0 with the skin tone as a grapheme { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x23), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3425,15 +3413,15 @@ test "Terminal: Fitzpatrick skin tone next valid base" { // The skin tone should combine with the base emoji into a single grapheme cluster, // taking 2 cells (wide character). - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // Row should be dirty try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); // The base emoji should be in cell 0 with the skin tone as a grapheme { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F44B), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3455,8 +3443,8 @@ test "Terminal: Fitzpatrick skin tone next to non-base" { // We should have 4 cells taken up. Importantly, the skin tone // should not join with the quotes. - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); // Row should be dirty try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); @@ -3464,21 +3452,21 @@ test "Terminal: Fitzpatrick skin tone next to non-base" { // Assert various properties about our screen to verify // we have all expected cells. { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x22), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F3FF), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x22), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3511,8 +3499,8 @@ test "Terminal: multicodepoint grapheme marks dirty on every codepoint" { try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); } test "Terminal: VS15 to make narrow character" { @@ -3527,16 +3515,16 @@ test "Terminal: VS15 to make narrow character" { t.clearDirty(); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); try t.print(0xFE0E); // VS15 to make narrow try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); t.clearDirty(); // VS15 should send us back a cell since our char is no longer wide. - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -3545,7 +3533,7 @@ test "Terminal: VS15 to make narrow character" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3570,8 +3558,8 @@ test "Terminal: VS15 on already narrow emoji" { t.clearDirty(); // Character takes up one cell - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -3580,7 +3568,7 @@ test "Terminal: VS15 on already narrow emoji" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3602,18 +3590,18 @@ test "Terminal: VS15 to make narrow character with pending wrap" { t.clearDirty(); // We only move one because we're in a pending wrap state. - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); try t.print(0xFE0E); // VS15 to make narrow try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); t.clearDirty(); // VS15 should clear the pending wrap state - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); @@ -3622,7 +3610,7 @@ test "Terminal: VS15 to make narrow character with pending wrap" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3653,7 +3641,7 @@ test "Terminal: VS16 to make wide character with mode 2027" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3684,7 +3672,7 @@ test "Terminal: VS16 repeated with mode 2027" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3693,7 +3681,7 @@ test "Terminal: VS16 repeated with mode 2027" { try testing.expectEqual(@as(usize, 1), cps.len); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3715,20 +3703,20 @@ test "Terminal: print invalid VS16 grapheme" { try t.print(0xFE0F); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + 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.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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); @@ -3748,13 +3736,13 @@ test "Terminal: print invalid VS16 with second char" { try t.print('y'); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + 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.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3762,7 +3750,7 @@ test "Terminal: print invalid VS16 with second char" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'y'), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3793,7 +3781,7 @@ test "Terminal: overwrite grapheme should clear grapheme data" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); @@ -3817,11 +3805,11 @@ test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { try t.print(0x1F467); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // We should have one cell with graphemes - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.graphemeCount()); // Move back and overwrite wide @@ -3830,8 +3818,8 @@ test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { try t.print('X'); try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), page.graphemeCount()); { @@ -3857,11 +3845,11 @@ test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { try t.print(0x1F467); // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // We should have one cell with graphemes - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.graphemeCount()); // Move back and overwrite wide @@ -3874,8 +3862,8 @@ test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { try testing.expectEqualStrings(" X", str); } - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); try testing.expectEqual(@as(usize, 0), page.graphemeCount()); } @@ -3899,7 +3887,7 @@ test "Terminal: print writes to bottom if scrolled" { } // Scroll to the top - t.screen.scroll(.{ .top = {} }); + t.screens.active.scroll(.{ .top = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -3908,7 +3896,7 @@ test "Terminal: print writes to bottom if scrolled" { // Type try t.print('A'); - t.screen.scroll(.{ .active = {} }); + t.screens.active.scroll(.{ .active = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -3916,8 +3904,8 @@ test "Terminal: print writes to bottom if scrolled" { } try testing.expect(t.isDirty(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } })); } @@ -4024,11 +4012,11 @@ test "Terminal: print kitty unicode placeholder" { defer t.deinit(testing.allocator); try t.print(kitty.graphics.unicode.placeholder); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, kitty.graphics.unicode.placeholder), cell.content.codepoint); try testing.expect(list_cell.row.kitty_virtual_placeholder); @@ -4043,8 +4031,8 @@ test "Terminal: soft wrap" { // Basic grid writing for ("hello") |c| try t.print(c); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4063,11 +4051,11 @@ test "Terminal: soft wrap with semantic prompt" { for ("hello") |c| try t.print(c); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); } } @@ -4083,8 +4071,8 @@ test "Terminal: disabled wraparound with wide char and one space" { try t.printString("AAAA"); t.clearDirty(); try t.print(0x1F6A8); // Police car light - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -4094,7 +4082,7 @@ test "Terminal: disabled wraparound with wide char and one space" { // Make sure we printed nothing { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -4115,8 +4103,8 @@ test "Terminal: disabled wraparound with wide char and no space" { try t.printString("AAAAA"); t.clearDirty(); try t.print(0x1F6A8); // Police car light - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -4125,7 +4113,7 @@ test "Terminal: disabled wraparound with wide char and no space" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -4148,8 +4136,8 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { try t.print(0x2764); // Heart t.clearDirty(); try t.print(0xFE0F); // VS16 to make wide - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -4158,7 +4146,7 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, '❤'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -4185,7 +4173,7 @@ test "Terminal: print right margin wrap" { } { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = list_cell.row; try testing.expect(!row.wrap); } @@ -4265,15 +4253,15 @@ test "Terminal: print wide char at right margin does not create spacer head" { t.setLeftAndRightMargin(3, 5); t.setCursorPos(1, 5); try t.print(0x1F600); // Smiley face - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); // Both rows dirty because the cursor moved try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 1 } })); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -4282,13 +4270,13 @@ test "Terminal: print wide char at right margin does not create spacer head" { try testing.expect(!row.wrap); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 1 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } @@ -4299,12 +4287,12 @@ test "Terminal: print with hyperlink" { defer t.deinit(testing.allocator); // Setup our hyperlink and print - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("123456"); // Verify all our cells have a hyperlink for (0..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4324,14 +4312,14 @@ test "Terminal: print over cell with same hyperlink" { defer t.deinit(testing.allocator); // Setup our hyperlink and print - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("123456"); t.setCursorPos(1, 1); try t.printString("123456"); // Verify all our cells have a hyperlink for (0..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4351,14 +4339,14 @@ test "Terminal: print and end hyperlink" { defer t.deinit(testing.allocator); // Setup our hyperlink and print - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("123"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); try t.printString("456"); // Verify all our cells have a hyperlink for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4370,7 +4358,7 @@ test "Terminal: print and end hyperlink" { try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (3..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4388,14 +4376,14 @@ test "Terminal: print and change hyperlink" { defer t.deinit(testing.allocator); // Setup our hyperlink and print - try t.screen.startHyperlink("http://one.example.com", null); + try t.screens.active.startHyperlink("http://one.example.com", null); try t.printString("123"); - try t.screen.startHyperlink("http://two.example.com", null); + try t.screens.active.startHyperlink("http://two.example.com", null); try t.printString("456"); // Verify all our cells have a hyperlink for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4405,7 +4393,7 @@ test "Terminal: print and change hyperlink" { try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (3..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4423,15 +4411,15 @@ test "Terminal: overwrite hyperlink" { defer t.deinit(testing.allocator); // Setup our hyperlink and print - try t.screen.startHyperlink("http://one.example.com", null); + try t.screens.active.startHyperlink("http://one.example.com", null); try t.printString("123"); t.setCursorPos(1, 1); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); try t.printString("456"); // Verify all our cells have a hyperlink for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -4466,8 +4454,8 @@ test "Terminal: linefeed and carriage return" { try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); for ("world") |c| try t.print(c); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4481,12 +4469,12 @@ test "Terminal: linefeed unsets pending wrap" { // Basic grid writing for ("hello") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap == true); + try testing.expect(t.screens.active.cursor.pending_wrap == true); t.clearDirty(); try t.linefeed(); try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); - try testing.expect(t.screen.cursor.pending_wrap == false); + try testing.expect(t.screens.active.cursor.pending_wrap == false); } test "Terminal: linefeed mode automatic carriage return" { @@ -4511,9 +4499,9 @@ test "Terminal: carriage return unsets pending wrap" { // Basic grid writing for ("hello") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap == true); + try testing.expect(t.screens.active.cursor.pending_wrap == true); t.carriageReturn(); - try testing.expect(t.screen.cursor.pending_wrap == false); + try testing.expect(t.screens.active.cursor.pending_wrap == false); } test "Terminal: carriage return origin mode moves to left margin" { @@ -4521,30 +4509,30 @@ test "Terminal: carriage return origin mode moves to left margin" { defer t.deinit(testing.allocator); t.modes.set(.origin, true); - t.screen.cursor.x = 0; + t.screens.active.cursor.x = 0; t.scrolling_region.left = 2; t.carriageReturn(); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); } test "Terminal: carriage return left of left margin moves to zero" { var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); - t.screen.cursor.x = 1; + t.screens.active.cursor.x = 1; t.scrolling_region.left = 2; t.carriageReturn(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); } test "Terminal: carriage return right of left margin moves to left margin" { var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); - t.screen.cursor.x = 3; + t.screens.active.cursor.x = 3; t.scrolling_region.left = 2; t.carriageReturn(); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); } test "Terminal: backspace" { @@ -4555,8 +4543,8 @@ test "Terminal: backspace" { for ("hello") |c| try t.print(c); t.backspace(); try t.print('y'); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -4572,17 +4560,17 @@ test "Terminal: horizontal tabs" { // HT try t.print('1'); try t.horizontalTab(); - try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 8), t.screens.active.cursor.x); // HT try t.horizontalTab(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); // HT at the end try t.horizontalTab(); - try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 19), t.screens.active.cursor.x); try t.horizontalTab(); - try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 19), t.screens.active.cursor.x); } test "Terminal: horizontal tabs starting on tabstop" { @@ -4590,9 +4578,9 @@ test "Terminal: horizontal tabs starting on tabstop" { var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); - t.setCursorPos(t.screen.cursor.y, 9); + t.setCursorPos(t.screens.active.cursor.y, 9); try t.print('X'); - t.setCursorPos(t.screen.cursor.y, 9); + t.setCursorPos(t.screens.active.cursor.y, 9); try t.horizontalTab(); try t.print('A'); @@ -4610,7 +4598,7 @@ test "Terminal: horizontal tabs with right margin" { t.scrolling_region.left = 2; t.scrolling_region.right = 5; - t.setCursorPos(t.screen.cursor.y, 1); + t.setCursorPos(t.screens.active.cursor.y, 1); try t.print('X'); try t.horizontalTab(); try t.print('A'); @@ -4628,21 +4616,21 @@ test "Terminal: horizontal tabs back" { defer t.deinit(alloc); // Edge of screen - t.setCursorPos(t.screen.cursor.y, 20); + t.setCursorPos(t.screens.active.cursor.y, 20); // HT try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); // HT try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 8), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 8), t.screens.active.cursor.x); // HT try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); try t.horizontalTabBack(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); } test "Terminal: horizontal tabs back starting on tabstop" { @@ -4650,9 +4638,9 @@ test "Terminal: horizontal tabs back starting on tabstop" { var t = try init(alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); - t.setCursorPos(t.screen.cursor.y, 9); + t.setCursorPos(t.screens.active.cursor.y, 9); try t.print('X'); - t.setCursorPos(t.screen.cursor.y, 9); + t.setCursorPos(t.screens.active.cursor.y, 9); try t.horizontalTabBack(); try t.print('A'); @@ -4709,9 +4697,9 @@ test "Terminal: cursorPos resets wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.setCursorPos(1, 1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -4799,52 +4787,52 @@ test "Terminal: setCursorPos (original test)" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); // Setting it to 0 should keep it zero (1 based) t.setCursorPos(0, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); // Should clamp to size t.setCursorPos(81, 81); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.y); // Should reset pending wrap t.setCursorPos(0, 80); try t.print('c'); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.setCursorPos(0, 80); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); // Origin mode t.modes.set(.origin, true); // No change without a scroll region t.setCursorPos(81, 81); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.y); // Set the scroll region t.setTopAndBottomMargin(10, t.rows); t.setCursorPos(0, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); t.setCursorPos(1, 1); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); t.setCursorPos(100, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 79), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.y); t.setTopAndBottomMargin(10, 11); t.setCursorPos(2, 0); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 10), t.screens.active.cursor.y); } test "Terminal: setTopAndBottomMargin simple" { @@ -5175,7 +5163,7 @@ test "Terminal: insertLines colors with bg color" { } for (0..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5206,7 +5194,7 @@ test "Terminal: insertLines handles style refs" { try t.setAttribute(.{ .unset = {} }); // verify we have styles in our style map - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); t.setCursorPos(2, 2); @@ -5410,9 +5398,9 @@ test "Terminal: insertLines resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.insertLines(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('B'); { @@ -5442,7 +5430,7 @@ test "Terminal: insertLines resets wrap" { } { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; const row = list_cell.row; try testing.expect(!row.wrap); } @@ -5525,11 +5513,11 @@ test "Terminal: scrollUp simple" { try t.printString("GHI"); t.setCursorPos(2, 2); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); t.scrollUp(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -5550,9 +5538,9 @@ test "Terminal: scrollUp moves hyperlink" { try t.printString("ABC"); t.carriageReturn(); try t.linefeed(); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("DEF"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.carriageReturn(); try t.linefeed(); try t.printString("GHI"); @@ -5566,7 +5554,7 @@ test "Terminal: scrollUp moves hyperlink" { } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5580,7 +5568,7 @@ test "Terminal: scrollUp moves hyperlink" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5598,9 +5586,9 @@ test "Terminal: scrollUp clears hyperlink" { var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("ABC"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.carriageReturn(); try t.linefeed(); try t.printString("DEF"); @@ -5617,7 +5605,7 @@ test "Terminal: scrollUp clears hyperlink" { } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5676,11 +5664,11 @@ test "Terminal: scrollUp left/right scroll region" { t.scrolling_region.right = 3; t.setCursorPos(2, 2); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); t.scrollUp(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -5701,9 +5689,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try t.printString("ABC123"); t.carriageReturn(); try t.linefeed(); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("DEF456"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.carriageReturn(); try t.linefeed(); try t.printString("GHI789"); @@ -5721,7 +5709,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { // First row gets some hyperlinks { for (0..1) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5731,7 +5719,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(id == null); } for (1..4) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5745,7 +5733,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (4..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5759,7 +5747,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { // Second row preserves hyperlink where we didn't scroll { for (0..1) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5773,7 +5761,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (1..4) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5783,7 +5771,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(id == null); } for (4..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5887,11 +5875,11 @@ test "Terminal: scrollDown simple" { try t.printString("GHI"); t.setCursorPos(2, 2); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); for (0..5) |y| try testing.expect(t.isDirty(.{ .active = .{ .x = 0, @@ -5910,9 +5898,9 @@ test "Terminal: scrollDown hyperlink moves" { var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("ABC"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.carriageReturn(); try t.linefeed(); try t.printString("DEF"); @@ -5929,7 +5917,7 @@ test "Terminal: scrollDown hyperlink moves" { } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -5943,7 +5931,7 @@ test "Terminal: scrollDown hyperlink moves" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -5971,11 +5959,11 @@ test "Terminal: scrollDown outside of scroll region" { t.setTopAndBottomMargin(3, 4); t.setCursorPos(2, 2); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -6007,11 +5995,11 @@ test "Terminal: scrollDown left/right scroll region" { t.scrolling_region.right = 3; t.setCursorPos(2, 2); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); for (0..4) |y| try testing.expect(t.isDirty(.{ .active = .{ .x = 0, @@ -6030,9 +6018,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { var t = try init(alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("ABC123"); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.carriageReturn(); try t.linefeed(); try t.printString("DEF456"); @@ -6053,7 +6041,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { // First row preserves hyperlink where we didn't scroll { for (0..1) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -6067,7 +6055,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (1..4) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -6077,7 +6065,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(id == null); } for (4..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 0, } }).?; @@ -6095,7 +6083,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { // Second row gets some hyperlinks { for (0..1) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -6105,7 +6093,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(id == null); } for (1..4) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -6119,7 +6107,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expectEqual(1, page.hyperlink_set.count()); } for (4..6) |x| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(x), .y = 1, } }).?; @@ -6147,11 +6135,11 @@ test "Terminal: scrollDown outside of left/right scroll region" { t.scrolling_region.right = 3; t.setCursorPos(1, 1); - const cursor = t.screen.cursor; + const cursor = t.screens.active.cursor; t.clearDirty(); t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); for (0..4) |y| try testing.expect(t.isDirty(.{ .active = .{ .x = 0, @@ -6266,9 +6254,9 @@ test "Terminal: eraseChars resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.eraseChars(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -6285,7 +6273,7 @@ test "Terminal: eraseChars resets wrap" { for ("ABCDE123") |c| try t.print(c); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = list_cell.row; try testing.expect(row.wrap); } @@ -6294,7 +6282,7 @@ test "Terminal: eraseChars resets wrap" { t.eraseChars(1); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = list_cell.row; try testing.expect(!row.wrap); } @@ -6327,7 +6315,7 @@ test "Terminal: eraseChars preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings(" C", str); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 0xFF, @@ -6336,7 +6324,7 @@ test "Terminal: eraseChars preserves background sgr" { }, list_cell.cell.content.color_rgb); } { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1, .y = 0 } }).?; try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 0xFF, @@ -6359,7 +6347,7 @@ test "Terminal: eraseChars handles refcounted styles" { try t.print('C'); // verify we have styles in our style map - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); t.setCursorPos(1, 1); @@ -6436,7 +6424,7 @@ test "Terminal: eraseChars wide char boundary conditions" { t.setCursorPos(1, 2); t.eraseChars(3); - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); { const str = try t.plainString(alloc); @@ -6466,7 +6454,7 @@ test "Terminal: eraseChars wide char splits proper cell boundaries" { t.setCursorPos(1, 6); // At: て t.eraseChars(4); // Delete: て下 - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); { const str = try t.plainString(alloc); @@ -6493,7 +6481,7 @@ test "Terminal: eraseChars wide char wrap boundary conditions" { t.setCursorPos(2, 2); t.eraseChars(3); - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); { const str = try t.plainString(alloc); @@ -6774,9 +6762,9 @@ test "Terminal: index scrolling with hyperlink" { defer t.deinit(alloc); t.setCursorPos(5, 1); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.print('A'); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); t.cursorLeft(1); // undo moving right from 'A' try t.index(); try t.print('B'); @@ -6788,7 +6776,7 @@ test "Terminal: index scrolling with hyperlink" { } { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = 0, .y = 3, } }).?; @@ -6800,7 +6788,7 @@ test "Terminal: index scrolling with hyperlink" { try testing.expectEqual(@as(hyperlink.Id, 1), id); } { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = 0, .y = 4, } }).?; @@ -6818,10 +6806,10 @@ test "Terminal: index outside of scrolling region" { var t = try init(alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); t.setTopAndBottomMargin(2, 5); try t.index(); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); } test "Terminal: index from the bottom outside of scroll region" { @@ -6904,7 +6892,7 @@ test "Terminal: index bottom of primary screen background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("\n\n\nA", str); for (0..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 4, } }).?; @@ -6948,9 +6936,9 @@ test "Terminal: index bottom of scroll region with hyperlinks" { try t.print('A'); try t.index(); t.carriageReturn(); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.print('B'); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); try t.index(); t.carriageReturn(); try t.print('C'); @@ -6962,7 +6950,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" { } { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = 0, .y = 0, } }).?; @@ -6974,7 +6962,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" { try testing.expectEqual(@as(hyperlink.Id, 1), id); } { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = 0, .y = 1, } }).?; @@ -6994,9 +6982,9 @@ test "Terminal: index bottom of scroll region clear hyperlinks" { t.setTopAndBottomMargin(2, 3); t.setCursorPos(2, 1); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.print('A'); - t.screen.endHyperlink(); + t.screens.active.endHyperlink(); try t.index(); t.carriageReturn(); try t.print('B'); @@ -7011,7 +6999,7 @@ test "Terminal: index bottom of scroll region clear hyperlinks" { } for (1..3) |y| { - const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ .x = 0, .y = @intCast(y), } }).?; @@ -7050,7 +7038,7 @@ test "Terminal: index bottom of scroll region with background SGR" { } for (0..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 2, } }).?; @@ -7139,8 +7127,8 @@ test "Terminal: index inside left/right margin" { try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); { const str = try t.plainString(testing.allocator); @@ -7163,12 +7151,12 @@ test "Terminal: index bottom of scroll region creates scrollback" { try t.print('Y'); { - const str = try t.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer testing.allocator.free(str); try testing.expectEqualStrings("2\n3\nY\nX", str); } { - const str = try t.screen.dumpStringAlloc(alloc, .{ .screen = .{} }); + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); defer testing.allocator.free(str); try testing.expectEqualStrings("1\n2\n3\nY\nX", str); } @@ -7213,17 +7201,17 @@ test "Terminal: index bottom of scroll region blank line preserves SGR" { try t.index(); { - const str = try t.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer testing.allocator.free(str); try testing.expectEqualStrings("2\n3\n\nX", str); } { - const str = try t.screen.dumpStringAlloc(alloc, .{ .screen = .{} }); + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); defer testing.allocator.free(str); try testing.expectEqualStrings("1\n2\n3\n\nX", str); } for (0..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 2, } }).?; @@ -7296,9 +7284,9 @@ test "Terminal: cursorUp resets wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorUp(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7332,9 +7320,9 @@ test "Terminal: cursorLeft unsets pending wrap state" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7350,9 +7338,9 @@ test "Terminal: cursorLeft unsets pending wrap state with longer jump" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorLeft(3); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7371,9 +7359,9 @@ test "Terminal: cursorLeft reverse wrap with pending wrap state" { t.modes.set(.reverse_wrap, true); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7392,9 +7380,9 @@ test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { t.modes.set(.reverse_wrap_extended, true); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7415,7 +7403,7 @@ test "Terminal: cursorLeft reverse wrap" { for ("ABCDE1") |c| try t.print(c); t.cursorLeft(2); try t.print('X'); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); @@ -7543,8 +7531,8 @@ test "Terminal: cursorLeft extended reverse wrap above top scroll region" { t.setCursorPos(2, 1); t.cursorLeft(1000); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); } test "Terminal: cursorLeft reverse wrap on first row" { @@ -7559,8 +7547,8 @@ test "Terminal: cursorLeft reverse wrap on first row" { t.setCursorPos(1, 2); t.cursorLeft(1000); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); } test "Terminal: cursorDown basic" { @@ -7620,9 +7608,9 @@ test "Terminal: cursorDown resets wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorDown(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7638,9 +7626,9 @@ test "Terminal: cursorRight resets wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.cursorRight(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -7755,7 +7743,7 @@ test "Terminal: deleteLines colors with bg color" { } for (0..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 4, } }).?; @@ -7793,8 +7781,8 @@ test "Terminal: deleteLines (legacy)" { try t.linefeed(); // We should be - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); { const str = try t.plainString(testing.allocator); @@ -7836,8 +7824,8 @@ test "Terminal: deleteLines with scroll region" { try t.linefeed(); // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + // try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); { const str = try t.plainString(testing.allocator); @@ -7879,8 +7867,8 @@ test "Terminal: deleteLines with scroll region, large count" { try t.linefeed(); // We should be - // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - // try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + // try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); { const str = try t.plainString(testing.allocator); @@ -7930,9 +7918,9 @@ test "Terminal: deleteLines resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.deleteLines(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('B'); { @@ -7964,7 +7952,7 @@ test "Terminal: deleteLines resets wrap" { } for (0..t.rows) |y| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = @intCast(y), } }).?; @@ -8323,7 +8311,7 @@ test "Terminal: default style is empty" { try t.print('A'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expectEqual(@as(style.Id, 0), cell.style_id); @@ -8339,12 +8327,12 @@ test "Terminal: bold style" { try t.print('A'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expect(cell.style_id != 0); - const page = &t.screen.cursor.page_pin.node.data; - try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 1); + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 1); } } @@ -8360,14 +8348,14 @@ test "Terminal: garbage collect overwritten" { try t.print('B'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); try testing.expect(cell.style_id == 0); } // verify we have no styles in our style map - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -8382,14 +8370,14 @@ test "Terminal: do not garbage collect old styles in use" { try t.print('B'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); try testing.expect(cell.style_id == 0); } // verify we have no styles in our style map - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -8404,7 +8392,7 @@ test "Terminal: print with style marks the row as styled" { try t.print('B'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.row.styled); } } @@ -8421,8 +8409,8 @@ test "Terminal: DECALN" { try t.print('B'); try t.decaln(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); for (0..t.rows) |y| try testing.expect(t.isDirty(.{ .active = .{ .x = 0, @@ -8473,7 +8461,7 @@ test "Terminal: decaln preserves color" { } { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 0xFF, @@ -8502,10 +8490,10 @@ test "Terminal: DECALN resets graphemes with protected mode" { try t.decaln(); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expect(t.screen.cursor.protected); - try testing.expect(t.screen.protected_mode == .iso); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.protected); + try testing.expect(t.screens.active.protected_mode == .iso); for (0..t.rows) |y| try testing.expect(t.isDirty(.{ .active = .{ .x = 0, @@ -8628,7 +8616,7 @@ test "Terminal: insertBlanks preserves background sgr" { try testing.expectEqualStrings(" ABC", str); } { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 0xFF, @@ -8708,11 +8696,11 @@ test "Terminal: insertBlanks outside left/right scroll region" { for ("ABC") |c| try t.print(c); t.scrolling_region.left = 2; t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.clearDirty(); t.insertBlanks(2); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -8761,7 +8749,7 @@ test "Terminal: insertBlanks deleting graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -8797,7 +8785,7 @@ test "Terminal: insertBlanks shift graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = &t.screen.cursor.page_pin.node.data; + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -8844,7 +8832,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { var t = try init(alloc, .{ .cols = 10, .rows = 2 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("ABC"); t.setCursorPos(1, 1); t.insertBlanks(2); @@ -8857,7 +8845,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { // Verify all our cells have a hyperlink for (2..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -8869,7 +8857,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (0..2) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -8885,7 +8873,7 @@ test "Terminal: insertBlanks pushes hyperlink off end completely" { var t = try init(alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); try t.printString("ABC"); t.setCursorPos(1, 1); t.insertBlanks(3); @@ -8897,7 +8885,7 @@ test "Terminal: insertBlanks pushes hyperlink off end completely" { } for (0..3) |x| { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = @intCast(x), .y = 0, } }).?; @@ -9112,9 +9100,9 @@ test "Terminal: deleteChars resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.deleteChars(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('X'); { @@ -9131,7 +9119,7 @@ test "Terminal: deleteChars resets wrap" { for ("ABCDE123") |c| try t.print(c); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = list_cell.row; try testing.expect(row.wrap); } @@ -9139,7 +9127,7 @@ test "Terminal: deleteChars resets wrap" { t.deleteChars(1); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = list_cell.row; try testing.expect(!row.wrap); } @@ -9192,7 +9180,7 @@ test "Terminal: deleteChars preserves background sgr" { try testing.expectEqualStrings("AB23", str); } for (t.cols - 2..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 0, } }).?; @@ -9213,11 +9201,11 @@ test "Terminal: deleteChars outside scroll region" { try t.printString("ABC123"); t.scrolling_region.left = 2; t.scrolling_region.right = 4; - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.clearDirty(); t.deleteChars(2); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); @@ -9273,13 +9261,13 @@ test "Terminal: deleteChars split wide character from wide" { t.deleteChars(1); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, '1'), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -9296,13 +9284,13 @@ test "Terminal: deleteChars split wide character from end" { t.deleteChars(1); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x6A4B), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + 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); @@ -9316,7 +9304,7 @@ test "Terminal: deleteChars with a spacer head at the end" { try t.printString("0123橋123"); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const row = list_cell.row; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); @@ -9327,7 +9315,7 @@ test "Terminal: deleteChars with a spacer head at the end" { t.deleteChars(1); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -9407,7 +9395,7 @@ test "Terminal: deleteChars wide char boundary conditions" { t.setCursorPos(1, 2); t.deleteChars(3); - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); { const str = try t.plainString(alloc); @@ -9459,7 +9447,7 @@ test "Terminal: deleteChars wide char wrap boundary conditions" { t.setCursorPos(2, 2); t.deleteChars(3); - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); { const str = try t.plainString(alloc); @@ -9498,7 +9486,7 @@ test "Terminal: deleteChars wide char across right margin" { t.setCursorPos(1, 2); t.deleteChars(1); - t.screen.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); // NOTE: This behavior is slightly inconsistent with xterm. xterm // _visually_ splits the wide character (half the wide character shows @@ -9521,15 +9509,15 @@ test "Terminal: saveCursor" { defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); - t.screen.charset.gr = .G3; + t.screens.active.charset.gr = .G3; t.modes.set(.origin, true); t.saveCursor(); - t.screen.charset.gr = .G0; + t.screens.active.charset.gr = .G0; try t.setAttribute(.{ .unset = {} }); t.modes.set(.origin, false); try t.restoreCursor(); - try testing.expect(t.screen.cursor.style.flags.bold); - try testing.expect(t.screen.charset.gr == .G3); + try testing.expect(t.screens.active.cursor.style.flags.bold); + try testing.expect(t.screens.active.charset.gr == .G3); try testing.expect(t.modes.get(.origin)); } @@ -9617,13 +9605,13 @@ test "Terminal: saveCursor protected pen" { defer t.deinit(alloc); t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screens.active.cursor.protected); t.setCursorPos(1, 10); t.saveCursor(); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screens.active.cursor.protected); try t.restoreCursor(); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screens.active.cursor.protected); } test "Terminal: saveCursor doesn't modify hyperlink state" { @@ -9631,12 +9619,12 @@ test "Terminal: saveCursor doesn't modify hyperlink state" { var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); - try t.screen.startHyperlink("http://example.com", null); - const id = t.screen.cursor.hyperlink_id; + try t.screens.active.startHyperlink("http://example.com", null); + const id = t.screens.active.cursor.hyperlink_id; t.saveCursor(); - try testing.expectEqual(id, t.screen.cursor.hyperlink_id); + try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id); try t.restoreCursor(); - try testing.expectEqual(id, t.screen.cursor.hyperlink_id); + try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id); } test "Terminal: setProtectedMode" { @@ -9644,15 +9632,15 @@ test "Terminal: setProtectedMode" { var t = try init(alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screens.active.cursor.protected); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screens.active.cursor.protected); t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screens.active.cursor.protected); t.setProtectedMode(.dec); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screens.active.cursor.protected); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screens.active.cursor.protected); } test "Terminal: eraseLine simple erase right" { @@ -9679,9 +9667,9 @@ test "Terminal: eraseLine resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.eraseLine(.right, false); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('B'); { @@ -9698,7 +9686,7 @@ test "Terminal: eraseLine resets wrap" { for ("ABCDE123") |c| try t.print(c); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.row.wrap); } @@ -9706,7 +9694,7 @@ test "Terminal: eraseLine resets wrap" { t.eraseLine(.right, false); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(!list_cell.row.wrap); } try t.print('X'); @@ -9737,7 +9725,7 @@ test "Terminal: eraseLine right preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("A", str); for (1..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 0, } }).?; @@ -9836,10 +9824,10 @@ test "Terminal: eraseLine right protected requested" { defer t.deinit(alloc); for ("12345678") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); + t.setCursorPos(t.screens.active.cursor.y + 1, 4); t.clearDirty(); t.eraseLine(.right, true); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -9875,11 +9863,11 @@ test "Terminal: eraseLine left resets wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.clearDirty(); t.eraseLine(.left, false); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); try t.print('B'); { @@ -9908,7 +9896,7 @@ test "Terminal: eraseLine left preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings(" CDE", str); for (0..2) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 0, } }).?; @@ -10007,10 +9995,10 @@ test "Terminal: eraseLine left protected requested" { defer t.deinit(alloc); for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); + t.setCursorPos(t.screens.active.cursor.y + 1, 8); t.clearDirty(); t.eraseLine(.left, true); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -10041,7 +10029,7 @@ test "Terminal: eraseLine complete preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("", str); for (0..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 0, } }).?; @@ -10120,10 +10108,10 @@ test "Terminal: eraseLine complete protected requested" { defer t.deinit(alloc); for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); + t.setCursorPos(t.screens.active.cursor.y + 1, 8); t.clearDirty(); t.eraseLine(.complete, true); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -10145,7 +10133,7 @@ test "Terminal: tabClear single" { try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); t.setCursorPos(1, 1); try t.horizontalTab(); - try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); } test "Terminal: tabClear all" { @@ -10157,7 +10145,7 @@ test "Terminal: tabClear all" { try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); t.setCursorPos(1, 1); try t.horizontalTab(); - try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 29), t.screens.active.cursor.x); } test "Terminal: printRepeat simple" { @@ -10312,7 +10300,7 @@ test "Terminal: eraseDisplay erase below preserves SGR bg" { defer testing.allocator.free(str); try testing.expectEqualStrings("ABC\nD", str); for (1..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 1, } }).?; @@ -10495,7 +10483,7 @@ test "Terminal: eraseDisplay erase above preserves SGR bg" { defer testing.allocator.free(str); try testing.expectEqualStrings("\n F\nGHI", str); for (0..2) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = @intCast(x), .y = 1, } }).?; @@ -10634,10 +10622,10 @@ test "Terminal: eraseDisplay protected complete" { t.carriageReturn(); try t.linefeed(); for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); + t.setCursorPos(t.screens.active.cursor.y + 1, 4); t.clearDirty(); t.eraseDisplay(.complete, true); @@ -10662,10 +10650,10 @@ test "Terminal: eraseDisplay protected below" { t.carriageReturn(); try t.linefeed(); for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); + t.setCursorPos(t.screens.active.cursor.y + 1, 4); t.eraseDisplay(.below, true); { @@ -10701,10 +10689,10 @@ test "Terminal: eraseDisplay protected above" { t.carriageReturn(); try t.linefeed(); for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); t.setProtectedMode(.dec); try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); + t.setCursorPos(t.screens.active.cursor.y + 1, 8); t.eraseDisplay(.above, true); { @@ -10722,13 +10710,13 @@ test "Terminal: eraseDisplay complete preserves cursor" { // Set our cursur try t.setAttribute(.{ .bold = {} }); try t.printString("AAAA"); - try testing.expect(t.screen.cursor.style_id != style.default_id); + try testing.expect(t.screens.active.cursor.style_id != style.default_id); // Erasing the display may detect that our style is no longer in use // and prune our style, which we don't want because its still our // active cursor. t.eraseDisplay(.complete, false); - try testing.expect(t.screen.cursor.style_id != style.default_id); + try testing.expect(t.screens.active.cursor.style_id != style.default_id); } test "Terminal: cursorIsAtPrompt" { @@ -10786,24 +10774,24 @@ test "Terminal: fullReset with a non-empty pen" { t.fullReset(); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; try testing.expect(cell.style_id == 0); } - try testing.expectEqual(@as(style.Id, 0), t.screen.cursor.style_id); + try testing.expectEqual(@as(style.Id, 0), t.screens.active.cursor.style_id); } test "Terminal: fullReset hyperlink" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); - try t.screen.startHyperlink("http://example.com", null); + try t.screens.active.startHyperlink("http://example.com", null); t.fullReset(); - try testing.expectEqual(0, t.screen.cursor.hyperlink_id); + try testing.expectEqual(0, t.screens.active.cursor.hyperlink_id); } test "Terminal: fullReset with a non-empty saved cursor" { @@ -10816,15 +10804,15 @@ test "Terminal: fullReset with a non-empty saved cursor" { t.fullReset(); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; try testing.expect(cell.style_id == 0); } - try testing.expectEqual(@as(style.Id, 0), t.screen.cursor.style_id); + try testing.expectEqual(@as(style.Id, 0), t.screens.active.cursor.style_id); } test "Terminal: fullReset origin mode" { @@ -10836,8 +10824,8 @@ test "Terminal: fullReset origin mode" { t.fullReset(); // Origin mode should be reset and the cursor should be moved - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); try testing.expect(!t.modes.get(.origin)); } @@ -10856,7 +10844,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { defer t.deinit(testing.allocator); try t.switchScreenMode(.@"1049", true); - t.screen.kitty_keyboard.push(.{ + t.screens.active.kitty_keyboard.push(.{ .disambiguate = true, .report_events = false, .report_alternates = true, @@ -10886,9 +10874,9 @@ test "Terminal: fullReset tracked pins" { defer t.deinit(testing.allocator); // Create a tracked pin - const p = try t.screen.pages.trackPin(t.screen.cursor.page_pin.*); + const p = try t.screens.active.pages.trackPin(t.screens.active.cursor.page_pin.*); t.fullReset(); - try testing.expect(t.screen.pages.pinIsValid(p.*)); + try testing.expect(t.screens.active.pages.pinIsValid(p.*)); } // https://github.com/mitchellh/ghostty/issues/272 @@ -11014,9 +11002,9 @@ test "Terminal: resize with reflow and saved cursor" { try t.printString("1A2B"); t.setCursorPos(2, 2); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); @@ -11040,9 +11028,9 @@ test "Terminal: resize with reflow and saved cursor" { // Verify our cursor is still in the same place { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); @@ -11055,9 +11043,9 @@ test "Terminal: resize with reflow and saved cursor pending wrap" { defer t.deinit(alloc); try t.printString("1A2B"); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); @@ -11117,13 +11105,13 @@ test "Terminal: DECCOLM resets pending wrap" { defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); + try testing.expect(t.screens.active.cursor.pending_wrap); t.modes.set(.enable_mode_3, true); try t.deccolm(alloc, .@"80_cols"); try testing.expectEqual(@as(usize, 80), t.cols); try testing.expectEqual(@as(usize, 5), t.rows); - try testing.expect(!t.screen.cursor.pending_wrap); + try testing.expect(!t.screens.active.cursor.pending_wrap); } test "Terminal: DECCOLM preserves SGR bg" { @@ -11140,7 +11128,7 @@ test "Terminal: DECCOLM preserves SGR bg" { try t.deccolm(alloc, .@"80_cols"); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 0xFF, @@ -11234,10 +11222,10 @@ test "Terminal: mode 47 copies cursor both directions" { // Verify that our style is set { - try testing.expect(t.screen.cursor.style_id != style.default_id); - const page = &t.screen.cursor.page_pin.node.data; + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); - try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } // Set a new style @@ -11249,10 +11237,10 @@ test "Terminal: mode 47 copies cursor both directions" { // Verify that our style is still set { - try testing.expect(t.screen.cursor.style_id != style.default_id); - const page = &t.screen.cursor.page_pin.node.data; + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); - try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } } @@ -11321,10 +11309,10 @@ test "Terminal: mode 1047 copies cursor both directions" { // Verify that our style is set { - try testing.expect(t.screen.cursor.style_id != style.default_id); - const page = &t.screen.cursor.page_pin.node.data; + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); - try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } // Set a new style @@ -11336,10 +11324,10 @@ test "Terminal: mode 1047 copies cursor both directions" { // Verify that our style is still set { - try testing.expect(t.screen.cursor.style_id != style.default_id); - const page = &t.screen.cursor.page_pin.node.data; + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count()); - try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } } diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 0a742ccb1..35fd71665 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -287,7 +287,7 @@ pub const TerminalFormatter = struct { // the node pointer is always properly initialized. m.map.appendNTimes( m.alloc, - self.terminal.screen.pages.getTopLeft(.screen), + self.terminal.screens.active.pages.getTopLeft(.screen), discarding.count, ) catch return error.WriteFailed; } @@ -325,13 +325,13 @@ pub const TerminalFormatter = struct { // the node pointer is always properly initialized. m.map.appendNTimes( m.alloc, - self.terminal.screen.pages.getTopLeft(.screen), + self.terminal.screens.active.pages.getTopLeft(.screen), discarding.count, ) catch return error.WriteFailed; } } - var screen_formatter: ScreenFormatter = .init(self.terminal.screen, self.opts); + var screen_formatter: ScreenFormatter = .init(self.terminal.screens.active, self.opts); screen_formatter.content = self.content; screen_formatter.extra = self.extra.screen; screen_formatter.pin_map = self.pin_map; @@ -409,7 +409,7 @@ pub const TerminalFormatter = struct { .x = last.x, .y = last.y, }; - } else self.terminal.screen.pages.getTopLeft(.screen), + } else self.terminal.screens.active.pages.getTopLeft(.screen), discarding.count, ) catch return error.WriteFailed; } @@ -1422,7 +1422,7 @@ test "Page plain single line" { try s.nextSlice("hello, world"); // Verify we have only a single page - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -1469,7 +1469,7 @@ test "Page plain single wide char" { try s.nextSlice("1A⚡"); // Verify we have only a single page - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -1560,7 +1560,7 @@ test "Page plain single wide char soft-wrapped unwrapped" { try s.nextSlice("1A⚡"); // Verify we have only a single page - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -1677,7 +1677,7 @@ test "Page plain multiline" { try s.nextSlice("hello\r\nworld"); // Verify we have only a single page - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -1728,7 +1728,7 @@ test "Page plain multiline rectangle" { try s.nextSlice("hello\r\nworld"); // Verify we have only a single page - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -1782,7 +1782,7 @@ test "Page plain multi blank lines" { try s.nextSlice("hello\r\n\r\n\r\nworld"); // Verify we have only a single page - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -1835,7 +1835,7 @@ test "Page plain trailing blank lines" { try s.nextSlice("hello\r\nworld\r\n\r\n"); // Verify we have only a single page - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -1888,7 +1888,7 @@ test "Page plain trailing whitespace" { try s.nextSlice("hello \r\nworld "); // Verify we have only a single page - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -1941,7 +1941,7 @@ test "Page plain trailing whitespace no trim" { try s.nextSlice("hello \r\nworld "); // Verify we have only a single page - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -1996,7 +1996,7 @@ test "Page plain with prior trailing state rows" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -2042,7 +2042,7 @@ test "Page plain with prior trailing state cells no wrapped line" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -2087,7 +2087,7 @@ test "Page plain with prior trailing state cells with wrap continuation" { try s.nextSlice("world"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -2141,7 +2141,7 @@ test "Page plain soft-wrapped without unwrap" { try s.nextSlice("hello world test"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -2190,7 +2190,7 @@ test "Page plain soft-wrapped with unwrap" { try s.nextSlice("hello world test"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -2238,7 +2238,7 @@ test "Page plain soft-wrapped 3 lines without unwrap" { try s.nextSlice("hello world this is a test"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -2292,7 +2292,7 @@ test "Page plain soft-wrapped 3 lines with unwrap" { try s.nextSlice("hello world this is a test"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -2344,7 +2344,7 @@ test "Page plain start_y subset" { try s.nextSlice("hello\r\nworld\r\ntest"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2391,7 +2391,7 @@ test "Page plain end_y subset" { try s.nextSlice("hello\r\nworld\r\ntest"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2438,7 +2438,7 @@ test "Page plain start_y and end_y range" { try s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2486,7 +2486,7 @@ test "Page plain start_y out of bounds" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2524,7 +2524,7 @@ test "Page plain end_y greater than rows" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2567,7 +2567,7 @@ test "Page plain end_y less than start_y" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2606,7 +2606,7 @@ test "Page plain start_x on first row only" { try s.nextSlice("hello world"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2648,7 +2648,7 @@ test "Page plain end_x on last row only" { try s.nextSlice("first line\r\nsecond line\r\nthird line"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2701,7 +2701,7 @@ test "Page plain start_x and end_x multiline" { try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2758,7 +2758,7 @@ test "Page plain start_x out of bounds" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2796,7 +2796,7 @@ test "Page plain end_x greater than cols" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2838,7 +2838,7 @@ test "Page plain end_x less than start_x single row" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2878,7 +2878,7 @@ test "Page plain start_y non-zero ignores trailing state" { try s.nextSlice("hello\r\nworld"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2922,7 +2922,7 @@ test "Page plain start_x non-zero ignores trailing state" { try s.nextSlice("hello world"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -2966,7 +2966,7 @@ test "Page plain start_y and start_x zero uses trailing state" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .plain); @@ -3014,7 +3014,7 @@ test "Page plain single line with styling" { try s.nextSlice("hello, \x1b[1mworld\x1b[0m"); // Verify we have only a single page - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); @@ -3059,7 +3059,7 @@ test "Page VT single line plain text" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .vt); @@ -3098,7 +3098,7 @@ test "Page VT single line with bold" { try s.nextSlice("\x1b[1mhello\x1b[0m"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .vt); @@ -3144,7 +3144,7 @@ test "Page VT multiple styles" { try s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .vt); @@ -3179,7 +3179,7 @@ test "Page VT with foreground color" { try s.nextSlice("\x1b[31mred\x1b[0m"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .vt); @@ -3225,7 +3225,7 @@ test "Page VT with background and foreground colors" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .{ @@ -3262,7 +3262,7 @@ test "Page VT multi-line with styles" { try s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .vt); @@ -3297,7 +3297,7 @@ test "Page VT duplicate style not emitted twice" { try s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .vt); @@ -3335,7 +3335,7 @@ test "PageList plain single line" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: PageListFormatter = .init(&t.screen.pages, .plain); + var formatter: PageListFormatter = .init(&t.screens.active.pages, .plain); formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); @@ -3343,7 +3343,7 @@ test "PageList plain single line" { // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| try testing.expectEqual( Pin{ .node = node, .x = @intCast(i), .y = 0 }, pin_map.items[i], @@ -3366,7 +3366,7 @@ test "PageList plain spanning two pages" { var s = t.vtStream(); defer s.deinit(); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page almost completely @@ -3439,7 +3439,7 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { var s = t.vtStream(); defer s.deinit(); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page with soft-wrapped content @@ -3503,7 +3503,7 @@ test "PageList soft-wrapped line spanning two pages with unwrap" { var s = t.vtStream(); defer s.deinit(); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page with soft-wrapped content @@ -3564,7 +3564,7 @@ test "PageList VT spanning two pages" { var s = t.vtStream(); defer s.deinit(); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill the first page almost completely @@ -3626,7 +3626,7 @@ test "PageList plain with x offset on single page" { try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const node = pages.pages.first.?; var pin_map: std.ArrayList(Pin) = .empty; @@ -3670,7 +3670,7 @@ test "PageList plain with x offset spanning two pages" { var s = t.vtStream(); defer s.deinit(); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const first_page_rows = pages.pages.first.?.data.capacity.rows; // Fill first page almost completely @@ -3742,7 +3742,7 @@ test "PageList plain with start_x only" { try s.nextSlice("hello world"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const node = pages.pages.first.?; var pin_map: std.ArrayList(Pin) = .empty; @@ -3783,7 +3783,7 @@ test "PageList plain with end_x only" { try s.nextSlice("hello world\r\ntest"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const node = pages.pages.first.?; var pin_map: std.ArrayList(Pin) = .empty; @@ -3840,7 +3840,7 @@ test "PageList plain rectangle basic" { try s.nextSlice("eiusmod tempor incididunt\r\n"); try s.nextSlice("ut labore et dolore"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; var formatter: PageListFormatter = .init(pages, .plain); formatter.top_left = pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?; @@ -3880,7 +3880,7 @@ test "PageList plain rectangle with EOL" { try s.nextSlice("eiusmod tempor incididunt\r\n"); try s.nextSlice("ut labore et dolore"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; var formatter: PageListFormatter = .init(pages, .plain); formatter.top_left = pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?; @@ -3925,7 +3925,7 @@ test "PageList plain rectangle more complex with breaks" { try s.nextSlice("magna aliqua. Ut enim\r\n"); try s.nextSlice("ad minim veniam, quis"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; var formatter: PageListFormatter = .init(pages, .plain); formatter.top_left = pages.pin(.{ .screen = .{ .x = 11, .y = 2 } }).?; @@ -4035,8 +4035,8 @@ test "TerminalFormatter with selection" { var formatter: TerminalFormatter = .init(&t, .plain); formatter.content = .{ .selection = .init( - t.screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, - t.screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, false, ) }; @@ -4074,7 +4074,7 @@ test "TerminalFormatter plain with pin_map" { // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| try testing.expectEqual( Pin{ .node = node, .x = @intCast(i), .y = 0 }, pin_map.items[i], @@ -4111,7 +4111,7 @@ test "TerminalFormatter plain multiline with pin_map" { // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; // "hello" (5 chars) for (0..5) |i| { try testing.expectEqual(node, pin_map.items[i].node); @@ -4160,7 +4160,7 @@ test "TerminalFormatter vt with palette and pin_map" { // Verify pin map - palette bytes should be mapped to top left try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { try testing.expectEqual(node, pin_map.items[i].node); } @@ -4189,8 +4189,8 @@ test "TerminalFormatter with selection and pin_map" { var formatter: TerminalFormatter = .init(&t, .plain); formatter.content = .{ .selection = .init( - t.screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, - t.screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, false, ) }; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4201,7 +4201,7 @@ test "TerminalFormatter with selection and pin_map" { // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; // "line2" (5 chars) from row 1 for (0..5) |i| { try testing.expectEqual(node, pin_map.items[i].node); @@ -4231,7 +4231,7 @@ test "Screen plain single line" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(t.screen, .plain); + var formatter: ScreenFormatter = .init(t.screens.active, .plain); formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); @@ -4240,7 +4240,7 @@ test "Screen plain single line" { // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| try testing.expectEqual( Pin{ .node = node, .x = @intCast(i), .y = 0 }, pin_map.items[i], @@ -4268,7 +4268,7 @@ test "Screen plain multiline" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(t.screen, .plain); + var formatter: ScreenFormatter = .init(t.screens.active, .plain); formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); @@ -4277,7 +4277,7 @@ test "Screen plain multiline" { // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; // "hello" (5 chars) for (0..5) |i| { try testing.expectEqual(node, pin_map.items[i].node); @@ -4316,10 +4316,10 @@ test "Screen plain with selection" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(t.screen, .plain); + var formatter: ScreenFormatter = .init(t.screens.active, .plain); formatter.content = .{ .selection = .init( - t.screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, - t.screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screens.active.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, false, ) }; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4330,7 +4330,7 @@ test "Screen plain with selection" { // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; // "line2" (5 chars) from row 1 for (0..5) |i| { try testing.expectEqual(node, pin_map.items[i].node); @@ -4361,7 +4361,7 @@ test "Screen vt with cursor position" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screens.active, .vt); formatter.extra.cursor = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4381,12 +4381,12 @@ test "Screen vt with cursor position" { try s2.nextSlice(output); // Verify cursor positions match - try testing.expectEqual(t.screen.cursor.x, t2.screen.cursor.x); - try testing.expectEqual(t.screen.cursor.y, t2.screen.cursor.y); + try testing.expectEqual(t.screens.active.cursor.x, t2.screens.active.cursor.x); + try testing.expectEqual(t.screens.active.cursor.y, t2.screens.active.cursor.y); // Verify pin map - the extras should be mapped to the last pin try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; const content_len = "hello\r\nworld".len; // Content bytes map to their positions for (0..content_len) |i| { @@ -4420,7 +4420,7 @@ test "Screen vt with style" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screens.active, .vt); formatter.extra.style = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4440,11 +4440,11 @@ test "Screen vt with style" { try s2.nextSlice(output); // Verify styles match - try testing.expect(t.screen.cursor.style.eql(t2.screen.cursor.style)); + try testing.expect(t.screens.active.cursor.style.eql(t2.screens.active.cursor.style)); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { try testing.expectEqual(node, pin_map.items[i].node); } @@ -4472,7 +4472,7 @@ test "Screen vt with hyperlink" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screens.active, .vt); formatter.extra.hyperlink = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4492,19 +4492,19 @@ test "Screen vt with hyperlink" { try s2.nextSlice(output); // Verify hyperlinks match - const has_link1 = t.screen.cursor.hyperlink != null; - const has_link2 = t2.screen.cursor.hyperlink != null; + const has_link1 = t.screens.active.cursor.hyperlink != null; + const has_link2 = t2.screens.active.cursor.hyperlink != null; try testing.expectEqual(has_link1, has_link2); if (has_link1) { - const link1 = t.screen.cursor.hyperlink.?; - const link2 = t2.screen.cursor.hyperlink.?; + const link1 = t.screens.active.cursor.hyperlink.?; + const link2 = t2.screens.active.cursor.hyperlink.?; try testing.expectEqualStrings(link1.uri, link2.uri); } // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { try testing.expectEqual(node, pin_map.items[i].node); } @@ -4532,7 +4532,7 @@ test "Screen vt with protection" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screens.active, .vt); formatter.extra.protection = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4552,11 +4552,11 @@ test "Screen vt with protection" { try s2.nextSlice(output); // Verify protection state matches - try testing.expectEqual(t.screen.cursor.protected, t2.screen.cursor.protected); + try testing.expectEqual(t.screens.active.cursor.protected, t2.screens.active.cursor.protected); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { try testing.expectEqual(node, pin_map.items[i].node); } @@ -4584,7 +4584,7 @@ test "Screen vt with kitty keyboard" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screens.active, .vt); formatter.extra.kitty_keyboard = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4604,13 +4604,13 @@ test "Screen vt with kitty keyboard" { try s2.nextSlice(output); // Verify kitty keyboard state matches - const flags1 = t.screen.kitty_keyboard.current().int(); - const flags2 = t2.screen.kitty_keyboard.current().int(); + const flags1 = t.screens.active.kitty_keyboard.current().int(); + const flags2 = t2.screens.active.kitty_keyboard.current().int(); try testing.expectEqual(flags1, flags2); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { try testing.expectEqual(node, pin_map.items[i].node); } @@ -4638,7 +4638,7 @@ test "Screen vt with charsets" { var pin_map: std.ArrayList(Pin) = .empty; defer pin_map.deinit(alloc); - var formatter: ScreenFormatter = .init(t.screen, .vt); + var formatter: ScreenFormatter = .init(t.screens.active, .vt); formatter.extra.charsets = true; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -4658,16 +4658,16 @@ test "Screen vt with charsets" { try s2.nextSlice(output); // Verify charset state matches - try testing.expectEqual(t.screen.charset.gl, t2.screen.charset.gl); - try testing.expectEqual(t.screen.charset.gr, t2.screen.charset.gr); + try testing.expectEqual(t.screens.active.charset.gl, t2.screens.active.charset.gl); + try testing.expectEqual(t.screens.active.charset.gr, t2.screens.active.charset.gr); try testing.expectEqual( - t.screen.charset.charsets.get(.G0), - t2.screen.charset.charsets.get(.G0), + t.screens.active.charset.charsets.get(.G0), + t2.screens.active.charset.charsets.get(.G0), ); // Verify pin map try testing.expectEqual(output.len, pin_map.items.len); - const node = t.screen.pages.pages.first.?; + const node = t.screens.active.pages.pages.first.?; for (0..output.len) |i| { try testing.expectEqual(node, pin_map.items[i].node); } @@ -4917,7 +4917,7 @@ test "Page html with multiple styles" { // Set bold, then italic, then reset try s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .{ .emit = .html }); @@ -4952,7 +4952,7 @@ test "Page html plain text" { try s.nextSlice("hello, world"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .{ .emit = .html }); @@ -4985,7 +4985,7 @@ test "Page html with colors" { // Set red foreground, blue background try s.nextSlice("\x1b[31;44mcolored"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .{ .emit = .html }); @@ -5055,7 +5055,7 @@ test "Page html with background and foreground colors" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .{ .emit = .html, @@ -5090,7 +5090,7 @@ test "Page html with escaping" { try s.nextSlice("&\"'text"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .{ .emit = .html }); @@ -5161,7 +5161,7 @@ test "Page html with unicode as numeric entities" { // Box drawing characters that caused issue #9426 try s.nextSlice("╰─ ❯"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .{ .emit = .html }); @@ -5194,7 +5194,7 @@ test "Page html ascii characters unchanged" { try s.nextSlice("hello world"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .{ .emit = .html }); @@ -5226,7 +5226,7 @@ test "Page html mixed ascii and unicode" { try s.nextSlice("test ╰─❯ ok"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; var formatter: PageFormatter = .init(page, .{ .emit = .html }); @@ -5260,7 +5260,7 @@ test "Page VT with palette option emits RGB" { try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); try s.nextSlice("\x1b[31mred"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; // Without palette option - should emit palette index @@ -5304,7 +5304,7 @@ test "Page html with palette option emits RGB" { try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); try s.nextSlice("\x1b[31mred"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; // Without palette option - should emit CSS variable @@ -5357,7 +5357,7 @@ test "Page VT style reset properly closes styles" { // Set bold, then reset with SGR 0 try s.nextSlice("\x1b[1mbold\x1b[0mnormal"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; builder.clearRetainingCapacity(); @@ -5387,7 +5387,7 @@ test "Page codepoint_map single replacement" { try s.nextSlice("hello world"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; // Replace 'o' with 'x' @@ -5446,7 +5446,7 @@ test "Page codepoint_map conflicting replacement prefers last" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; // Replace 'o' with 'x', then with 'y' - should prefer last @@ -5488,7 +5488,7 @@ test "Page codepoint_map replace with string" { try s.nextSlice("hello"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; // Replace 'o' with a multi-byte string @@ -5544,7 +5544,7 @@ test "Page codepoint_map range replacement" { try s.nextSlice("abcdefg"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; // Replace 'b' through 'e' with 'X' @@ -5582,7 +5582,7 @@ test "Page codepoint_map multiple ranges" { try s.nextSlice("hello world"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; // Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z' @@ -5626,7 +5626,7 @@ test "Page codepoint_map unicode replacement" { try s.nextSlice("hello ⚡ world"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; // Replace lightning bolt with fire emoji @@ -5691,7 +5691,7 @@ test "Page codepoint_map with styled formats" { try s.nextSlice("\x1b[31mred text\x1b[0m"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; // Replace 'e' with 'X' in styled text @@ -5732,7 +5732,7 @@ test "Page codepoint_map empty map" { try s.nextSlice("hello world"); - const pages = &t.screen.pages; + const pages = &t.screens.active.pages; const page = &pages.pages.last.?.data; // Empty map should not change anything diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index 9cf0bda0c..1559c0cec 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -30,7 +30,7 @@ pub fn execute( // If storage is disabled then we disable the full protocol. This means // we don't even respond to queries so the terminal completely acts as // if this feature is not supported. - if (!terminal.screen.kitty_images.enabled()) { + if (!terminal.screens.active.kitty_images.enabled()) { log.debug("kitty graphics requested but disabled", .{}); return null; } @@ -55,7 +55,7 @@ pub fn execute( // The `q` setting inherits the value from the starting command // unless `q` is set >= 1 on this command. If it is, then we save // that as the new `q` setting. - const storage = &terminal.screen.kitty_images; + const storage = &terminal.screens.active.kitty_images; if (storage.loading) |loading| switch (cmd.quiet) { // q=0 we use whatever the start command value is .no => quiet = loading.quiet, @@ -196,7 +196,7 @@ fn display( }; // Verify the requested image exists if we have an ID - const storage = &terminal.screen.kitty_images; + const storage = &terminal.screens.active.kitty_images; const img_: ?Image = if (d.image_id != 0) storage.imageById(d.image_id) else @@ -223,8 +223,8 @@ fn display( // Track a new pin for our cursor. The cursor is always tracked but we // don't want this one to move with the cursor. - const pin = terminal.screen.pages.trackPin( - terminal.screen.cursor.page_pin.*, + const pin = terminal.screens.active.pages.trackPin( + terminal.screens.active.cursor.page_pin.*, ) catch |err| { log.warn("failed to create pin for Kitty graphics err={}", .{err}); result.message = "EINVAL: failed to prepare terminal state"; @@ -252,7 +252,7 @@ fn display( result.placement_id, p, ) catch |err| { - p.deinit(terminal.screen); + p.deinit(terminal.screens.active); encodeError(&result, err); return result; }; @@ -271,7 +271,7 @@ fn display( }; terminal.setCursorPos( - terminal.screen.cursor.y, + terminal.screens.active.cursor.y, pin.x + size.cols + 1, ); }, @@ -287,7 +287,7 @@ fn delete( terminal: *Terminal, cmd: *const Command, ) Response { - const storage = &terminal.screen.kitty_images; + const storage = &terminal.screens.active.kitty_images; storage.delete(alloc, terminal, cmd.control.delete); // Delete never responds on success @@ -304,7 +304,7 @@ fn loadAndAddImage( display: ?command.Display = null, } { const t = cmd.transmission().?; - const storage = &terminal.screen.kitty_images; + const storage = &terminal.screens.active.kitty_images; // Determine our image. This also handles chunking and early exit. var loading: LoadingImage = if (storage.loading) |loading| loading: { @@ -496,7 +496,7 @@ test "kittygfx default format is rgba" { const resp = execute(alloc, &t, &cmd).?; try testing.expect(resp.ok()); - const storage = &t.screen.kitty_images; + const storage = &t.screens.active.kitty_images; const img = storage.imageById(1).?; try testing.expectEqual(command.Transmission.Format.rgba, img.format); } diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 34b9a6e85..cfa654ae8 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -232,7 +232,7 @@ pub const ImageStorage = struct { // Deinit the placement and remove it const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (delete_images) self.deleteIfUnused(alloc, image_id); } @@ -247,7 +247,7 @@ pub const ImageStorage = struct { .id => |v| self.deleteById( alloc, - t.screen, + t.screens.active, v.image_id, v.placement_id, v.delete, @@ -257,7 +257,7 @@ pub const ImageStorage = struct { const img = self.imageByNumber(v.image_number) orelse break :newest; self.deleteById( alloc, - t.screen, + t.screens.active, img.id, v.placement_id, v.delete, @@ -269,8 +269,8 @@ pub const ImageStorage = struct { alloc, t, .{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, } }, delete_images, {}, @@ -332,7 +332,7 @@ pub const ImageStorage = struct { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t) orelse continue; if (rect.top_left.x <= x and rect.bottom_right.x >= x) { - entry.value_ptr.deinit(t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -350,7 +350,7 @@ pub const ImageStorage = struct { // v.y is in active coords so we want to convert it to a pin // so we can compare by page offsets. - const target_pin = t.screen.pages.pin(.{ .active = .{ + const target_pin = t.screens.active.pages.pin(.{ .active = .{ .y = std.math.cast(size.CellCountInt, v.y - 1) orelse break :row, } }) orelse break :row; @@ -364,7 +364,7 @@ pub const ImageStorage = struct { var target_pin_copy = target_pin; target_pin_copy.x = rect.top_left.x; if (target_pin_copy.isBetween(rect.top_left, rect.bottom_right)) { - entry.value_ptr.deinit(t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -387,7 +387,7 @@ pub const ImageStorage = struct { if (entry.value_ptr.z == v.z) { const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -411,7 +411,7 @@ pub const ImageStorage = struct { while (it.next()) |entry| { if (entry.key_ptr.image_id >= v.first or entry.key_ptr.image_id <= v.last) { const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -490,7 +490,7 @@ pub const ImageStorage = struct { comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool, ) void { // Convert our target point to a pin for comparison. - const target_pin = t.screen.pages.pin(p) orelse return; + const target_pin = t.screens.active.pages.pin(p) orelse return; var it = self.placements.iterator(); while (it.next()) |entry| { @@ -498,7 +498,7 @@ pub const ImageStorage = struct { const rect = entry.value_ptr.rect(img, t) orelse continue; if (target_pin.isBetween(rect.top_left, rect.bottom_right)) { if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; - entry.value_ptr.deinit(t.screen); + entry.value_ptr.deinit(t.screens.active); self.placements.removeByPtr(entry.key_ptr); if (delete_unused) self.deleteIfUnused(alloc, img.id); } @@ -811,7 +811,7 @@ fn trackPin( t: *terminal.Terminal, pt: point.Coordinate, ) !*PageList.Pin { - return try t.screen.pages.trackPin(t.screen.pages.pin(.{ + return try t.screens.active.pages.trackPin(t.screens.active.pages.pin(.{ .active = pt, }).?); } @@ -825,7 +825,7 @@ test "storage: add placement with zero placement id" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 0, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); @@ -850,10 +850,10 @@ test "storage: delete all placements and images" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -865,7 +865,7 @@ test "storage: delete all placements and images" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete all placements and images preserves limit" { @@ -873,10 +873,10 @@ test "storage: delete all placements and images preserves limit" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); s.total_limit = 5000; try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); @@ -890,7 +890,7 @@ test "storage: delete all placements and images preserves limit" { try testing.expectEqual(@as(usize, 0), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 5000), s.total_limit); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete all placements" { @@ -898,10 +898,10 @@ test "storage: delete all placements" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -913,7 +913,7 @@ test "storage: delete all placements" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete all placements by image id" { @@ -921,10 +921,10 @@ test "storage: delete all placements by image id" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -936,7 +936,7 @@ test "storage: delete all placements by image id" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); } test "storage: delete all placements by image id and unused images" { @@ -944,10 +944,10 @@ test "storage: delete all placements by image id and unused images" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -959,7 +959,7 @@ test "storage: delete all placements by image id and unused images" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); } test "storage: delete placement by specific id" { @@ -967,10 +967,10 @@ test "storage: delete placement by specific id" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -987,7 +987,7 @@ test "storage: delete placement by specific id" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 2), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(tracked + 2, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 2, t.screens.active.pages.countTrackedPins()); } test "storage: delete intersecting cursor" { @@ -997,23 +997,23 @@ test "storage: delete intersecting cursor" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); - t.screen.cursorAbsolute(12, 12); + t.screens.active.cursorAbsolute(12, 12); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = false }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -1029,23 +1029,23 @@ test "storage: delete intersecting cursor plus unused" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); - t.screen.cursorAbsolute(12, 12); + t.screens.active.cursorAbsolute(12, 12); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -1061,23 +1061,23 @@ test "storage: delete intersecting cursor hits multiple" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) } }); - t.screen.cursorAbsolute(26, 26); + t.screens.active.cursorAbsolute(26, 26); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 1), s.images.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete by column" { @@ -1087,10 +1087,10 @@ test "storage: delete by column" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); @@ -1104,7 +1104,7 @@ test "storage: delete by column" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -1122,7 +1122,7 @@ test "storage: delete by column 1x1" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 0 }) } }); @@ -1153,10 +1153,10 @@ test "storage: delete by row" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) } }); @@ -1170,7 +1170,7 @@ test "storage: delete by row" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -1188,7 +1188,7 @@ test "storage: delete by row 1x1" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) } }); try s.addPlacement(alloc, 1, 2, .{ .location = .{ .pin = try trackPin(&t, .{ .y = 1 }) } }); @@ -1217,10 +1217,10 @@ test "storage: delete images by range 1" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1234,7 +1234,7 @@ test "storage: delete images by range 1" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 3), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete images by range 2" { @@ -1242,10 +1242,10 @@ test "storage: delete images by range 2" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1259,7 +1259,7 @@ test "storage: delete images by range 2" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete images by range 3" { @@ -1267,10 +1267,10 @@ test "storage: delete images by range 3" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1284,7 +1284,7 @@ test "storage: delete images by range 3" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 3), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: delete images by range 4" { @@ -1292,10 +1292,10 @@ test "storage: delete images by range 4" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); + const tracked = t.screens.active.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); @@ -1309,7 +1309,7 @@ test "storage: delete images by range 4" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } test "storage: aspect ratio calculation when only columns or rows specified" { diff --git a/src/terminal/kitty/graphics_unicode.zig b/src/terminal/kitty/graphics_unicode.zig index 491c3e110..b2a90296c 100644 --- a/src/terminal/kitty/graphics_unicode.zig +++ b/src/terminal/kitty/graphics_unicode.zig @@ -893,7 +893,7 @@ test "unicode placement: none" { try t.printString("hello\nworld\n1\n2"); // No placements - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); var it = placementIterator(pin, null); try testing.expect(it.next() == null); } @@ -908,7 +908,7 @@ test "unicode placement: single row/col" { try t.printString("\u{10EEEE}\u{0305}\u{0305}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -933,7 +933,7 @@ test "unicode placement: continuation break" { try t.printString("\u{10EEEE}\u{0305}\u{030E}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -968,7 +968,7 @@ test "unicode placement: continuation with diacritics set" { try t.printString("\u{10EEEE}\u{0305}\u{030E}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -995,7 +995,7 @@ test "unicode placement: continuation with no col" { try t.printString("\u{10EEEE}\u{0305}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1022,7 +1022,7 @@ test "unicode placement: continuation with no diacritics" { try t.printString("\u{10EEEE}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1049,7 +1049,7 @@ test "unicode placement: run ending" { try t.printString("ABC"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1076,7 +1076,7 @@ test "unicode placement: run starting in the middle" { try t.printString("\u{10EEEE}\u{0305}\u{030D}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1102,7 +1102,7 @@ test "unicode placement: specifying image id as palette" { try t.printString("\u{10EEEE}\u{0305}\u{0305}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1127,7 +1127,7 @@ test "unicode placement: specifying image id with high bits" { try t.printString("\u{10EEEE}\u{0305}\u{0305}\u{030E}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1153,7 +1153,7 @@ test "unicode placement: specifying placement id as palette" { try t.printString("\u{10EEEE}\u{0305}\u{0305}"); // Get our top left pin - const pin = t.screen.pages.getTopLeft(.viewport); + const pin = t.screens.active.pages.getTopLeft(.viewport); // Should have exactly one placement var it = placementIterator(pin, null); @@ -1180,7 +1180,7 @@ test "unicode render placement: dog 4x2" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; try s.addImage(alloc, image); @@ -1193,7 +1193,7 @@ test "unicode render placement: dog 4x2" { // Row 1 { const p: Placement = .{ - .pin = t.screen.cursor.page_pin.*, + .pin = t.screens.active.cursor.page_pin.*, .image_id = 1, .placement_id = 0, .col = 0, @@ -1214,7 +1214,7 @@ test "unicode render placement: dog 4x2" { // Row 2 { const p: Placement = .{ - .pin = t.screen.cursor.page_pin.*, + .pin = t.screens.active.cursor.page_pin.*, .image_id = 1, .placement_id = 0, .col = 0, @@ -1247,7 +1247,7 @@ test "unicode render placement: dog 2x2 with blank cells" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; try s.addImage(alloc, image); @@ -1260,7 +1260,7 @@ test "unicode render placement: dog 2x2 with blank cells" { // Row 1 { const p: Placement = .{ - .pin = t.screen.cursor.page_pin.*, + .pin = t.screens.active.cursor.page_pin.*, .image_id = 1, .placement_id = 0, .col = 0, @@ -1281,7 +1281,7 @@ test "unicode render placement: dog 2x2 with blank cells" { // Row 2 { const p: Placement = .{ - .pin = t.screen.cursor.page_pin.*, + .pin = t.screens.active.cursor.page_pin.*, .image_id = 1, .placement_id = 0, .col = 0, @@ -1313,7 +1313,7 @@ test "unicode render placement: dog 1x1" { var t = try terminal.Terminal.init(alloc, .{ .cols = 100, .rows = 100 }); defer t.deinit(alloc); var s: ImageStorage = .{}; - defer s.deinit(alloc, t.screen); + defer s.deinit(alloc, t.screens.active); const image: Image = .{ .id = 1, .width = 500, .height = 306 }; try s.addImage(alloc, image); @@ -1326,7 +1326,7 @@ test "unicode render placement: dog 1x1" { // Row 1 { const p: Placement = .{ - .pin = t.screen.cursor.page_pin.*, + .pin = t.screens.active.cursor.page_pin.*, .image_id = 1, .placement_id = 0, .col = 0, diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index d05417747..2ace939e7 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -112,29 +112,29 @@ test "simple search" { var search: ActiveSearch = try .init(alloc, "Fizz"); defer search.deinit(); - _ = try search.update(&t.screen.pages); + _ = try search.update(&t.screens.active.pages); { const sel = search.next().?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, - } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); } { const sel = search.next().?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 2, - } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); } try testing.expect(search.next() == null); } @@ -150,23 +150,23 @@ test "clear screen and search" { var search: ActiveSearch = try .init(alloc, "Fizz"); defer search.deinit(); - _ = try search.update(&t.screen.pages); + _ = 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"); - _ = try search.update(&t.screen.pages); + _ = try search.update(&t.screens.active.pages); { const sel = search.next().?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 1, - } }, t.screen.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 b1ad88e81..8b6b57949 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -143,8 +143,8 @@ test "simple search" { var search: PageListSearch = try .init( alloc, "Fizz", - &t.screen.pages, - t.screen.pages.pages.last.?, + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, ); defer search.deinit(); @@ -153,22 +153,22 @@ test "simple search" { try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 2, - } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); } { const sel = search.next().?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, - } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); } try testing.expect(search.next() == null); @@ -185,21 +185,21 @@ test "feed multiple pages with matches" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screen.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); try s.nextSlice("Fizz"); - try testing.expect(t.screen.pages.pages.first == t.screen.pages.pages.last); + try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page try s.nextSlice("\r\n"); - try testing.expect(t.screen.pages.pages.first != t.screen.pages.pages.last); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); try s.nextSlice("Buzz\r\nFizz"); var search: PageListSearch = try .init( alloc, "Fizz", - &t.screen.pages, - t.screen.pages.pages.last.?, + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, ); defer search.deinit(); @@ -229,20 +229,20 @@ test "feed multiple pages no matches" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screen.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); try s.nextSlice("Hello"); // Create second page try s.nextSlice("\r\n"); - try testing.expect(t.screen.pages.pages.first != t.screen.pages.pages.last); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); try s.nextSlice("World"); var search: PageListSearch = try .init( alloc, "Nope", - &t.screen.pages, - t.screen.pages.pages.last.?, + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, ); defer search.deinit(); @@ -267,23 +267,23 @@ test "feed iteratively through multiple matches" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screen.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; // Fill first page with a match at the end for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); try s.nextSlice("Page1Test"); - try testing.expect(t.screen.pages.pages.first == t.screen.pages.pages.last); + try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page with a match try s.nextSlice("\r\n"); - try testing.expect(t.screen.pages.pages.first != t.screen.pages.pages.last); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); try s.nextSlice("Page2Test"); var search: PageListSearch = try .init( alloc, "Test", - &t.screen.pages, - t.screen.pages.pages.last.?, + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, ); defer search.deinit(); @@ -308,23 +308,23 @@ test "feed with match spanning page boundary" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screen.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; // Fill first page ending with "Te" for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - for (0..t.screen.pages.cols - 2) |_| try s.nextSlice("x"); + for (0..t.screens.active.pages.cols - 2) |_| try s.nextSlice("x"); try s.nextSlice("Te"); - try testing.expect(t.screen.pages.pages.first == t.screen.pages.pages.last); + try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Second page starts with "st" try s.nextSlice("st"); - try testing.expect(t.screen.pages.pages.first != t.screen.pages.pages.last); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); var search: PageListSearch = try .init( alloc, "Test", - &t.screen.pages, - t.screen.pages.pages.last.?, + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, ); defer search.deinit(); @@ -338,7 +338,7 @@ test "feed with match spanning page boundary" { const sel = search.next().?; try testing.expect(sel.start().node != sel.end().node); { - const str = try t.screen.selectionString( + const str = try t.screens.active.selectionString( alloc, .{ .sel = sel }, ); @@ -361,24 +361,24 @@ test "feed with match spanning page boundary with newline" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screen.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; // Fill first page ending with "Te" for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); - for (0..t.screen.pages.cols - 2) |_| try s.nextSlice("x"); + for (0..t.screens.active.pages.cols - 2) |_| try s.nextSlice("x"); try s.nextSlice("Te"); - try testing.expect(t.screen.pages.pages.first == t.screen.pages.pages.last); + try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Second page starts with "st" try s.nextSlice("\r\n"); - try testing.expect(t.screen.pages.pages.first != t.screen.pages.pages.last); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); try s.nextSlice("st"); var search: PageListSearch = try .init( alloc, "Test", - &t.screen.pages, - t.screen.pages.pages.last.?, + &t.screens.active.pages, + t.screens.active.pages.pages.last.?, ); defer search.deinit(); diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 674f08b8c..721caeca9 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -391,7 +391,7 @@ test "simple search" { defer s.deinit(); try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); - var search: ScreenSearch = try .init(alloc, t.screen, "Fizz"); + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); try search.searchAll(); try testing.expectEqual(2, search.active_results.items.len); @@ -407,22 +407,22 @@ test "simple search" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 2, - } }, t.screen.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 2, - } }, t.screen.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); } { const sel = matches[1]; try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screen.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screen.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); } } @@ -434,7 +434,7 @@ test "simple search with history" { .max_scrollback = std.math.maxInt(usize), }); defer t.deinit(alloc); - const list: *PageList = &t.screen.pages; + const list: *PageList = &t.screens.active.pages; var s = t.vtStream(); defer s.deinit(); @@ -444,7 +444,7 @@ test "simple search with history" { for (0..list.rows) |_| try s.nextSlice("\r\n"); try s.nextSlice("hello."); - var search: ScreenSearch = try .init(alloc, t.screen, "Fizz"); + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); try search.searchAll(); try testing.expectEqual(0, search.active_results.items.len); @@ -459,11 +459,11 @@ test "simple search with history" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screen.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screen.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); } } @@ -475,14 +475,14 @@ test "reload active with history change" { .max_scrollback = std.math.maxInt(usize), }); defer t.deinit(alloc); - const list: *PageList = &t.screen.pages; + const list: *PageList = &t.screens.active.pages; var s = t.vtStream(); defer s.deinit(); try s.nextSlice("Fizz\r\n"); // Start up our search which will populate our initial active area. - var search: ScreenSearch = try .init(alloc, t.screen, "Fizz"); + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); try search.searchAll(); { @@ -510,22 +510,22 @@ test "reload active with history change" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screen.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, - } }, t.screen.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); } { const sel = matches[0]; try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 1, - } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 4, .y = 1, - } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); } } @@ -544,11 +544,11 @@ test "reload active with history change" { try testing.expectEqual(point.Point{ .active = .{ .x = 2, .y = 0, - } }, t.screen.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 5, .y = 0, - } }, t.screen.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); } } } @@ -562,7 +562,7 @@ test "active change contents" { defer s.deinit(); try s.nextSlice("Fuzz\r\nBuzz\r\nFizz\r\nBang"); - var search: ScreenSearch = try .init(alloc, t.screen, "Fizz"); + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); try search.searchAll(); try testing.expectEqual(1, search.active_results.items.len); @@ -585,10 +585,10 @@ test "active change contents" { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 1, - } }, t.screen.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, - } }, t.screen.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); } } diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index d34e4c84c..3b088e2b7 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -63,15 +63,15 @@ pub const Handler = struct { .cursor_left => self.terminal.cursorLeft(value.value), .cursor_right => self.terminal.cursorRight(value.value), .cursor_pos => self.terminal.setCursorPos(value.row, value.col), - .cursor_col => self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, value.value), - .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screen.cursor.x + 1), + .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( - self.terminal.screen.cursor.y + 1, - self.terminal.screen.cursor.x + 1 +| value.value, + self.terminal.screens.active.cursor.y + 1, + self.terminal.screens.active.cursor.x + 1 +| value.value, ), .cursor_row_relative => self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1 +| value.value, - self.terminal.screen.cursor.x + 1, + self.terminal.screens.active.cursor.y + 1 +| value.value, + self.terminal.screens.active.cursor.x + 1, ), .cursor_style => { const blink = switch (value) { @@ -84,7 +84,7 @@ pub const Handler = struct { .blinking_underline, .steady_underline => .underline, }; self.terminal.modes.set(.cursor_blinking, blink); - self.terminal.screen.cursor.cursor_style = style; + self.terminal.screens.active.cursor.cursor_style = style; }, .erase_display_below => self.terminal.eraseDisplay(.below, value), .erase_display_above => self.terminal.eraseDisplay(.above, value), @@ -136,11 +136,11 @@ pub const Handler = struct { .protected_mode_iso => self.terminal.setProtectedMode(.iso), .protected_mode_dec => self.terminal.setProtectedMode(.dec), .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, - .kitty_keyboard_push => self.terminal.screen.kitty_keyboard.push(value.flags), - .kitty_keyboard_pop => self.terminal.screen.kitty_keyboard.pop(@intCast(value)), - .kitty_keyboard_set => self.terminal.screen.kitty_keyboard.set(.set, value.flags), - .kitty_keyboard_set_or => self.terminal.screen.kitty_keyboard.set(.@"or", value.flags), - .kitty_keyboard_set_not => self.terminal.screen.kitty_keyboard.set(.not, value.flags), + .kitty_keyboard_push => self.terminal.screens.active.kitty_keyboard.push(value.flags), + .kitty_keyboard_pop => self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)), + .kitty_keyboard_set => self.terminal.screens.active.kitty_keyboard.set(.set, value.flags), + .kitty_keyboard_set_or => self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags), + .kitty_keyboard_set_not => self.terminal.screens.active.kitty_keyboard.set(.not, value.flags), .modify_key_format => { self.terminal.flags.modify_other_keys_2 = false; switch (value) { @@ -151,16 +151,16 @@ pub const Handler = struct { .active_status_display => self.terminal.status_display = value, .decaln => try self.terminal.decaln(), .full_reset => self.terminal.fullReset(), - .start_hyperlink => try self.terminal.screen.startHyperlink(value.uri, value.id), - .end_hyperlink => self.terminal.screen.endHyperlink(), + .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), + .end_hyperlink => self.terminal.screens.active.endHyperlink(), .prompt_start => { - self.terminal.screen.cursor.page_row.semantic_prompt = .prompt; + self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; self.terminal.flags.shell_redraws_prompt = value.redraw; }, - .prompt_continuation => self.terminal.screen.cursor.page_row.semantic_prompt = .prompt_continuation, + .prompt_continuation => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation, .prompt_end => self.terminal.markSemanticPrompt(.input), .end_of_input => self.terminal.markSemanticPrompt(.command), - .end_of_command => self.terminal.screen.cursor.page_row.semantic_prompt = .input, + .end_of_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, .mouse_shape => self.terminal.mouse_shape = value, .color_operation => try self.colorOperation(value.op, &value.requests), .kitty_color_report => try self.kittyColorOperation(value), @@ -202,17 +202,17 @@ pub const Handler = struct { inline fn horizontalTab(self: *Handler, count: u16) !void { for (0..count) |_| { - const x = self.terminal.screen.cursor.x; + const x = self.terminal.screens.active.cursor.x; try self.terminal.horizontalTab(); - if (x == self.terminal.screen.cursor.x) break; + if (x == self.terminal.screens.active.cursor.x) break; } } inline fn horizontalTabBack(self: *Handler, count: u16) !void { for (0..count) |_| { - const x = self.terminal.screen.cursor.x; + const x = self.terminal.screens.active.cursor.x; try self.terminal.horizontalTabBack(); - if (x == self.terminal.screen.cursor.x) break; + if (x == self.terminal.screens.active.cursor.x) break; } } @@ -246,7 +246,7 @@ pub const Handler = struct { .enable_mode_3 => {}, .@"132_column" => try self.terminal.deccolm( - self.terminal.screen.alloc, + self.terminal.screens.active.alloc, if (enabled) .@"132_cols" else .@"80_cols", ), @@ -410,8 +410,8 @@ test "basic print" { defer s.deinit(); try s.nextSlice("Hello"); - try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -427,13 +427,13 @@ test "cursor movement" { // Move cursor using escape sequences try s.nextSlice("Hello\x1B[1;1H"); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); // Move to position 2,3 try s.nextSlice("\x1B[2;3H"); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); } test "erase operations" { @@ -445,8 +445,8 @@ test "erase operations" { // Print some text try s.nextSlice("Hello World"); - try testing.expectEqual(@as(usize, 11), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 11), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); // Move cursor to position 1,6 and erase from cursor to end of line try s.nextSlice("\x1B[1;6H"); @@ -465,7 +465,7 @@ test "tabs" { defer s.deinit(); try s.nextSlice("A\tB"); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.x); const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -554,21 +554,21 @@ test "cursor save and restore" { // Move cursor to 10,15 try s.nextSlice("\x1B[10;15H"); - try testing.expectEqual(@as(usize, 14), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); // Save cursor try s.nextSlice("\x1B7"); // Move cursor elsewhere try s.nextSlice("\x1B[1;1H"); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); // Restore cursor try s.nextSlice("\x1B8"); - try testing.expectEqual(@as(usize, 14), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 9), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 14), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); } test "attributes" { @@ -603,8 +603,8 @@ test "DECALN screen alignment" { try testing.expectEqualStrings("EEEEEEEEEE\nEEEEEEEEEE\nEEEEEEEEEE", str); // Cursor should be at 1,1 - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); } test "full reset" { @@ -624,8 +624,8 @@ test "full reset" { try s.nextSlice("\x1Bc"); // Verify reset state - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom); try testing.expect(t.modes.get(.wraparound)); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 6371722b5..9bcbd38ca 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -257,7 +257,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { } // Set our default cursor style - term.screen.cursor.cursor_style = opts.config.cursor_style; + term.screens.active.cursor.cursor_style = opts.config.cursor_style; // Setup our terminal size in pixels for certain requests. term.width_px = term.cols * opts.size.cell.width; @@ -579,17 +579,17 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { if (self.terminal.screens.active_key == .alternate) return; // Clear our selection - self.terminal.screen.clearSelection(); + self.terminal.screens.active.clearSelection(); // Clear our scrollback if (history) self.terminal.eraseDisplay(.scrollback, false); // If we're not at a prompt, we just delete above the cursor. if (!self.terminal.cursorIsAtPrompt()) { - if (self.terminal.screen.cursor.y > 0) { - self.terminal.screen.eraseRows( + if (self.terminal.screens.active.cursor.y > 0) { + self.terminal.screens.active.eraseRows( .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = self.terminal.screen.cursor.y - 1 } }, + .{ .active = .{ .y = self.terminal.screens.active.cursor.y - 1 } }, ); } @@ -599,8 +599,8 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // graphics that are placed baove the cursor or if it deletes // all of them. We delete all of them for now but if this behavior // isn't fully correct we should fix this later. - self.terminal.screen.kitty_images.delete( - self.terminal.screen.alloc, + self.terminal.screens.active.kitty_images.delete( + self.terminal.screens.active.alloc, &self.terminal, .{ .all = true }, ); @@ -633,7 +633,7 @@ pub fn jumpToPrompt(self: *Termio, delta: isize) !void { { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - self.terminal.screen.scroll(.{ .delta_prompt = delta }); + self.terminal.screens.active.scroll(.{ .delta_prompt = delta }); } try self.renderer_wakeup.notify(); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 4b40ff3cf..fd94f77bc 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -181,15 +181,15 @@ pub const StreamHandler = struct { .cursor_left => self.terminal.cursorLeft(value.value), .cursor_right => self.terminal.cursorRight(value.value), .cursor_pos => self.terminal.setCursorPos(value.row, value.col), - .cursor_col => self.terminal.setCursorPos(self.terminal.screen.cursor.y + 1, value.value), - .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screen.cursor.x + 1), + .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( - self.terminal.screen.cursor.y + 1, - self.terminal.screen.cursor.x + 1 +| value.value, + self.terminal.screens.active.cursor.y + 1, + self.terminal.screens.active.cursor.x + 1 +| value.value, ), .cursor_row_relative => self.terminal.setCursorPos( - self.terminal.screen.cursor.y + 1 +| value.value, - self.terminal.screen.cursor.x + 1, + self.terminal.screens.active.cursor.y + 1 +| value.value, + self.terminal.screens.active.cursor.x + 1, ), .cursor_style => try self.setCursorStyle(value), .erase_display_below => self.terminal.eraseDisplay(.below, value), @@ -254,23 +254,23 @@ pub const StreamHandler = struct { .kitty_keyboard_query => try self.queryKittyKeyboard(), .kitty_keyboard_push => { log.debug("pushing kitty keyboard mode: {}", .{value.flags}); - self.terminal.screen.kitty_keyboard.push(value.flags); + self.terminal.screens.active.kitty_keyboard.push(value.flags); }, .kitty_keyboard_pop => { log.debug("popping kitty keyboard mode n={}", .{value}); - self.terminal.screen.kitty_keyboard.pop(@intCast(value)); + self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)); }, .kitty_keyboard_set => { log.debug("setting kitty keyboard mode: set {}", .{value.flags}); - self.terminal.screen.kitty_keyboard.set(.set, value.flags); + self.terminal.screens.active.kitty_keyboard.set(.set, value.flags); }, .kitty_keyboard_set_or => { log.debug("setting kitty keyboard mode: or {}", .{value.flags}); - self.terminal.screen.kitty_keyboard.set(.@"or", value.flags); + self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags); }, .kitty_keyboard_set_not => { log.debug("setting kitty keyboard mode: not {}", .{value.flags}); - self.terminal.screen.kitty_keyboard.set(.not, value.flags); + self.terminal.screens.active.kitty_keyboard.set(.not, value.flags); }, .kitty_color_report => try self.kittyColorReport(value), .color_operation => try self.colorOperation(value.op, &value.requests, value.terminator), @@ -371,7 +371,7 @@ pub const StreamHandler = struct { .decscusr => { const blink = self.terminal.modes.get(.cursor_blinking); - const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { + const style: u8 = switch (self.terminal.screens.active.cursor.cursor_style) { .block => if (blink) 1 else 2, .underline => if (blink) 3 else 4, .bar => if (blink) 5 else 6, @@ -443,17 +443,17 @@ pub const StreamHandler = struct { inline fn horizontalTab(self: *StreamHandler, count: u16) !void { for (0..count) |_| { - const x = self.terminal.screen.cursor.x; + const x = self.terminal.screens.active.cursor.x; try self.terminal.horizontalTab(); - if (x == self.terminal.screen.cursor.x) break; + if (x == self.terminal.screens.active.cursor.x) break; } } inline fn horizontalTabBack(self: *StreamHandler, count: u16) !void { for (0..count) |_| { - const x = self.terminal.screen.cursor.x; + const x = self.terminal.screens.active.cursor.x; try self.terminal.horizontalTabBack(); - if (x == self.terminal.screen.cursor.x) break; + if (x == self.terminal.screens.active.cursor.x) break; } } @@ -688,11 +688,11 @@ pub const StreamHandler = struct { } inline fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { - try self.terminal.screen.startHyperlink(uri, id); + try self.terminal.screens.active.startHyperlink(uri, id); } pub inline fn endHyperlink(self: *StreamHandler) !void { - self.terminal.screen.endHyperlink(); + self.terminal.screens.active.endHyperlink(); } pub fn deviceAttributes( @@ -732,11 +732,11 @@ pub const StreamHandler = struct { x: usize, y: usize, } = if (self.terminal.modes.get(.origin)) .{ - .x = self.terminal.screen.cursor.x -| self.terminal.scrolling_region.left, - .y = self.terminal.screen.cursor.y -| self.terminal.scrolling_region.top, + .x = self.terminal.screens.active.cursor.x -| self.terminal.scrolling_region.left, + .y = self.terminal.screens.active.cursor.y -| self.terminal.scrolling_region.top, } else .{ - .x = self.terminal.screen.cursor.x, - .y = self.terminal.screen.cursor.y, + .x = self.terminal.screens.active.cursor.x, + .y = self.terminal.screens.active.cursor.y, }; // Response always is at least 4 chars, so this leaves the @@ -766,7 +766,7 @@ pub const StreamHandler = struct { switch (style) { .default => { self.default_cursor = true; - self.terminal.screen.cursor.cursor_style = self.default_cursor_style; + self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style; self.terminal.modes.set( .cursor_blinking, self.default_cursor_blink orelse true, @@ -774,32 +774,32 @@ pub const StreamHandler = struct { }, .blinking_block => { - self.terminal.screen.cursor.cursor_style = .block; + self.terminal.screens.active.cursor.cursor_style = .block; self.terminal.modes.set(.cursor_blinking, true); }, .steady_block => { - self.terminal.screen.cursor.cursor_style = .block; + self.terminal.screens.active.cursor.cursor_style = .block; self.terminal.modes.set(.cursor_blinking, false); }, .blinking_underline => { - self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.screens.active.cursor.cursor_style = .underline; self.terminal.modes.set(.cursor_blinking, true); }, .steady_underline => { - self.terminal.screen.cursor.cursor_style = .underline; + self.terminal.screens.active.cursor.cursor_style = .underline; self.terminal.modes.set(.cursor_blinking, false); }, .blinking_bar => { - self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.screens.active.cursor.cursor_style = .bar; self.terminal.modes.set(.cursor_blinking, true); }, .steady_bar => { - self.terminal.screen.cursor.cursor_style = .bar; + self.terminal.screens.active.cursor.cursor_style = .bar; self.terminal.modes.set(.cursor_blinking, false); }, } @@ -844,7 +844,7 @@ pub const StreamHandler = struct { log.debug("querying kitty keyboard mode", .{}); var data: termio.Message.WriteReq.Small.Array = undefined; const resp = try std.fmt.bufPrint(&data, "\x1b[?{}u", .{ - self.terminal.screen.kitty_keyboard.current().int(), + self.terminal.screens.active.kitty_keyboard.current().int(), }); self.messageWriter(.{ From 4ba00dbe89267619bf170c7ecc06a0b180386050 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 15:44:57 -0800 Subject: [PATCH 308/702] fix harfbuzz --- src/font/shaper/harfbuzz.zig | 40 ++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 634afd0de..83de69cfe 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -509,7 +509,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, 30, 3, 0); + var screen = try terminal.Screen.init( + alloc, + .{ .cols = 30, .rows = 3 }, + ); defer screen.deinit(); var page = screen.pages.pages.first.?.data; @@ -628,7 +631,10 @@ test "shape with empty cells in between" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 30, 3, 0); + var screen = try terminal.Screen.init( + alloc, + .{ .cols = 30, .rows = 3 }, + ); defer screen.deinit(); try screen.testWriteString("A"); screen.cursorRight(5); @@ -666,7 +672,10 @@ 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, 30, 3, 0); + var screen = try terminal.Screen.init( + alloc, + .{ .cols = 30, .rows = 3 }, + ); defer screen.deinit(); try screen.testWriteString(buf[0..buf_idx]); @@ -997,7 +1006,10 @@ test "shape cursor boundary and colored emoji" { defer testdata.deinit(); // Make a screen with some data - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init( + alloc, + .{ .cols = 3, .rows = 10 }, + ); defer screen.deinit(); try screen.testWriteString("👍🏼"); @@ -1112,7 +1124,10 @@ test "shape cell attribute change" { // Bold vs regular should split { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + var screen = try terminal.Screen.init( + alloc, + .{ .cols = 3, .rows = 10 }, + ); defer screen.deinit(); try screen.testWriteString(">"); try screen.setAttribute(.{ .bold = {} }); @@ -1134,7 +1149,10 @@ test "shape cell attribute change" { // Changing fg color should split { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + 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(">"); @@ -1157,7 +1175,10 @@ test "shape cell attribute change" { // Changing bg color should not split { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + 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(">"); @@ -1180,7 +1201,10 @@ test "shape cell attribute change" { // Same bg color should not split { - var screen = try terminal.Screen.init(alloc, 3, 10, 0); + 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(">"); From 2452026ff34c8633ff4849c8496bb574a5510af1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 15:52:59 -0800 Subject: [PATCH 309/702] terminal: kitty limits only if kitty graphics being built --- src/terminal/Terminal.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index f67cb119c..472b390d1 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2614,7 +2614,10 @@ pub fn switchScreen(self: *Terminal, key: ScreenSet.Key) !?*Screen { // Inherit our Kitty image storage limit from the primary // screen if we have to initialize. - .kitty_image_storage_limit = primary.kitty_images.total_limit, + .kitty_image_storage_limit = if (comptime build_options.kitty_graphics) + primary.kitty_images.total_limit + else + 0, }, ); }; From de545eeae130ad31bbf171041ad3d39ed21673cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 16:01:56 -0800 Subject: [PATCH 310/702] lib-vt: export stream.Action for custom streams --- src/lib_vt.zig | 3 ++- src/terminal/main.zig | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index e95eee5f4..41fd1c71e 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -48,13 +48,14 @@ pub const Parser = terminal.Parser; pub const Pin = PageList.Pin; pub const Point = point.Point; pub const Screen = terminal.Screen; -pub const ScreenType = Terminal.ScreenType; +pub const ScreenSet = terminal.ScreenSet; pub const Selection = terminal.Selection; pub const SizeReportStyle = terminal.SizeReportStyle; pub const StringMap = terminal.StringMap; pub const Style = terminal.Style; pub const Terminal = terminal.Terminal; pub const Stream = terminal.Stream; +pub const StreamAction = terminal.StreamAction; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = terminal.CursorStyle; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 3f67b78b3..d57bd6530 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -49,6 +49,7 @@ pub const StringMap = @import("StringMap.zig"); pub const Style = style.Style; pub const Terminal = @import("Terminal.zig"); pub const Stream = stream.Stream; +pub const StreamAction = stream.Action; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; From 19dfc0aa9817e8831274f47b6423ff890c4b54aa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 16:29:34 -0800 Subject: [PATCH 311/702] terminal: search.Thread more boilerplate, test starting --- src/datastruct/blocking_queue.zig | 2 +- src/terminal/search/Thread.zig | 144 +++++++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/datastruct/blocking_queue.zig b/src/datastruct/blocking_queue.zig index 06bc8267f..c95b6b96a 100644 --- a/src/datastruct/blocking_queue.zig +++ b/src/datastruct/blocking_queue.zig @@ -73,7 +73,7 @@ pub fn BlockingQueue( not_full_waiters: usize = 0, /// Allocate the blocking queue on the heap. - pub fn create(alloc: Allocator) !*Self { + pub fn create(alloc: Allocator) Allocator.Error!*Self { const ptr = try alloc.create(Self); errdefer alloc.destroy(ptr); diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index b9f98a9dc..38a092a91 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -9,8 +9,13 @@ pub const Thread = @This(); const std = @import("std"); +const builtin = @import("builtin"); +const testing = std.testing; const Allocator = std.mem.Allocator; +const xev = @import("../../global.zig").xev; +const internal_os = @import("../../os/main.zig"); const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; +const Terminal = @import("../Terminal.zig"); const log = std.log.scoped(.search_thread); @@ -21,23 +26,57 @@ alloc: std.mem.Allocator, /// this is a blocking queue so if it is full you will get errors (or block). mailbox: *Mailbox, +/// The event loop for the search thread. +loop: xev.Loop, + +/// This can be used to wake up the renderer and force a render safely from +/// any thread. +wakeup: xev.Async, +wakeup_c: xev.Completion = .{}, + +/// This can be used to stop the thread on the next loop iteration. +stop: xev.Async, +stop_c: xev.Completion = .{}, + +/// The options used to initialize this thread. +opts: Options, + /// Initialize the thread. This does not START the thread. This only sets /// up all the internal state necessary prior to starting the thread. It /// is up to the caller to start the thread with the threadMain entrypoint. -pub fn init(alloc: Allocator) Thread { +pub fn init(alloc: Allocator, opts: Options) !Thread { // The mailbox for messaging this thread var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); + // Create our event loop. + var loop = try xev.Loop.init(.{}); + errdefer loop.deinit(); + + // This async handle is used to "wake up" the renderer and force a render. + var wakeup_h = try xev.Async.init(); + errdefer wakeup_h.deinit(); + + // This async handle is used to stop the loop and force the thread to end. + var stop_h = try xev.Async.init(); + errdefer stop_h.deinit(); + return .{ .alloc = alloc, .mailbox = mailbox, + .loop = loop, + .wakeup = wakeup_h, + .stop = stop_h, + .opts = opts, }; } /// Clean up the thread. This is only safe to call once the thread /// completes executing; the caller must join prior to this. pub fn deinit(self: *Thread) void { + self.wakeup.deinit(); + self.stop.deinit(); + self.loop.deinit(); // Nothing can possibly access the mailbox anymore, destroy it. self.mailbox.destroy(self.alloc); } @@ -53,11 +92,110 @@ pub fn threadMain(self: *Thread) void { fn threadMain_(self: *Thread) !void { defer log.debug("search thread exited", .{}); - _ = self; + + // Right now, on Darwin, `std.Thread.setName` can only name the current + // thread, and we have no way to get the current thread from within it, + // so instead we use this code to name the thread instead. + if (comptime builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"search".*); + + // We can run with lower priority than other threads. + const class: internal_os.macos.QosClass = .utility; + if (internal_os.macos.setQosClass(class)) { + log.debug("thread QoS class set class={}", .{class}); + } else |err| { + log.warn("error setting QoS class err={}", .{err}); + } + } + + // Start the async handlers + self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback); + self.stop.wait(&self.loop, &self.stop_c, Thread, self, stopCallback); + + // Send an initial wakeup so we drain our mailbox immediately. + try self.wakeup.notify(); + + // Run + log.debug("starting search thread", .{}); + defer log.debug("starting search thread shutdown", .{}); + _ = try self.loop.run(.until_done); } +/// Drain the mailbox. +fn drainMailbox(self: *Thread) !void { + while (self.mailbox.pop()) |message| { + log.debug("mailbox message={}", .{message}); + } +} + +fn wakeupCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Async.WaitError!void, +) xev.CallbackAction { + _ = r catch |err| { + log.warn("error in wakeup err={}", .{err}); + return .rearm; + }; + + const self = self_.?; + + // When we wake up, we drain the mailbox. Mailbox producers should + // wake up our thread after publishing. + self.drainMailbox() catch |err| + log.warn("error draining mailbox err={}", .{err}); + + return .rearm; +} + +fn stopCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Async.WaitError!void, +) xev.CallbackAction { + _ = r catch unreachable; + self_.?.loop.stop(); + return .disarm; +} + +pub const Options = struct { + /// Mutex that must be held while reading/writing the terminal. + mutex: *std.Thread.Mutex, + + /// The terminal data to search. + terminal: *Terminal, +}; + /// The type used for sending messages to the thread. pub const Mailbox = BlockingQueue(Message, 64); /// The messages that can be sent to the thread. -pub const Message = union(enum) {}; +pub const Message = union(enum) { + /// 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, +}; + +test { + const alloc = testing.allocator; + var mutex: std.Thread.Mutex = .{}; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var thread: Thread = try .init(alloc, .{ + .mutex = &mutex, + .terminal = &t, + }); + defer thread.deinit(); + + var os_thread = try std.Thread.spawn( + .{}, + threadMain, + .{&thread}, + ); + try thread.stop.notify(); + os_thread.join(); +} From d1ad32eadd5c49147005180ee94731aecccc9bd8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 17:04:31 -0800 Subject: [PATCH 312/702] terminal: search.Thread starting search loop --- src/terminal/search/Thread.zig | 164 ++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 2 deletions(-) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 38a092a91..6e8115b84 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -15,8 +15,12 @@ const Allocator = std.mem.Allocator; const xev = @import("../../global.zig").xev; const internal_os = @import("../../os/main.zig"); const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; +const Screen = @import("../Screen.zig"); +const ScreenSet = @import("../ScreenSet.zig"); const Terminal = @import("../Terminal.zig"); +const ScreenSearch = @import("screen.zig").ScreenSearch; + const log = std.log.scoped(.search_thread); /// Allocator used for some state @@ -38,6 +42,10 @@ wakeup_c: xev.Completion = .{}, stop: xev.Async, stop_c: xev.Completion = .{}, +/// Search state. Starts as null and is populated when a search is +/// started (a needle is given). +search: ?Search = null, + /// The options used to initialize this thread. opts: Options, @@ -79,6 +87,8 @@ pub fn deinit(self: *Thread) void { self.loop.deinit(); // Nothing can possibly access the mailbox anymore, destroy it. self.mailbox.destroy(self.alloc); + + if (self.search) |*s| s.deinit(); } /// The main entrypoint for the thread. @@ -118,16 +128,106 @@ fn threadMain_(self: *Thread) !void { // Run log.debug("starting search thread", .{}); defer log.debug("starting search thread shutdown", .{}); - _ = try self.loop.run(.until_done); + + // Unlike some of our other threads, we interleave search work + // with our xev loop so that we can try to make forward search progress + // while also listening for messages. + while (true) { + // If our loop is canceled then we drain our messages and quit. + if (self.loop.stopped()) { + while (self.mailbox.pop()) |message| { + log.debug("mailbox message ignored during shutdown={}", .{message}); + } + + return; + } + + const s: *Search = if (self.search) |*s| s else { + // If we're not actively searching, we can block the loop + // until it does some work. + try self.loop.run(.once); + continue; + }; + + if (s.isComplete()) { + // If our search is complete, there's no more work to do, we + // can block until we have an xev action. + try self.loop.run(.once); + continue; + } + + // Tick the search. This will trigger any event callbacks, lock + // for data loading, etc. + try s.tick(self); + + // We have an active search, so we only want to process messages + // we have but otherwise return immediately so we can continue the + // search. + try self.loop.run(.no_wait); + } } /// Drain the mailbox. 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 the search term to the given value. +fn changeNeedle(self: *Thread, needle: []const u8) !void { + log.debug("changing search needle to '{s}'", .{needle}); + + // Stop the previous search + if (self.search) |*s| { + s.deinit(); + self.search = null; + } + + // No needle means stop the search. + if (needle.len == 0) return; + + // Our new search state + var search: Search = .empty; + errdefer search.deinit(); + + // We need to grab the terminal lock to setup our search state. + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + const t: *Terminal = self.opts.terminal; + + // Go through all our screens, setup our search state. + // + // NOTE(mitchellh): Maybe we should only initialize the screen we're + // currently looking at (the active screen) and then let our screen + // reconciliation timer add the others later in order to minimize + // startup latency. + var it = t.screens.all.iterator(); + while (it.next()) |entry| { + var screen_search: ScreenSearch = ScreenSearch.init( + self.alloc, + entry.value.*, + needle, + ) catch |err| switch (err) { + error.OutOfMemory => { + // We can ignore this (although OOM probably means the whole + // ship is sinking). Our reconciliation timer will try again + // later. + log.warn("error initializing screen search key={} err={}", .{ entry.key, err }); + continue; + }, + }; + errdefer screen_search.deinit(); + search.screens.put(entry.key, screen_search); + } + + // Our search state is setup + self.search = search; +} + fn wakeupCallback( self_: ?*Thread, _: *xev.Loop, @@ -166,6 +266,13 @@ pub const Options = struct { /// The terminal data to search. terminal: *Terminal, + + /// The callback for events from the search thread along with optional + /// userdata. This can be null if you don't want to receive events, + /// which could be useful for a one-time search (although, odd, you + /// should use our search structures directly then). + event_cb: ?*const fn (event: Event, userdata: ?*anyopaque) void = null, + event_userdata: ?*anyopaque = null, }; /// The type used for sending messages to the thread. @@ -179,12 +286,57 @@ pub const Message = union(enum) { change_needle: []const u8, }; +/// Events that can be emitted from the search thread. The caller +/// chooses to handle these as they see fit. +pub const Event = union(enum) { + /// Nothing yet. :) + todo, +}; + +/// Search state. +const Search = struct { + /// The searchers for all the screens. + screens: std.EnumMap(ScreenSet.Key, ScreenSearch), + + pub const empty: Search = .{ + .screens = .init(.{}), + }; + + pub fn deinit(self: *Search) void { + var it = self.screens.iterator(); + while (it.next()) |entry| entry.value.deinit(); + } + + /// Returns true if all searches on all screens are complete. + pub fn isComplete(self: *Search) bool { + var it = self.screens.iterator(); + while (it.next()) |entry| { + switch (entry.value.state) { + .complete => {}, + else => return false, + } + } + + return true; + } + + pub fn tick(self: *Search, thread: *Thread) !void { + // TODO + _ = self; + _ = thread; + } +}; + test { const alloc = testing.allocator; var mutex: std.Thread.Mutex = .{}; - var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 }); defer t.deinit(alloc); + var stream = t.vtStream(); + defer stream.deinit(); + try stream.nextSlice("Hello, world"); + var thread: Thread = try .init(alloc, .{ .mutex = &mutex, .terminal = &t, @@ -196,6 +348,14 @@ test { threadMain, .{&thread}, ); + + // Start our search + _ = thread.mailbox.push( + .{ .change_needle = "world" }, + .forever, + ); + try thread.wakeup.notify(); + try thread.stop.notify(); os_thread.join(); } From 1867928b849ebec4653ef817d2c84ded496ecf1d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 20:24:14 -0800 Subject: [PATCH 313/702] terminal: search thread search ticking --- src/terminal/search/Thread.zig | 170 ++++++++++++++++++++++++++++++--- src/terminal/search/screen.zig | 27 +++++- 2 files changed, 180 insertions(+), 17 deletions(-) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 6e8115b84..984730793 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -12,6 +12,7 @@ const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; const Allocator = std.mem.Allocator; +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; @@ -158,7 +159,37 @@ fn threadMain_(self: *Thread) !void { // Tick the search. This will trigger any event callbacks, lock // for data loading, etc. - try s.tick(self); + switch (s.tick()) { + // We're complete now when we were not before. Notify! + .complete => if (self.opts.event_cb) |cb| { + cb(.complete, self.opts.event_userdata); + }, + + // Forward progress was made. + .progress => {}, + + // All searches are blocked. Let's grab the lock and feed data. + .blocked => { + try s.feed(self.opts.mutex, self.opts.terminal); + + // Feeding can result in completion if there is no more + // data to feed. If we transitioned to complete, notify! + if (self.opts.event_cb) |cb| { + if (s.isComplete()) cb( + .complete, + self.opts.event_userdata, + ); + } + }, + } + + // Publish any notifications about search state changes. + if (self.opts.event_cb) |cb| { + s.notify( + cb, + self.opts.event_userdata, + ); + } // We have an active search, so we only want to process messages // we have but otherwise return immediately so we can continue the @@ -262,7 +293,7 @@ fn stopCallback( pub const Options = struct { /// Mutex that must be held while reading/writing the terminal. - mutex: *std.Thread.Mutex, + mutex: *Mutex, /// The terminal data to search. terminal: *Terminal, @@ -271,10 +302,12 @@ pub const Options = struct { /// userdata. This can be null if you don't want to receive events, /// which could be useful for a one-time search (although, odd, you /// should use our search structures directly then). - event_cb: ?*const fn (event: Event, userdata: ?*anyopaque) void = null, + event_cb: ?EventCallback = null, event_userdata: ?*anyopaque = null, }; +pub const EventCallback = *const fn (event: Event, userdata: ?*anyopaque) void; + /// The type used for sending messages to the thread. pub const Mailbox = BlockingQueue(Message, 64); @@ -289,8 +322,11 @@ 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) { - /// Nothing yet. :) - todo, + /// Search is complete for the given needle on all screens. + complete, + + /// Total matches on the current active screen have changed. + total_matches: usize, }; /// Search state. @@ -298,8 +334,16 @@ 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, + pub const empty: Search = .{ .screens = .init(.{}), + .last_active_screen = .primary, + .last_total = null, }; pub fn deinit(self: *Search) void { @@ -311,23 +355,114 @@ const Search = struct { pub fn isComplete(self: *Search) bool { var it = self.screens.iterator(); while (it.next()) |entry| { - switch (entry.value.state) { - .complete => {}, - else => return false, - } + if (!entry.value.state.isComplete()) return false; } return true; } - pub fn tick(self: *Search, thread: *Thread) !void { - // TODO - _ = self; - _ = thread; + pub const Tick = enum { + /// All searches are complete. + complete, + + /// Progress was made on at least one screen. + progress, + + /// All incomplete searches are blocked on feed. + blocked, + }; + + /// Tick the search forward as much as possible without acquiring + /// the big lock. Returns the overall tick progress. + pub fn tick(self: *Search) Tick { + var result: Tick = .complete; + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (entry.value.tick()) { + result = .progress; + } else |err| switch (err) { + // Ignore... nothing we can do. + error.OutOfMemory => log.warn( + "error ticking screen search key={} err={}", + .{ entry.key, err }, + ), + + // Ignore, good for us. State remains whatever it is. + error.SearchComplete => {}, + + // Ignore, too, progressed + error.FeedRequired => switch (result) { + // If we think we're complete, we're not because we're + // blocked now (nothing made progress). + .complete => result = .blocked, + + // If we made some progress, we remain in progress + // since blocked means no progress at all. + .progress => {}, + + // If we're blocked already then we remain blocked. + .blocked => {}, + }, + } + } + + // log.debug("tick result={}", .{result}); + return result; + } + + /// Grab the mutex and update any state that requires it, such as + /// feeding additional data to the searches or updating the active screen. + pub fn feed(self: *Search, mutex: *Mutex, t: *Terminal) !void { + mutex.lock(); + defer mutex.unlock(); + + // 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 + } + + // Feed data + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (entry.value.state.needsFeed()) { + try entry.value.feed(); + } + } + } + + /// Notify about any changes to the search state. + /// + /// This doesn't require any locking as it only reads internal state. + pub fn notify( + self: *Search, + cb: EventCallback, + ud: ?*anyopaque, + ) void { + const screen_search = self.screens.get(self.last_active_screen) orelse return; + const total = screen_search.matchesLen(); + if (total != self.last_total) { + self.last_total = total; + cb(.{ .total_matches = total }, ud); + } } }; test { + const UserData = struct { + const Self = @This(); + reset: std.Thread.ResetEvent = .{}, + total: usize = 0, + + fn callback(event: Event, userdata: ?*anyopaque) void { + const ud: *Self = @ptrCast(@alignCast(userdata.?)); + switch (event) { + .complete => ud.reset.set(), + .total_matches => |v| ud.total = v, + } + } + }; + const alloc = testing.allocator; var mutex: std.Thread.Mutex = .{}; var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 }); @@ -337,9 +472,12 @@ test { defer stream.deinit(); try stream.nextSlice("Hello, world"); + var ud: UserData = .{}; var thread: Thread = try .init(alloc, .{ .mutex = &mutex, .terminal = &t, + .event_cb = &UserData.callback, + .event_userdata = &ud, }); defer thread.deinit(); @@ -356,6 +494,12 @@ test { ); try thread.wakeup.notify(); + // Wait for completion + try ud.reset.timedWait(100 * std.time.ns_per_ms); + + // Stop the thread try thread.stop.notify(); os_thread.join(); + + try testing.expectEqual(1, ud.total); } diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 721caeca9..c60161153 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -78,6 +78,20 @@ pub const ScreenSearch = struct { /// Search is complete given the current terminal state. complete, + + pub fn isComplete(self: State) bool { + return switch (self) { + .complete => true, + else => false, + }; + } + + pub fn needsFeed(self: State) bool { + return switch (self) { + .history_feed => true, + else => false, + }; + } }; // Initialize a screen search for the given screen and needle. @@ -114,10 +128,10 @@ pub const ScreenSearch = struct { return self.active.window.alloc; } - pub const TickError = Allocator.Error || error{ - FeedRequired, - SearchComplete, - }; + /// Returns the total number of matches found so far. + pub fn matchesLen(self: *const ScreenSearch) usize { + return self.active_results.items.len + self.history_results.items.len; + } /// Returns all matches as an owned slice (caller must free). /// The matches are ordered from most recent to oldest (e.g. bottom @@ -167,6 +181,11 @@ pub const ScreenSearch = struct { } } + pub const TickError = Allocator.Error || error{ + FeedRequired, + SearchComplete, + }; + /// Make incremental progress on the search without accessing any /// screen state (so no lock is required). /// From bfa397b196df7e57a2e59bc9e232a1af6d7ef4bf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 21:24:17 -0800 Subject: [PATCH 314/702] terminal: search thread active screen reconciliation loop --- src/terminal/search/Thread.zig | 141 ++++++++++++++++++++------------- src/terminal/search/screen.zig | 17 ++-- 2 files changed, 97 insertions(+), 61 deletions(-) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 984730793..557001fe8 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -161,25 +161,19 @@ fn threadMain_(self: *Thread) !void { // for data loading, etc. switch (s.tick()) { // We're complete now when we were not before. Notify! - .complete => if (self.opts.event_cb) |cb| { - cb(.complete, self.opts.event_userdata); - }, + .complete => {}, // Forward progress was made. .progress => {}, // All searches are blocked. Let's grab the lock and feed data. .blocked => { - try s.feed(self.opts.mutex, self.opts.terminal); - - // Feeding can result in completion if there is no more - // data to feed. If we transitioned to complete, notify! - if (self.opts.event_cb) |cb| { - if (s.isComplete()) cb( - .complete, - self.opts.event_userdata, - ); - } + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + try s.feed( + self.alloc, + self.opts.terminal, + ); }, } @@ -189,6 +183,13 @@ fn threadMain_(self: *Thread) !void { cb, self.opts.event_userdata, ); + + // If our forward progress resulted in us becoming complete, + // then notify our callback. + if (s.isComplete()) cb( + .complete, + self.opts.event_userdata, + ); } // We have an active search, so we only want to process messages @@ -221,42 +222,11 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // No needle means stop the search. if (needle.len == 0) return; - // Our new search state - var search: Search = .empty; - errdefer search.deinit(); - // We need to grab the terminal lock to setup our search state. self.opts.mutex.lock(); defer self.opts.mutex.unlock(); const t: *Terminal = self.opts.terminal; - - // Go through all our screens, setup our search state. - // - // NOTE(mitchellh): Maybe we should only initialize the screen we're - // currently looking at (the active screen) and then let our screen - // reconciliation timer add the others later in order to minimize - // startup latency. - var it = t.screens.all.iterator(); - while (it.next()) |entry| { - var screen_search: ScreenSearch = ScreenSearch.init( - self.alloc, - entry.value.*, - needle, - ) catch |err| switch (err) { - error.OutOfMemory => { - // We can ignore this (although OOM probably means the whole - // ship is sinking). Our reconciliation timer will try again - // later. - log.warn("error initializing screen search key={} err={}", .{ entry.key, err }); - continue; - }, - }; - errdefer screen_search.deinit(); - search.screens.put(entry.key, screen_search); - } - - // Our search state is setup - self.search = search; + self.search = try .init(self.alloc, needle, t); } fn wakeupCallback( @@ -340,11 +310,27 @@ const Search = struct { /// The last total matches reported. last_total: ?usize, - pub const empty: Search = .{ - .screens = .init(.{}), - .last_active_screen = .primary, - .last_total = null, - }; + pub fn init( + alloc: Allocator, + needle: []const u8, + t: *Terminal, + ) Allocator.Error!Search { + // We only initialize the primary screen for now. Our reconciler + // via feed will handle setting up our other screens. We just need + // to setup at least one here so that we can store our needle. + var screen_search: ScreenSearch = try .init( + alloc, + t.screens.get(.primary).?, + needle, + ); + errdefer screen_search.deinit(); + + return .{ + .screens = .init(.{ .primary = screen_search }), + .last_active_screen = .primary, + .last_total = null, + }; + } pub fn deinit(self: *Search) void { var it = self.screens.iterator(); @@ -412,16 +398,63 @@ const Search = struct { /// Grab the mutex and update any state that requires it, such as /// feeding additional data to the searches or updating the active screen. - pub fn feed(self: *Search, mutex: *Mutex, t: *Terminal) !void { - mutex.lock(); - defer mutex.unlock(); - + pub fn feed( + self: *Search, + alloc: Allocator, + 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 } + // Reconcile our screens with the terminal screens. Remove + // searchers for screens that no longer exist and add searchers + // for screens that do exist but we don't have yet. + { + // Remove screens we have that no longer exist or changed. + var it = self.screens.iterator(); + while (it.next()) |entry| { + const remove: bool = remove: { + // If the screen doesn't exist at all, remove it. + const actual = t.screens.all.get(entry.key) orelse break :remove true; + + // If the screen pointer changed, remove it, the screen + // was totally reinitialized. + break :remove actual != entry.value.screen; + }; + + if (remove) { + entry.value.deinit(); + _ = self.screens.remove(entry.key); + } + } + } + { + // Add screens that exist but we don't have yet. + var it = t.screens.all.iterator(); + while (it.next()) |entry| { + if (self.screens.contains(entry.key)) continue; + self.screens.put(entry.key, ScreenSearch.init( + alloc, + entry.value.*, + self.screens.get(.primary).?.needle(), + ) catch |err| switch (err) { + error.OutOfMemory => { + // OOM is probably going to sink the entire ship but + // we can just ignore it and wait on the next + // reconciliation to try again. + log.warn( + "error initializing screen search for key={} err={}", + .{ entry.key, err }, + ); + continue; + }, + }); + } + } + // Feed data var it = self.screens.iterator(); while (it.next()) |entry| { diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index c60161153..07d700742 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -98,11 +98,11 @@ pub const ScreenSearch = struct { pub fn init( alloc: Allocator, screen: *Screen, - needle: []const u8, + needle_unowned: []const u8, ) Allocator.Error!ScreenSearch { var result: ScreenSearch = .{ .screen = screen, - .active = try .init(alloc, needle), + .active = try .init(alloc, needle_unowned), .history = null, .state = .active, .active_results = .empty, @@ -128,6 +128,12 @@ pub const ScreenSearch = struct { return self.active.window.alloc; } + /// The needle that this search is using. + pub fn needle(self: *const ScreenSearch) []const u8 { + assert(self.active.window.direction == .forward); + return self.active.window.needle; + } + /// Returns the total number of matches found so far. pub fn matchesLen(self: *const ScreenSearch) usize { return self.active_results.items.len + self.history_results.items.len; @@ -310,12 +316,9 @@ pub const ScreenSearch = struct { // No history search yet, but we now have history. So let's // initialize. - // Our usage of needle below depends on this - assert(self.active.window.direction == .forward); - var search: PageListSearch = try .init( self.allocator(), - self.active.window.needle, + self.needle(), list, history_node, ); @@ -347,7 +350,7 @@ pub const ScreenSearch = struct { var window: SlidingWindow = try .init( alloc, .forward, - self.active.window.needle, + self.needle(), ); defer window.deinit(); while (true) { From b124b78313a9d017191b6fdc44e628f446563368 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sat, 15 Nov 2025 08:10:05 +0100 Subject: [PATCH 315/702] macOS: find correct tab bar when in fullscreen Fixes #9593 --- .../Window Styles/TerminalWindow.swift | 23 +++++++++++++++++-- .../TitlebarTabsTahoeTerminalWindow.swift | 18 ++++----------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index c33073a45..a829ec519 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -202,10 +202,29 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + func findTitlebarView() -> NSView? { + // Find our tab bar. If it doesn't exist we don't do anything. + // + // In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`. + // In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes; + // in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`. + // ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205 + guard let themeFrameView = contentView?.rootView else { return nil } + let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) { + themeFrameView.value(forKey: "titlebarView") as? NSView + } else { + NSView?.none + } + return titlebarView + } + + func findTabBar() -> NSView? { + findTitlebarView()?.firstDescendant(withClassName: "NSTabBar") + } + /// Returns true if there is a tab bar visible on this window. var hasTabBar: Bool { - // TODO: use titlebarView to find it instead - contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil + findTabBar() != nil } var hasMoreThanOneTabs: Bool { diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 4e067eddc..7ce138c2a 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -143,19 +143,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // We only want to setup the observer once guard tabBarObserver == nil else { return } - // Find our tab bar. If it doesn't exist we don't do anything. - // - // In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`. - // In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes; - // in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`. - // ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205 - guard let themeFrameView = contentView?.rootView else { return } - let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) { - themeFrameView.value(forKey: "titlebarView") as? NSView - } else { - NSView?.none - } - guard let tabBar = titlebarView?.firstDescendant(withClassName: "NSTabBar") else { return } + guard + let titlebarView = findTitlebarView(), + let tabBar = findTabBar() + else { return } // View model updates must happen on their own ticks. DispatchQueue.main.async { [weak self] in @@ -165,7 +156,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Find our clip view guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } guard let accessoryView = clipView.subviews[safe: 0] else { return } - guard let titlebarView else { return } guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } // Make sure tabBar's height won't be stretched From 8d1dd332c68286009526834ef0fe59a21c3ea901 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sat, 15 Nov 2025 10:14:43 +0100 Subject: [PATCH 316/702] macOS: fix misplaced frame modifier As per #9504, this was supposed to be on `ZStack`, not on the overlay. See also #9503. I cherry-picked it in the wrong place before. --- macos/Sources/Features/Terminal/TerminalView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 74e82836d..8c5955c7f 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -119,6 +119,7 @@ struct TerminalView: View { UpdateOverlay() } } + .frame(maxWidth: .greatestFiniteMagnitude, maxHeight: .greatestFiniteMagnitude) } } } @@ -136,7 +137,6 @@ fileprivate struct UpdateOverlay: View { .padding(.trailing, 9) } } - .frame(maxWidth: .greatestFiniteMagnitude, maxHeight: .greatestFiniteMagnitude) } } } From 99d47a4627494505b71ed03b4a611d9cea8ed3a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Nov 2025 12:50:34 -0800 Subject: [PATCH 317/702] terminal: viewport search --- src/terminal/search.zig | 1 + src/terminal/search/viewport.zig | 293 +++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/terminal/search/viewport.zig diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 510aac980..0f0c53c03 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -3,6 +3,7 @@ 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"); test { diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig new file mode 100644 index 000000000..66cbd33f0 --- /dev/null +++ b/src/terminal/search/viewport.zig @@ -0,0 +1,293 @@ +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; +const Allocator = std.mem.Allocator; +const point = @import("../point.zig"); +const size = @import("../size.zig"); +const PageList = @import("../PageList.zig"); +const Selection = @import("../Selection.zig"); +const SlidingWindow = @import("sliding_window.zig").SlidingWindow; +const Terminal = @import("../Terminal.zig"); + +/// Searches for a substring within the viewport of a PageList. +/// +/// This contains logic to efficiently detect when the viewport changes +/// and only re-search when necessary. +/// +/// The specialization for "viewport" is because the viewport is the +/// only part of the search where the user can actively see the results, +/// usually. In that case, it is more efficient to re-search only the +/// viewport rather than store all the results for the entire screen. +/// +/// Note that this searches all the pages that viewport covers, so +/// this can include extra matches outside the viewport if the data +/// lives in the same page. +pub const ViewportSearch = struct { + window: SlidingWindow, + fingerprint: ?Fingerprint, + + pub fn init( + alloc: Allocator, + needle: []const u8, + ) Allocator.Error!ViewportSearch { + // We just do a forward search since the viewport is usually + // pretty small so search results are instant anyways. This avoids + // a small amount of work to reverse things. + var window: SlidingWindow = try .init(alloc, .forward, needle); + errdefer window.deinit(); + return .{ .window = window, .fingerprint = null }; + } + + pub fn deinit(self: *ViewportSearch) void { + if (self.fingerprint) |*fp| fp.deinit(self.window.alloc); + self.window.deinit(); + } + + /// Update the sliding window to reflect the current viewport. This + /// will do nothing if the viewport hasn't changed since the last + /// search. + /// + /// The PageList must be safe to read throughout the lifetime of this + /// function. + /// + /// Returns true if the viewport changed and a re-search is needed. + /// Returns false if the viewport is unchanged. + pub fn update( + self: *ViewportSearch, + list: *PageList, + ) Allocator.Error!bool { + // See if our viewport changed + 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).?; + + // 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 + fingerprint.deinit(self.window.alloc); + return false; + } + + old.deinit(self.window.alloc); + self.fingerprint = null; + } + assert(self.fingerprint == null); + self.fingerprint = fingerprint; + errdefer { + fingerprint.deinit(self.window.alloc); + self.fingerprint = null; + } + + // Clear our previous sliding window + self.window.clearAndRetainCapacity(); + + // Add enough overlap to cover needle.len - 1 bytes (if it + // exists) so we can cover the overlap. We only do this for the + // soft-wrapped prior pages. + var node_ = fingerprint.nodes[0].prev; + var added: usize = 0; + while (node_) |node| : (node_ = node.prev) { + // If the last row of this node isn't wrapped we can't overlap. + const row = node.data.getRow(node.data.size.rows - 1); + if (!row.wrap) break; + + // We could be more accurate here and count bytes since the + // last wrap but its complicated and unlikely multiple pages + // wrap so this should be fine. + added += try self.window.append(node); + if (added >= self.window.needle.len - 1) break; + } + + // We can use our fingerprint nodes to initialize our sliding + // window, since we already traversed the viewport once. + for (fingerprint.nodes) |node| { + _ = try self.window.append(node); + } + + // Add any trailing overlap as well. + trailing: { + const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1]; + if (!end.data.getRow(end.data.size.rows - 1).wrap) break :trailing; + + node_ = end.next; + added = 0; + while (node_) |node| : (node_ = node.next) { + added += try self.window.append(node); + if (added >= self.window.needle.len - 1) break; + + // If this row doesn't wrap, then we can quit + const row = node.data.getRow(node.data.size.rows - 1); + if (!row.wrap) break; + } + } + + return true; + } + + /// 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 { + return self.window.next(); + } + + /// Viewport fingerprint so we can detect when the viewport moves. + const Fingerprint = struct { + /// The nodes that make up the viewport. We need to flatten this + /// to a single list because we can't safely traverse the cached values + /// because the page nodes may be invalid. All that is safe is comparing + /// the actual pointer values. + nodes: []const *PageList.List.Node, + + pub fn init(alloc: Allocator, pages: *PageList) Allocator.Error!Fingerprint { + var list: std.ArrayList(*PageList.List.Node) = .empty; + defer list.deinit(alloc); + + // Get our viewport area. Bottom right of a viewport can never + // fail. + const tl = pages.getTopLeft(.viewport); + const br = pages.getBottomRight(.viewport).?; + + var it = tl.pageIterator(.right_down, br); + while (it.next()) |chunk| try list.append(alloc, chunk.node); + return .{ .nodes = try list.toOwnedSlice(alloc) }; + } + + pub fn deinit(self: *Fingerprint, alloc: Allocator) void { + alloc.free(self.nodes); + } + + pub fn eql(self: Fingerprint, other: Fingerprint) bool { + if (self.nodes.len != other.nodes.len) return false; + for (self.nodes, other.nodes) |a, b| { + if (a != b) return false; + } + return true; + } + }; +}; + +test "simple search" { + 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(); + try testing.expect(try search.update(&t.screens.active.pages)); + + // Viewport contains active so update should always re-search. + try testing.expect(try search.update(&t.screens.active.pages)); + + { + const sel = search.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, 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()).?); + } + { + const sel = search.next().?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, 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()).?); + } + try testing.expect(search.next() == null); +} + +test "clear screen and search" { + 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(); + 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"); + 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 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Fill up first page + const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + try s.nextSlice("Fizz\r\n"); + for (1..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); + + // Create second page + try s.nextSlice("\r\n"); + try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); + try s.nextSlice("Buzz\r\nFizz"); + + try t.scrollViewport(.top); + + var search: ViewportSearch = try .init(alloc, "Fizz"); + defer search.deinit(); + try testing.expect(try search.update(&t.screens.active.pages)); + + { + const sel = search.next().?; + 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()).?); + } + try testing.expect(search.next() == null); + + // Viewport doesn't contain active + try testing.expect(!try search.update(&t.screens.active.pages)); + try testing.expect(search.next() == null); +} From acab8c90a2b1dfe9a34442d60a0b8d13857e68e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Nov 2025 21:36:43 -0800 Subject: [PATCH 318/702] terminal: search.Thread searches viewport and notifies viewport results --- src/terminal/search/Thread.zig | 157 ++++++++++++++++++++++++------- src/terminal/search/viewport.zig | 18 +++- 2 files changed, 138 insertions(+), 37 deletions(-) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 557001fe8..2e38a51ad 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -16,11 +16,15 @@ 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 PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); +const Selection = @import("../Selection.zig"); const Terminal = @import("../Terminal.zig"); const ScreenSearch = @import("screen.zig").ScreenSearch; +const ViewportSearch = @import("viewport.zig").ViewportSearch; const log = std.log.scoped(.search_thread); @@ -150,6 +154,17 @@ fn threadMain_(self: *Thread) !void { continue; }; + // If we have an active search, we always send any pending + // notifications. Even if the search is complete, there may be + // notifications to send. + if (self.opts.event_cb) |cb| { + s.notify( + self.alloc, + cb, + self.opts.event_userdata, + ); + } + if (s.isComplete()) { // If our search is complete, there's no more work to do, we // can block until we have an xev action. @@ -170,31 +185,25 @@ fn threadMain_(self: *Thread) !void { .blocked => { self.opts.mutex.lock(); defer self.opts.mutex.unlock(); - try s.feed( - self.alloc, - self.opts.terminal, - ); + s.feed(self.alloc, self.opts.terminal); }, } - // Publish any notifications about search state changes. - if (self.opts.event_cb) |cb| { - s.notify( - cb, - self.opts.event_userdata, - ); - - // If our forward progress resulted in us becoming complete, - // then notify our callback. - if (s.isComplete()) cb( - .complete, - self.opts.event_userdata, - ); + // Ticking can complete our search. + if (s.isComplete()) { + if (self.opts.event_cb) |cb| { + cb( + .complete, + self.opts.event_userdata, + ); + } } // We have an active search, so we only want to process messages // we have but otherwise return immediately so we can continue the - // search. + // search. If the above completed the search, we still want to + // go around the loop as quickly as possible to send notifications, + // and then we'll block on the loop next time. try self.loop.run(.no_wait); } } @@ -222,11 +231,13 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // No needle means stop the search. if (needle.len == 0) return; - // We need to grab the terminal lock to setup our search state. + // Setup our search state. + self.search = try .init(self.alloc, needle); + + // We need to grab the terminal lock and do an initial feed. self.opts.mutex.lock(); defer self.opts.mutex.unlock(); - const t: *Terminal = self.opts.terminal; - self.search = try .init(self.alloc, needle, t); + self.search.?.feed(self.alloc, self.opts.terminal); } fn wakeupCallback( @@ -297,10 +308,17 @@ pub const Event = union(enum) { /// Total matches on the current active screen have changed. total_matches: usize, + + /// 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, }; /// Search state. const Search = struct { + /// Active viewport search for the active screen. + viewport: ViewportSearch, + /// The searchers for all the screens. screens: std.EnumMap(ScreenSet.Key, ScreenSearch), @@ -310,29 +328,27 @@ const Search = struct { /// The last total matches reported. last_total: ?usize, + /// The last viewport matches we found. + stale_viewport_matches: bool, + pub fn init( alloc: Allocator, needle: []const u8, - t: *Terminal, ) Allocator.Error!Search { - // We only initialize the primary screen for now. Our reconciler - // via feed will handle setting up our other screens. We just need - // to setup at least one here so that we can store our needle. - var screen_search: ScreenSearch = try .init( - alloc, - t.screens.get(.primary).?, - needle, - ); - errdefer screen_search.deinit(); + var vp: ViewportSearch = try .init(alloc, needle); + errdefer vp.deinit(); return .{ - .screens = .init(.{ .primary = screen_search }), + .viewport = vp, + .screens = .init(.{}), .last_active_screen = .primary, .last_total = null, + .stale_viewport_matches = true, }; } pub fn deinit(self: *Search) void { + self.viewport.deinit(); var it = self.screens.iterator(); while (it.next()) |entry| entry.value.deinit(); } @@ -402,7 +418,7 @@ const Search = struct { self: *Search, alloc: Allocator, t: *Terminal, - ) !void { + ) void { // Update our active screen if (t.screens.active_key != self.last_active_screen) { self.last_active_screen = t.screens.active_key; @@ -439,7 +455,7 @@ const Search = struct { self.screens.put(entry.key, ScreenSearch.init( alloc, entry.value.*, - self.screens.get(.primary).?.needle(), + self.viewport.needle(), ) catch |err| switch (err) { error.OutOfMemory => { // OOM is probably going to sink the entire ship but @@ -455,11 +471,26 @@ const Search = struct { } } + // Check our viewport for changes. + if (self.viewport.update(&t.screens.active.pages)) |updated| { + if (updated) self.stale_viewport_matches = true; + } else |err| switch (err) { + error.OutOfMemory => log.warn( + "error updating viewport search err={}", + .{err}, + ), + } + // Feed data var it = self.screens.iterator(); while (it.next()) |entry| { if (entry.value.state.needsFeed()) { - try entry.value.feed(); + entry.value.feed() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error feeding screen search key={} err={}", + .{ entry.key, err }, + ), + }; } } } @@ -469,15 +500,49 @@ const Search = struct { /// This doesn't require any locking as it only reads internal state. pub fn notify( self: *Search, + alloc: Allocator, cb: EventCallback, ud: ?*anyopaque, ) void { const screen_search = self.screens.get(self.last_active_screen) orelse return; + + // Check our total match data const total = screen_search.matchesLen(); if (total != self.last_total) { self.last_total = total; cb(.{ .total_matches = total }, ud); } + + // Check our viewport matches. If they're stale, we do the + // viewport search now. We do this as part of notify and not + // tick because the viewport search is very fast and doesn't + // require ticked progress or feeds. + if (self.stale_viewport_matches) viewport: { + // We always make stale as false. Even if we fail below + // we require a re-feed to re-search the viewport. The feed + // 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) { + error.OutOfMemory => { + log.warn( + "error collecting viewport matches err={}", + .{err}, + ); + + // Reset the viewport so we force an update on the + // next feed. + self.viewport.reset(); + break :viewport; + }, + }; + } + + cb(.{ .viewport_matches = results.items }, ud); + } } }; @@ -486,12 +551,20 @@ test { const Self = @This(); reset: std.Thread.ResetEvent = .{}, total: usize = 0, + viewport: []const Selection = &.{}, fn callback(event: Event, userdata: ?*anyopaque) void { const ud: *Self = @ptrCast(@alignCast(userdata.?)); switch (event) { .complete => ud.reset.set(), .total_matches => |v| ud.total = v, + .viewport_matches => |v| { + testing.allocator.free(ud.viewport); + ud.viewport = testing.allocator.dupe( + Selection, + v, + ) catch unreachable; + }, } } }; @@ -506,6 +579,7 @@ test { try stream.nextSlice("Hello, world"); var ud: UserData = .{}; + defer alloc.free(ud.viewport); var thread: Thread = try .init(alloc, .{ .mutex = &mutex, .terminal = &t, @@ -534,5 +608,18 @@ test { try thread.stop.notify(); os_thread.join(); + // 1 total matches try testing.expectEqual(1, ud.total); + try testing.expectEqual(1, ud.viewport.len); + { + const sel = ud.viewport[0]; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 0, + } }, 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()).?); + } } diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 66cbd33f0..5b9199afc 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -28,12 +28,12 @@ pub const ViewportSearch = struct { pub fn init( alloc: Allocator, - needle: []const u8, + needle_unowned: []const u8, ) Allocator.Error!ViewportSearch { // We just do a forward search since the viewport is usually // pretty small so search results are instant anyways. This avoids // a small amount of work to reverse things. - var window: SlidingWindow = try .init(alloc, .forward, needle); + var window: SlidingWindow = try .init(alloc, .forward, needle_unowned); errdefer window.deinit(); return .{ .window = window, .fingerprint = null }; } @@ -43,6 +43,20 @@ pub const ViewportSearch = struct { self.window.deinit(); } + /// Reset our fingerprint and results so that the next update will + /// always re-search. + pub fn reset(self: *ViewportSearch) void { + if (self.fingerprint) |*fp| fp.deinit(self.window.alloc); + self.fingerprint = null; + self.window.clearAndRetainCapacity(); + } + + /// The needle that this search is using. + pub fn needle(self: *const ViewportSearch) []const u8 { + assert(self.window.direction == .forward); + return self.window.needle; + } + /// Update the sliding window to reflect the current viewport. This /// will do nothing if the viewport hasn't changed since the last /// search. From f0af63db155c0ca36ce1d74ecda9847e30fbc007 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Nov 2025 13:41:35 -0800 Subject: [PATCH 319/702] terminal: search thread refresh timer to reconcile state --- src/terminal/search/Thread.zig | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 2e38a51ad..38a7d88ed 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -28,6 +28,12 @@ const ViewportSearch = @import("viewport.zig").ViewportSearch; const log = std.log.scoped(.search_thread); +/// The interval at which we refresh the terminal state to check if +/// there are any changes that require us to re-search. This should be +/// balanced to be fast enough to be responsive but not so fast that +/// we hold the terminal lock too often. +const REFRESH_INTERVAL = 24; // 40 FPS + /// Allocator used for some state alloc: std.mem.Allocator, @@ -47,6 +53,13 @@ wakeup_c: xev.Completion = .{}, stop: xev.Async, stop_c: xev.Completion = .{}, +/// The timer used for refreshing the terminal state to determine if +/// we have a stale active area, viewport, screen change, etc. This is +/// CPU intensive so we stop doing this under certain conditions. +refresh: xev.Timer, +refresh_c: xev.Completion = .{}, +refresh_active: bool = false, + /// Search state. Starts as null and is populated when a search is /// started (a needle is given). search: ?Search = null, @@ -74,12 +87,17 @@ pub fn init(alloc: Allocator, opts: Options) !Thread { var stop_h = try xev.Async.init(); errdefer stop_h.deinit(); + // Refresh timer, see comments. + var refresh_h = try xev.Timer.init(); + errdefer refresh_h.deinit(); + return .{ .alloc = alloc, .mailbox = mailbox, .loop = loop, .wakeup = wakeup_h, .stop = stop_h, + .refresh = refresh_h, .opts = opts, }; } @@ -87,6 +105,7 @@ pub fn init(alloc: Allocator, opts: Options) !Thread { /// Clean up the thread. This is only safe to call once the thread /// completes executing; the caller must join prior to this. pub fn deinit(self: *Thread) void { + self.refresh.deinit(); self.wakeup.deinit(); self.stop.deinit(); self.loop.deinit(); @@ -130,6 +149,9 @@ fn threadMain_(self: *Thread) !void { // Send an initial wakeup so we drain our mailbox immediately. try self.wakeup.notify(); + // Start the refresh timer + self.startRefreshTimer(); + // Run log.debug("starting search thread", .{}); defer log.debug("starting search thread shutdown", .{}); @@ -192,6 +214,13 @@ fn threadMain_(self: *Thread) !void { // Ticking can complete our search. if (s.isComplete()) { if (self.opts.event_cb) |cb| { + // Send all pending notifications before we send complete. + s.notify( + self.alloc, + cb, + self.opts.event_userdata, + ); + cb( .complete, self.opts.event_userdata, @@ -240,6 +269,30 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { self.search.?.feed(self.alloc, self.opts.terminal); } +fn startRefreshTimer(self: *Thread) void { + // Set our active state so it knows we're running. We set this before + // even checking the active state in case we have a pending shutdown. + self.refresh_active = true; + + // If our timer is already active, then we don't have to do anything. + if (self.refresh_c.state() == .active) return; + + // Start the timer which loops + self.refresh.run( + &self.loop, + &self.refresh_c, + REFRESH_INTERVAL, + Thread, + self, + refreshCallback, + ); +} + +fn stopRefreshTimer(self: *Thread) void { + // This will stop the refresh on the next iteration. + self.refresh_active = false; +} + fn wakeupCallback( self_: ?*Thread, _: *xev.Loop, @@ -272,6 +325,39 @@ fn stopCallback( return .disarm; } +fn refreshCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + _ = r catch unreachable; + const self: *Thread = self_ orelse { + // This shouldn't happen so we log it. + log.warn("refresh callback fired without data set", .{}); + return .disarm; + }; + + // Run our feed if we have a search active. + if (self.search) |*s| { + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + s.feed(self.alloc, self.opts.terminal); + } + + // Only continue if we're still active + if (self.refresh_active) self.refresh.run( + &self.loop, + &self.refresh_c, + REFRESH_INTERVAL, + Thread, + self, + refreshCallback, + ); + + return .disarm; +} + pub const Options = struct { /// Mutex that must be held while reading/writing the terminal. mutex: *Mutex, From 90a6ea7aa50bdac129ebda3d4a8a36266e51978a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Nov 2025 13:47:49 -0800 Subject: [PATCH 320/702] terminal: note some search thread TODOs we can address later --- src/terminal/search/Thread.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 38a7d88ed..9517cfd78 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -28,6 +28,13 @@ const ViewportSearch = @import("viewport.zig").ViewportSearch; const log = std.log.scoped(.search_thread); +// TODO: Some stuff that could be improved: +// - pause the refresh timer when the terminal isn't focused +// - we probably want to know our progress through the search +// for viewport matches so we can show n/total UI. +// - notifications should be coalesced to avoid spamming a massive +// amount of events if the terminal is changing rapidly. + /// The interval at which we refresh the terminal state to check if /// there are any changes that require us to re-search. This should be /// balanced to be fast enough to be responsive but not so fast that From ead01e8ffd4bc69c9b969ce0d8f6123c69a30fd2 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 16 Nov 2025 00:15:33 +0000 Subject: [PATCH 321/702] 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 7e795338a..dfccaf61d 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-20251103-150536-ae86c8c/ghostty-themes.tgz", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz", .hash = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 107b93906..cd2621b2e 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-20251103-150536-ae86c8c/ghostty-themes.tgz", - "hash": "sha256-wloAMNeEm+8S3oVDzwmJ+F0tvn0lyZt8o7nCYagy9Sk=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz", + "hash": "sha256-VZq3L/cAAu7kLA5oqJYNjAZApoblfBtAzfdKVOuJPQI=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 6b22dfe2f..c38504847 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-20251103-150536-ae86c8c/ghostty-themes.tgz"; - hash = "sha256-wloAMNeEm+8S3oVDzwmJ+F0tvn0lyZt8o7nCYagy9Sk="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz"; + hash = "sha256-VZq3L/cAAu7kLA5oqJYNjAZApoblfBtAzfdKVOuJPQI="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index c92dd1471..6bd86a206 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/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz -https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251103-150536-ae86c8c/ghostty-themes.tgz +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 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 c10e03fa2..8ed18e38b 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-20251103-150536-ae86c8c/ghostty-themes.tgz", + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz", "dest": "vendor/p/N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", - "sha256": "c25a0030d7849bef12de8543cf0989f85d2dbe7d25c99b7ca3b9c261a832f529" + "sha256": "559ab72ff70002eee42c0e68a8960d8c0640a686e57c1b40cdf74a54eb893d02" }, { "type": "archive", From 79af2378d2d014601f1c2b39c15cfdd8980cf5e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Nov 2025 19:48:03 -0800 Subject: [PATCH 322/702] terminal: unify all notification sending into the notify command --- src/terminal/search/Thread.zig | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 9517cfd78..776dfc84a 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -218,23 +218,6 @@ fn threadMain_(self: *Thread) !void { }, } - // Ticking can complete our search. - if (s.isComplete()) { - if (self.opts.event_cb) |cb| { - // Send all pending notifications before we send complete. - s.notify( - self.alloc, - cb, - self.opts.event_userdata, - ); - - cb( - .complete, - self.opts.event_userdata, - ); - } - } - // We have an active search, so we only want to process messages // we have but otherwise return immediately so we can continue the // search. If the above completed the search, we still want to @@ -421,6 +404,9 @@ const Search = struct { /// The last total matches reported. last_total: ?usize, + /// True if we sent the complete notification yet. + last_complete: bool, + /// The last viewport matches we found. stale_viewport_matches: bool, @@ -436,6 +422,7 @@ const Search = struct { .screens = .init(.{}), .last_active_screen = .primary, .last_total = null, + .last_complete = false, .stale_viewport_matches = true, }; } @@ -636,6 +623,12 @@ const Search = struct { cb(.{ .viewport_matches = results.items }, ud); } + + // Send our complete notification if we just completed. + if (!self.last_complete and self.isComplete()) { + self.last_complete = true; + cb(.complete, ud); + } } }; 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 323/702] 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 324/702] 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 325/702] 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 326/702] 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 327/702] 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 328/702] 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 329/702] 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 330/702] 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 331/702] 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 332/702] 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 333/702] 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 334/702] 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 335/702] 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 336/702] =?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 337/702] 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 338/702] 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 339/702] 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 340/702] 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 341/702] 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 342/702] 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 343/702] 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 344/702] 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 345/702] 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 346/702] 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 347/702] 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 348/702] 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 349/702] 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 350/702] 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 351/702] 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 352/702] 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 353/702] 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 354/702] 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 3e5c4590da8e6303eface76535b3798c182f598a Mon Sep 17 00:00:00 2001 From: LN Liberda Date: Wed, 8 Oct 2025 05:24:30 +0200 Subject: [PATCH 355/702] Add system integration for highway --- src/build/SharedDeps.zig | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index dfa676bba..e530e4885 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -719,15 +719,19 @@ pub fn addSimd( } // Highway - if (b.lazyDependency("highway", .{ - .target = target, - .optimize = optimize, - })) |highway_dep| { - m.linkLibrary(highway_dep.artifact("highway")); - if (static_libs) |v| try v.append( - b.allocator, - highway_dep.artifact("highway").getEmittedBin(), - ); + if (b.systemIntegrationOption("highway", .{})) { + m.linkSystemLibrary("libhwy", dynamic_link_opts); + } else { + if (b.lazyDependency("highway", .{ + .target = target, + .optimize = optimize, + })) |highway_dep| { + m.linkLibrary(highway_dep.artifact("highway")); + if (static_libs) |v| try v.append( + b.allocator, + highway_dep.artifact("highway").getEmittedBin(), + ); + } } // utfcpp - This is used as a dependency on our hand-written C++ code @@ -746,6 +750,7 @@ pub fn addSimd( m.addIncludePath(b.path("src")); { // From hwy/detect_targets.h + const HWY_AVX10_2: c_int = 1 << 3; const HWY_AVX3_SPR: c_int = 1 << 4; const HWY_AVX3_ZEN4: c_int = 1 << 6; const HWY_AVX3_DL: c_int = 1 << 7; @@ -756,7 +761,7 @@ pub fn addSimd( // The performance difference between AVX2 and AVX512 is not // significant for our use case and AVX512 is very rare on consumer // hardware anyways. - const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; + const HWY_DISABLED_TARGETS: c_int = HWY_AVX10_2 | HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; m.addCSourceFiles(.{ .files = &.{ From 8d8798bc79602d48473299aeb871b4615f5be611 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 19 Nov 2025 06:15:26 -1000 Subject: [PATCH 356/702] 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 357/702] 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 358/702] 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 359/702] 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 360/702] 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 361/702] 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 362/702] 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 363/702] 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 701a2a1e05806094b5752e42caa3032a118fbdba Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 13 Nov 2025 08:53:04 -0600 Subject: [PATCH 364/702] gtk: update nixpkgs and zig-gobject for Gnome 49 --- build.zig.zon | 6 +++--- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flake.lock | 37 +++++++++++++------------------------ flake.nix | 9 ++++----- flatpak/zig-packages.json | 6 +++--- 7 files changed, 30 insertions(+), 42 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index dfccaf61d..92246cdba 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -55,10 +55,10 @@ .lazy = true, }, .gobject = .{ - // https://github.com/jcollie/ghostty-gobject based on zig_gobject + // https://github.com/ghostty-org/zig-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - .hash = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV", + .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index cd2621b2e..0e3b9b97a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -24,10 +24,10 @@ "url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz", "hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=" }, - "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV": { + "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": { "name": "gobject", - "url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - "hash": "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo=" + "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { "name": "gtk4_layer_shell", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c38504847..73a769ea4 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -123,11 +123,11 @@ in }; } { - name = "gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV"; + name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-"; path = fetchZigArtifact { name = "gobject"; - url = "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst"; - hash = "sha256-SXiqGm81aUn6yq1wFXgNTAULdKOHS/Rzkp5OgNkkcXo="; + url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst"; + hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 6bd86a206..189f7f320 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,6 @@ https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz -https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz @@ -27,6 +26,7 @@ https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.t https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz +https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz https://github.com/jacobsandlund/uucode/archive/b309dfb4e25a38201d7b300b201a698e02283862.tar.gz https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251110-150531-d5f3d53/ghostty-themes.tgz diff --git a/flake.lock b/flake.lock index 90b97ed4a..0150f7b84 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1747046372, - "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "lastModified": 1761588595, + "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", "owner": "edolstra", "repo": "flake-compat", - "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", "type": "github" }, "original": { @@ -36,30 +36,17 @@ }, "nixpkgs": { "locked": { - "lastModified": 315532800, - "narHash": "sha256-sV6pJNzFkiPc6j9Bi9JuHBnWdVhtKB/mHgVmMPvDFlk=", - "rev": "82c2e0d6dde50b17ae366d2aa36f224dc19af469", + "lastModified": 1763191728, + "narHash": "sha256-gI9PpaoX4/f28HkjcTbFVpFhtOxSDtOEdFaHZrdETe0=", + "rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre877938.82c2e0d6dde5/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre896415.1d4c88323ac3/nixexprs.tar.xz" }, "original": { "type": "tarball", "url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1758360447, - "narHash": "sha256-XDY3A83bclygHDtesRoaRTafUd80Q30D/Daf9KSG6bs=", - "rev": "8eaee110344796db060382e15d3af0a9fc396e0e", - "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-25.11pre864002.8eaee1103447/nixexprs.tar.xz" - }, - "original": { - "type": "tarball", - "url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz" - } - }, "root": { "inputs": { "flake-compat": "flake-compat", @@ -97,11 +84,11 @@ ] }, "locked": { - "lastModified": 1760401936, - "narHash": "sha256-/zj5GYO5PKhBWGzbHbqT+ehY8EghuABdQ2WGfCwZpCQ=", + "lastModified": 1763295135, + "narHash": "sha256-sGv/NHCmEnJivguGwB5w8LRmVqr1P72OjS+NzcJsssE=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "365085b6652259753b598d43b723858184980bbe", + "rev": "64f8b42cfc615b2cf99144adf2b7728c7847c72a", "type": "github" }, "original": { @@ -112,7 +99,9 @@ }, "zon2nix": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": [ + "nixpkgs" + ] }, "locked": { "lastModified": 1758405547, diff --git a/flake.nix b/flake.nix index 3dcfef185..18ca3ac18 100644 --- a/flake.nix +++ b/flake.nix @@ -6,7 +6,9 @@ # glibc versions used by our dependencies from Nix are compatible with the # system glibc that the user is building for. # - # We are currently on unstable to get Zig 0.15 for our package.nix + # We are currently on nixpkgs-unstable to get Zig 0.15 for our package.nix and + # Gnome 49/Gtk 4.20. + # nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; flake-utils.url = "github:numtide/flake-utils"; @@ -28,10 +30,7 @@ zon2nix = { url = "github:jcollie/zon2nix?rev=bf983aa90ff169372b9fa8c02e57ea75e0b42245"; inputs = { - # Don't override nixpkgs until Zig 0.15 is available in the Nix branch - # we are using for "normal" builds. - # - # nixpkgs.follows = "nixpkgs"; + nixpkgs.follows = "nixpkgs"; }; }; }; diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 8ed18e38b..417284788 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,9 +31,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/gobject-2025-09-20-20-1.tar.zst", - "dest": "vendor/p/gobject-0.3.0-Skun7ET3nQCqJhDL0KnF_X7M4L7o7JePsJBbrYpEr7UV", - "sha256": "4978aa1a6f356949facaad7015780d4c050b74a3874bf473929e4e80d924717a" + "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", + "sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48" }, { "type": "archive", From f1ab3b20ae6a6798d80cda190073d3d9c079507d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 13 Nov 2025 08:53:36 -0600 Subject: [PATCH 365/702] gtk: support GTK 4.20 media queries in runtime & custom css --- src/apprt/gtk/class/application.zig | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index eac88f9cf..0d66d16ec 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -1580,7 +1580,7 @@ pub const Application = extern struct { .dark; log.debug("style manager changed scheme={}", .{scheme}); - const priv = self.private(); + const priv: *Private = self.private(); const core_app = priv.core_app; core_app.colorSchemeEvent(self.rt(), scheme) catch |err| { log.warn("error updating app color scheme err={}", .{err}); @@ -1593,6 +1593,26 @@ pub const Application = extern struct { ); }; } + + if (gtk_version.atLeast(4, 20, 0)) { + const gtk_scheme: gtk.InterfaceColorScheme = switch (scheme) { + .light => gtk.InterfaceColorScheme.light, + .dark => gtk.InterfaceColorScheme.dark, + }; + var value = gobject.ext.Value.newFrom(gtk_scheme); + gobject.Object.setProperty( + priv.css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + for (priv.custom_css_providers.items) |css_provider| { + gobject.Object.setProperty( + css_provider.as(gobject.Object), + "prefers-color-scheme", + &value, + ); + } + } } fn handleReloadConfig( From 7ba88a71786392dccb03da8c37937676f46a7cd1 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 14 Oct 2025 09:35:54 -0500 Subject: [PATCH 366/702] synthetic: make bytes generation more flexible --- src/synthetic/Bytes.zig | 92 ++++++++++++++++++++++++++++++------- src/synthetic/Osc.zig | 72 +++++++++++++++++------------ src/synthetic/cli/Ascii.zig | 19 +++++--- 3 files changed, 130 insertions(+), 53 deletions(-) diff --git a/src/synthetic/Bytes.zig b/src/synthetic/Bytes.zig index 40a94e0e3..7d4c34a33 100644 --- a/src/synthetic/Bytes.zig +++ b/src/synthetic/Bytes.zig @@ -1,4 +1,4 @@ -/// Generates bytes. +//! Generates bytes. const Bytes = @This(); const std = @import("std"); @@ -7,9 +7,7 @@ const Generator = @import("Generator.zig"); /// Random number generator. rand: std.Random, -/// The minimum and maximum length of the generated bytes. The maximum -/// length will be capped to the length of the buffer passed in if the -/// buffer length is smaller. +/// The minimum and maximum length of the generated bytes. min_len: usize = 1, max_len: usize = std.math.maxInt(usize), @@ -18,23 +16,79 @@ max_len: usize = std.math.maxInt(usize), /// side effect of the generator, not an intended use case. alphabet: ?[]const u8 = null, -/// Predefined alphabets. -pub const Alphabet = struct { - pub const ascii = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;':\\\",./<>?`~"; -}; +/// Generate an alphabet given a function that returns true/false for a +/// given byte. +pub fn generateAlphabet(comptime func: fn (u8) bool) []const u8 { + @setEvalBranchQuota(3000); + var count = 0; + for (0..256) |c| { + if (func(c)) count += 1; + } + var alphabet: [count]u8 = undefined; + var i = 0; + for (0..256) |c| { + if (func(c)) { + alphabet[i] = c; + i += 1; + } + } + const result = alphabet; + return &result; +} pub fn generator(self: *Bytes) Generator { return .init(self, next); } -pub fn next(self: *Bytes, writer: *std.Io.Writer, max_len: usize) Generator.Error!void { - std.debug.assert(max_len >= 1); - const len = @min( - self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len), - max_len, - ); +/// Return a copy of the Bytes, but with a new alphabet. +pub fn newAlphabet(self: *const Bytes, new_alphabet: ?[]const u8) Bytes { + return .{ + .rand = self.rand, + .alphabet = new_alphabet, + .min_len = self.min_len, + .max_len = self.max_len, + }; +} + +/// Return a copy of the Bytes, but with a new min_len. The new min +/// len cannot be more than the previous max_len. +pub fn atLeast(self: *const Bytes, new_min_len: usize) Bytes { + return .{ + .rand = self.rand, + .alphabet = self.alphabet, + .min_len = @min(self.max_len, new_min_len), + .max_len = self.max_len, + }; +} + +/// Return a copy of the Bytes, but with a new max_len. The new max_len cannot +/// be more the previous max_len. +pub fn atMost(self: *const Bytes, new_max_len: usize) Bytes { + return .{ + .rand = self.rand, + .alphabet = self.alphabet, + .min_len = @min(self.min_len, @min(self.max_len, new_max_len)), + .max_len = @min(self.max_len, new_max_len), + }; +} + +pub fn next(self: *const Bytes, writer: *std.Io.Writer, max_len: usize) std.Io.Writer.Error!void { + _ = try self.atMost(max_len).write(writer); +} + +pub fn format(self: *const Bytes, writer: *std.Io.Writer) std.Io.Writer.Error!void { + _ = try self.write(writer); +} + +/// Write some random data and return the number of bytes written. +pub fn write(self: *const Bytes, writer: *std.Io.Writer) std.Io.Writer.Error!usize { + std.debug.assert(self.min_len >= 1); + std.debug.assert(self.max_len >= self.min_len); + + const len = self.rand.intRangeAtMostBiased(usize, self.min_len, self.max_len); var buf: [8]u8 = undefined; + var remaining = len; while (remaining > 0) { const data = buf[0..@min(remaining, buf.len)]; @@ -45,6 +99,8 @@ pub fn next(self: *Bytes, writer: *std.Io.Writer, max_len: usize) Generator.Erro try writer.writeAll(data); remaining -= data.len; } + + return len; } test "bytes" { @@ -52,9 +108,11 @@ test "bytes" { var prng = std.Random.DefaultPrng.init(0); var buf: [256]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var v: Bytes = .{ .rand = prng.random() }; - v.min_len = buf.len; - v.max_len = buf.len; + var v: Bytes = .{ + .rand = prng.random(), + .min_len = buf.len, + .max_len = buf.len, + }; const gen = v.generator(); try gen.next(&writer, buf.len); try testing.expectEqual(buf.len, writer.buffered().len); diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig index 52940fee9..b43079e1a 100644 --- a/src/synthetic/Osc.zig +++ b/src/synthetic/Osc.zig @@ -35,19 +35,26 @@ p_valid: f64 = 1.0, p_valid_kind: std.enums.EnumArray(ValidKind, f64) = .initFill(1.0), p_invalid_kind: std.enums.EnumArray(InvalidKind, f64) = .initFill(1.0), -/// The alphabet for random bytes (omitting 0x1B and 0x07). -const bytes_alphabet: []const u8 = alphabet: { - var alphabet: [256]u8 = undefined; - for (0..alphabet.len) |i| { - if (i == 0x1B or i == 0x07) { - alphabet[i] = @intCast(i + 1); - } else { - alphabet[i] = @intCast(i); - } - } - const result = alphabet; - break :alphabet &result; -}; +fn checkKvAlphabet(c: u8) bool { + return switch (c) { + std.ascii.control_code.esc, std.ascii.control_code.bel, ';', '=' => false, + else => std.ascii.isPrint(c), + }; +} + +/// The alphabet for random bytes in OSC key/value pairs (omitting 0x1B, +/// 0x07, ';', '='). +pub const kv_alphabet = Bytes.generateAlphabet(checkKvAlphabet); + +fn checkOscAlphabet(c: u8) bool { + return switch (c) { + std.ascii.control_code.esc, std.ascii.control_code.bel => false, + else => true, + }; +} + +/// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07). +pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet); pub fn generator(self: *Osc) Generator { return .init(self, next); @@ -99,35 +106,43 @@ fn nextUnwrapped(self: *Osc, writer: *std.Io.Writer, max_len: usize) Generator.E fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKind, max_len: usize) Generator.Error!void { switch (k) { - .change_window_title => { - try writer.writeAll("0;"); // Set window title - var bytes_gen = self.bytes(); - try bytes_gen.next(writer, max_len - 2); + .change_window_title => change_window_title: { + if (max_len < 3) break :change_window_title; + try writer.print("0;{f}", .{self.bytes().atMost(max_len - 3)}); // Set window title }, - .prompt_start => { + .prompt_start => prompt_start: { + if (max_len < 4) break :prompt_start; + var remaining = max_len; + try writer.writeAll("133;A"); // Start prompt + remaining -= 4; // aid - if (self.rand.boolean()) { - var bytes_gen = self.bytes(); - bytes_gen.max_len = 16; + if (self.rand.boolean()) aid: { + if (remaining < 6) break :aid; try writer.writeAll(";aid="); - try bytes_gen.next(writer, max_len); + remaining -= 5; + remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(16, remaining)).write(writer); } // redraw - if (self.rand.boolean()) { + if (self.rand.boolean()) redraw: { + if (remaining < 9) break :redraw; try writer.writeAll(";redraw="); if (self.rand.boolean()) { try writer.writeAll("1"); } else { try writer.writeAll("0"); } + remaining -= 9; } }, - .prompt_end => try writer.writeAll("133;B"), // End prompt + .prompt_end => prompt_end: { + if (max_len < 4) break :prompt_end; + try writer.writeAll("133;B"); // End prompt + }, } } @@ -139,14 +154,11 @@ fn nextUnwrappedInvalidExact( ) Generator.Error!void { switch (k) { .random => { - var bytes_gen = self.bytes(); - try bytes_gen.next(writer, max_len); + try self.bytes().atMost(max_len).format(writer); }, .good_prefix => { - try writer.writeAll("133;"); - var bytes_gen = self.bytes(); - try bytes_gen.next(writer, max_len - 4); + try writer.print("133;{f}", .{self.bytes().atMost(max_len - 4)}); }, } } @@ -154,7 +166,7 @@ fn nextUnwrappedInvalidExact( fn bytes(self: *const Osc) Bytes { return .{ .rand = self.rand, - .alphabet = bytes_alphabet, + .alphabet = osc_alphabet, }; } diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index b2d57fa88..22ca1ffb5 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -3,12 +3,21 @@ const Ascii = @This(); const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const synthetic = @import("../main.zig"); +const Bytes = @import("../Bytes.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); pub const Options = struct {}; +fn checkAsciiAlphabet(c: u8) bool { + return switch (c) { + ' ' => false, + else => std.ascii.isPrint(c), + }; +} + +pub const ascii = Bytes.generateAlphabet(checkAsciiAlphabet); + /// Create a new terminal stream handler for the given arguments. pub fn create( alloc: Allocator, @@ -23,12 +32,10 @@ pub fn destroy(self: *Ascii, alloc: Allocator) void { alloc.destroy(self); } -pub fn run(self: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { - _ = self; - - var gen: synthetic.Bytes = .{ +pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { + var gen: Bytes = .{ .rand = rand, - .alphabet = synthetic.Bytes.Alphabet.ascii, + .alphabet = ascii, }; while (true) { From 10fcd9111cdeb7a8fc4f7caa3fed0b01e8cb4a9a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 21 Aug 2025 17:38:46 -0500 Subject: [PATCH 367/702] nix: make 'nix flake check' happy --- flake.nix | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/flake.nix b/flake.nix index 3dcfef185..e744c1a09 100644 --- a/flake.nix +++ b/flake.nix @@ -48,7 +48,7 @@ system: let pkgs = nixpkgs.legacyPackages.${system}; in { - devShell.${system} = pkgs.callPackage ./nix/devShell.nix { + devShells.${system}.default = pkgs.callPackage ./nix/devShell.nix { zig = zig.packages.${system}."0.15.2"; wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {}; zon2nix = zon2nix; @@ -96,6 +96,9 @@ in { type = "app"; program = "${program}"; + meta = { + description = "start a vm from ${toString module}"; + }; } ); in { @@ -121,11 +124,6 @@ ghostty = self.packages.${prev.stdenv.hostPlatform.system}.ghostty-debug; }; }; - create-vm = import ./nix/vm/create.nix; - create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix; - create-gnome-vm = import ./nix/vm/create-gnome.nix; - create-plasma6-vm = import ./nix/vm/create-plasma6.nix; - create-xfce-vm = import ./nix/vm/create-xfce.nix; }; nixConfig = { From ca8313570c4180885a5ab55cdd04bc238292d083 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 21 Aug 2025 17:39:02 -0500 Subject: [PATCH 368/702] nix: add vm-based integration tests --- .gitignore | 1 + flake.lock | 22 ++++++ flake.nix | 12 +++ nix/tests.nix | 167 ++++++++++++++++++++++++++++++++++++++++ nix/vm/common-gnome.nix | 13 ++++ nix/vm/common.nix | 7 +- 6 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 nix/tests.nix diff --git a/.gitignore b/.gitignore index e451b171a..e521f8851 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ zig-cache/ .zig-cache/ zig-out/ /result* +/.nixos-test-history example/*.wasm test/ghostty test/cases/**/*.actual.png diff --git a/flake.lock b/flake.lock index 90b97ed4a..ece49febb 100644 --- a/flake.lock +++ b/flake.lock @@ -34,6 +34,27 @@ "type": "github" } }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1755776884, + "narHash": "sha256-CPM7zm6csUx7vSfKvzMDIjepEJv1u/usmaT7zydzbuI=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "4fb695d10890e9fc6a19deadf85ff79ffb78da86", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-25.05", + "repo": "home-manager", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 315532800, @@ -64,6 +85,7 @@ "inputs": { "flake-compat": "flake-compat", "flake-utils": "flake-utils", + "home-manager": "home-manager", "nixpkgs": "nixpkgs", "zig": "zig", "zon2nix": "zon2nix" diff --git a/flake.nix b/flake.nix index e744c1a09..aac42fbc0 100644 --- a/flake.nix +++ b/flake.nix @@ -34,6 +34,13 @@ # nixpkgs.follows = "nixpkgs"; }; }; + + home-manager = { + url = "github:nix-community/home-manager?ref=release-25.05"; + inputs = { + nixpkgs.follows = "nixpkgs"; + }; + }; }; outputs = { @@ -41,6 +48,7 @@ nixpkgs, zig, zon2nix, + home-manager, ... }: builtins.foldl' nixpkgs.lib.recursiveUpdate {} ( @@ -80,6 +88,10 @@ formatter.${system} = pkgs.alejandra; + checks.${system} = import ./nix/tests.nix { + inherit home-manager nixpkgs self system; + }; + apps.${system} = let runVM = ( module: let diff --git a/nix/tests.nix b/nix/tests.nix new file mode 100644 index 000000000..51fafad3e --- /dev/null +++ b/nix/tests.nix @@ -0,0 +1,167 @@ +{ + self, + system, + nixpkgs, + home-manager, + ... +}: let + nixos-version = nixpkgs.lib.trivial.release; + + pkgs = import nixpkgs { + inherit system; + overlays = [ + self.overlays.debug + ]; + }; + + pink_value = "#FF0087"; + + color_test = '' + import tempfile + import subprocess + + def check_for_pink(final=False) -> bool: + with tempfile.NamedTemporaryFile() as tmpin: + machine.send_monitor_command("screendump {}".format(tmpin.name)) + + cmd = 'convert {} -define histogram:unique-colors=true -format "%c" histogram:info:'.format( + tmpin.name + ) + ret = subprocess.run(cmd, shell=True, capture_output=True) + if ret.returncode != 0: + raise Exception( + "image analysis failed with exit code {}".format(ret.returncode) + ) + + text = ret.stdout.decode("utf-8") + return "${pink_value}" in text + ''; + + mkTestGnome = { + name, + settings, + testScript, + ocr ? false, + }: + pkgs.testers.runNixOSTest { + name = name; + + enableOCR = ocr; + + extraBaseModules = { + imports = [ + home-manager.nixosModules.home-manager + ]; + }; + + nodes = { + machine = { + config, + pkgs, + ... + }: { + imports = [ + ./vm/wayland-gnome.nix + settings + ]; + + virtualisation.vmVariant = { + virtualisation.host.pkgs = pkgs; + }; + + users.groups.ghostty = { + gid = 1000; + }; + + users.users.ghostty = { + uid = 1000; + }; + + home-manager = { + users = { + ghostty = { + home = { + username = config.users.users.ghostty.name; + homeDirectory = config.users.users.ghostty.home; + stateVersion = nixos-version; + }; + }; + }; + }; + + system.stateVersion = nixos-version; + }; + }; + + testScript = testScript; + }; +in { + basic-version-check = pkgs.testers.runNixOSTest { + name = "basic-version-check"; + nodes = { + machine = {pkgs, ...}: { + users.groups.ghostty = {}; + users.users.ghostty = { + isNormalUser = true; + group = "ghostty"; + packages = [ + pkgs.ghostty + ]; + }; + }; + }; + testScript = {...}: '' + machine.succeed("su - ghostty -c 'ghostty +version'") + ''; + }; + + basic-window-check-gnome = mkTestGnome { + name = "basic-window-check-gnome"; + settings = { + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = '' + background = ${pink_value} + ''; + }; + }; + }; + ocr = true; + testScript = {nodes, ...}: let + user = nodes.machine.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + in '' + ${color_test} + + with subtest("wait for x"): + start_all() + machine.wait_for_x() + + machine.wait_for_file("${bus_path}") + + with subtest("Ensuring no pink is present without the terminal."): + assert ( + check_for_pink() == False + ), "Pink was present on the screen before we even launched a terminal!" + + machine.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + machine.succeed("${su "${ghostty} +new-window"}") + machine.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + + machine.sleep(2) + + with subtest("Have the terminal display a color."): + assert( + check_for_pink() == True + ), "Pink was not found on the screen!" + + machine.systemctl("stop app-com.mitchellh.ghostty-debug.service", user="${user.name}") + ''; + }; +} diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix index 0c2bef150..ab4aab9e9 100644 --- a/nix/vm/common-gnome.nix +++ b/nix/vm/common-gnome.nix @@ -22,6 +22,19 @@ }; }; + systemd.user.services = { + "org.gnome.Shell@wayland" = { + serviceConfig = { + ExecStart = [ + # Clear the list before overriding it. + "" + # Eval API is now internal so Shell needs to run in unsafe mode. + "${pkgs.gnome-shell}/bin/gnome-shell --unsafe-mode" + ]; + }; + }; + }; + environment.systemPackages = [ pkgs.gnomeExtensions.no-overview ]; diff --git a/nix/vm/common.nix b/nix/vm/common.nix index eefd7c1c0..63b7570b8 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -35,12 +35,6 @@ initialPassword = "ghostty"; }; - environment.etc = { - "xdg/autostart/com.mitchellh.ghostty.desktop" = { - source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop"; - }; - }; - environment.systemPackages = [ pkgs.kitty pkgs.fish @@ -61,6 +55,7 @@ services.displayManager = { autoLogin = { + enable = true; user = "ghostty"; }; }; From f9d6a6d56fa8a3bf71d857ba1bcd0939453c648e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:02:00 -0500 Subject: [PATCH 369/702] nix vm tests: update contributors documentation --- CONTRIBUTING.md | 263 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7df4b71..34e6b273b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,3 +142,266 @@ pull request will be accepted with a high degree of certainty. > **Pull requests are NOT a place to discuss feature design.** Please do > not open a WIP pull request to discuss a feature. Instead, use a discussion > and link to your branch. + +# Developer Guide + +> [!NOTE] +> +> **The remainder of this file is dedicated to developers actively +> working on Ghostty.** If you're a user reporting an issue, you can +> ignore the rest of this document. + +## Including and Updating Translations + +See the [Contributor's Guide](po/README_CONTRIBUTORS.md) for more details. + +## Checking for Memory Leaks + +While Zig does an amazing job of finding and preventing memory leaks, +Ghostty uses many third-party libraries that are written in C. Improper usage +of those libraries or bugs in those libraries can cause memory leaks that +Zig cannot detect by itself. + +### On Linux + +On Linux the recommended tool to check for memory leaks is Valgrind. The +recommended way to run Valgrind is via `zig build`: + +```sh +zig build run-valgrind +``` + +This builds a Ghostty executable with Valgrind support and runs Valgrind +with the proper flags to ensure we're suppressing known false positives. + +You can combine the same build args with `run-valgrind` that you can with +`run`, such as specifying additional configurations after a trailing `--`. + +## Input Stack Testing + +The input stack is the part of the codebase that starts with a +key event and ends with text encoding being sent to the pty (it +does not include _rendering_ the text, which is part of the +font or rendering stack). + +If you modify any part of the input stack, you must manually verify +all the following input cases work properly. We unfortunately do +not automate this in any way, but if we can do that one day that'd +save a LOT of grief and time. + +Note: this list may not be exhaustive, I'm still working on it. + +### Linux IME + +IME (Input Method Editors) are a common source of bugs in the input stack, +especially on Linux since there are multiple different IME systems +interacting with different windowing systems and application frameworks +all written by different organizations. + +The following matrix should be tested to ensure that all IME input works +properly: + +1. Wayland, X11 +2. ibus, fcitx, none +3. Dead key input (e.g. Spanish), CJK (e.g. Japanese), Emoji, Unicode Hex +4. ibus versions: 1.5.29, 1.5.30, 1.5.31 (each exhibit slightly different behaviors) + +> [!NOTE] +> +> This is a **work in progress**. I'm still working on this list and it +> is not complete. As I find more test cases, I will add them here. + +#### Dead Key Input + +Set your keyboard layout to "Spanish" (or another layout that uses dead keys). + +1. Launch Ghostty +2. Press `'` +3. Press `a` +4. Verify that `á` is displayed + +Note that the dead key may or may not show a preedit state visually. +For ibus and fcitx it does but for the "none" case it does not. Importantly, +the text should be correct when it is sent to the pty. + +We should also test canceling dead key input: + +1. Launch Ghostty +2. Press `'` +3. Press escape +4. Press `a` +5. Verify that `a` is displayed (no diacritic) + +#### CJK Input + +Configure fcitx or ibus with a keyboard layout like Japanese or Mozc. The +exact layout doesn't matter. + +1. Launch Ghostty +2. Press `Ctrl+Shift` to switch to "Hiragana" +3. On a US physical layout, type: `konn`, you should see `こん` in preedit. +4. Press `Enter` +5. Verify that `こん` is displayed in the terminal. + +We should also test switching input methods while preedit is active, which +should commit the text: + +1. Launch Ghostty +2. Press `Ctrl+Shift` to switch to "Hiragana" +3. On a US physical layout, type: `konn`, you should see `こん` in preedit. +4. Press `Ctrl+Shift` to switch to another layout (any) +5. Verify that `こん` is displayed in the terminal as committed text. + +## Nix Virtual Machines + +Several Nix virtual machine definitions are provided by the project for testing +and developing Ghostty against multiple different Linux desktop environments. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +VMs should only be run on your local desktop and then powered off when not in +use, which will discard any changes to the VM. + +The VM definitions provide minimal software "out of the box" but additional +software can be installed by using standard Nix mechanisms like `nix run nixpkgs#`. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#`. `` can be any of the VMs defined in the + `nix/vm` directory (without the `.nix` suffix) excluding any file prefixed + with `common` or `create`. +3. The VM will build and then launch. Depending on the speed of your system, this + can take a while, but eventually you should get a new VM window. +4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending + on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be + writable by the VM user, so be careful! + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a VM. + +### Custom VMs + +To easily create a custom VM without modifying the Ghostty source, create a new +directory, then create a file called `flake.nix` with the following text in the +new directory. + +``` +{ + inputs = { + nixpkgs.url = "nixpkgs/nixpkgs-unstable"; + ghostty.url = "github:ghostty-org/ghostty"; + }; + outputs = { + nixpkgs, + ghostty, + ... + }: { + nixosConfigurations.custom-vm = ghostty.create-gnome-vm { + nixpkgs = nixpkgs; + system = "x86_64-linux"; + overlay = ghostty.overlays.releasefast; + # module = ./configuration.nix # also works + module = {pkgs, ...}: { + environment.systemPackages = [ + pkgs.btop + ]; + }; + }; + }; +} +``` + +The custom VM can then be run with a command like this: + +``` +nix run .#nixosConfigurations.custom-vm.config.system.build.vm +``` + +A file named `ghostty.qcow2` will be created that is used to persist any changes +made in the VM. To "reset" the VM to default delete the file and it will be +recreated the next time you run the VM. + +### Contributing new VM definitions + +#### VM Acceptance Criteria + +We welcome the contribution of new VM definitions, as long as they meet the following criteria: + +1. They should be different enough from existing VM definitions that they represent a distinct + user (and developer) experience. +2. There's a significant Ghostty user population that uses a similar environment. +3. The VMs can be built using only packages from the current stable NixOS release. + +#### VM Definition Criteria + +1. VMs should be as minimal as possible so that they build and launch quickly. + Additional software can be added at runtime with a command like `nix run nixpkgs#`. +2. VMs should not expose any services to the network, or run any remote access + software like SSH daemons, VNC or RDP. +3. VMs should auto-login using the "ghostty" user. + +## Nix VM Integration Tests + +Several Nix VM tests are provided by the project for testing Ghostty in a "live" +environment rather than just unit tests. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further +requirements for macOS are detailed below. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#check...driver`. `` should be + `x86_64-linux` or `aarch64-linux` (even on macOS, this launches a Linux + VM, not a macOS one). `` should be one of the tests defined in + `nix/tests.nix`. The test will build and then launch. Depending on the speed + of your system, this can take a while. Eventually though the test should + complete. Hopefully successfully, but if not error messages should be printed + out that can be used to diagnose the issue. +3. To run _all_ of the tests, run `nix flake check`. + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a test. + +### Interactively Running Test VMs + +To run a test interactively, run `nix run +.#check...driverInteractive`. This will load a Python console +that can be used to manage the test VMs. In this console run `start_all()` to +start the VM(s). The VMs should boot up and a window should appear showing the +VM's console. + +For more information about the Nix test console, see [the NixOS manual](https://nixos.org/manual/nixos/stable/index.html#sec-call-nixos-test-outside-nixos) + +### SSH Access to Test VMs + +Some test VMs are configured to allow outside SSH access for debugging. To +access the VM, use a command like the following: + +``` +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 root@192.168.122.1 +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 ghostty@192.168.122.1 +``` + +The SSH options are important because the SSH host keys will be regenerated +every time the test is started. Without them, your personal SSH known hosts file +will become difficult to manage. The port that is needed to access the VM may +change depending on the test. + +None of the users in the VM have passwords so do not expose these VMs to the Internet. From c77bbe6d7ec5736b4defb183c9daab18cd5f400e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:02:54 -0500 Subject: [PATCH 370/702] nix vms: make base vm more suitable for tests --- nix/vm/common.nix | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/nix/vm/common.nix b/nix/vm/common.nix index 63b7570b8..b2fec28e8 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -4,9 +4,6 @@ documentation.nixos.enable = false; - networking.hostName = "ghostty"; - networking.domain = "mitchellh.com"; - virtualisation.vmVariant = { virtualisation.memorySize = 2048; }; @@ -28,11 +25,11 @@ users.groups.ghostty = {}; users.users.ghostty = { + isNormalUser = true; description = "Ghostty"; group = "ghostty"; extraGroups = ["wheel"]; - isNormalUser = true; - initialPassword = "ghostty"; + hashedPassword = ""; }; environment.systemPackages = [ From debec946daf90174801d9cedbb52c084efb095b5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:04:38 -0500 Subject: [PATCH 371/702] nix vm tests: refactor to make gnome vm node builder reusable --- nix/tests.nix | 94 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/nix/tests.nix b/nix/tests.nix index 51fafad3e..33902d4d0 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -37,6 +37,65 @@ return "${pink_value}" in text ''; + mkNodeGnome = { + config, + pkgs, + settings, + sshPort ? null, + ... + }: { + imports = [ + ./vm/wayland-gnome.nix + settings + ]; + + virtualisation = { + forwardPorts = pkgs.lib.optionals (sshPort != null) [ + { + from = "host"; + host.port = sshPort; + guest.port = 22; + } + ]; + + vmVariant = { + virtualisation.host.pkgs = pkgs; + }; + }; + + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + + security.pam.services.sshd.allowNullPassword = true; + + users.groups.ghostty = { + gid = 1000; + }; + + users.users.ghostty = { + uid = 1000; + }; + + home-manager = { + users = { + ghostty = { + home = { + username = config.users.users.ghostty.name; + homeDirectory = config.users.users.ghostty.home; + stateVersion = nixos-version; + }; + }; + }; + }; + + system.stateVersion = nixos-version; + }; + mkTestGnome = { name, settings, @@ -59,38 +118,11 @@ config, pkgs, ... - }: { - imports = [ - ./vm/wayland-gnome.nix - settings - ]; - - virtualisation.vmVariant = { - virtualisation.host.pkgs = pkgs; + }: + mkNodeGnome { + inherit config pkgs settings; + sshPort = 2222; }; - - users.groups.ghostty = { - gid = 1000; - }; - - users.users.ghostty = { - uid = 1000; - }; - - home-manager = { - users = { - ghostty = { - home = { - username = config.users.users.ghostty.name; - homeDirectory = config.users.users.ghostty.home; - stateVersion = nixos-version; - }; - }; - }; - }; - - system.stateVersion = nixos-version; - }; }; testScript = testScript; From f26a6b949c58f0f7e3587a8f17997e868719abd9 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:05:18 -0500 Subject: [PATCH 372/702] nix vm tests: sync ghostty user with other tests --- nix/tests.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nix/tests.nix b/nix/tests.nix index 33902d4d0..1ef420cf3 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -136,6 +136,8 @@ in { users.users.ghostty = { isNormalUser = true; group = "ghostty"; + extraGroups = ["wheel"]; + hashedPassword = ""; packages = [ pkgs.ghostty ]; From 516c416fa4d9c31a82dd1a149f91524f64f2392c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:55:19 -0500 Subject: [PATCH 373/702] nix vm tests: fix ssh command --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34e6b273b..6d8976b21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -395,8 +395,8 @@ Some test VMs are configured to allow outside SSH access for debugging. To access the VM, use a command like the following: ``` -ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 root@192.168.122.1 -ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=none -p 2222 ghostty@192.168.122.1 +ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 root@192.168.122.1 +ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -p 2222 ghostty@192.168.122.1 ``` The SSH options are important because the SSH host keys will be regenerated From 8386159764fb398c8a60aa71367edb11b62db5c8 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 22 Aug 2025 17:55:54 -0500 Subject: [PATCH 374/702] nix vm tests: add test for ssh-terminfo shell integration feature --- nix/tests.nix | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/nix/tests.nix b/nix/tests.nix index 1ef420cf3..a9970e80c 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -89,6 +89,13 @@ homeDirectory = config.users.users.ghostty.home; stateVersion = nixos-version; }; + programs.ssh = { + enable = true; + extraOptionOverrides = { + StrictHostKeyChecking = "accept-new"; + UserKnownHostsFile = "/dev/null"; + }; + }; }; }; }; @@ -198,4 +205,79 @@ in { machine.systemctl("stop app-com.mitchellh.ghostty-debug.service", user="${user.name}") ''; }; + + ssh-integration-test = pkgs.testers.runNixOSTest { + name = "ssh-integration-test"; + extraBaseModules = { + imports = [ + home-manager.nixosModules.home-manager + ]; + }; + nodes = { + server = {...}: { + users.groups.ghostty = {}; + users.users.ghostty = { + isNormalUser = true; + group = "ghostty"; + extraGroups = ["wheel"]; + hashedPassword = ""; + packages = []; + }; + services.openssh = { + enable = true; + settings = { + PermitRootLogin = "yes"; + PermitEmptyPasswords = "yes"; + }; + }; + security.pam.services.sshd.allowNullPassword = true; + }; + client = { + config, + pkgs, + ... + }: + mkNodeGnome { + inherit config pkgs; + settings = { + home-manager.users.ghostty = { + xdg.configFile = { + "ghostty/config".text = let + in '' + shell-integration-features = ssh-terminfo + ''; + }; + }; + }; + sshPort = 2222; + }; + }; + testScript = {nodes, ...}: let + user = nodes.client.users.users.ghostty; + bus_path = "/run/user/${toString user.uid}/bus"; + bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}"; + gdbus = "${bus} gdbus"; + ghostty = "${bus} ghostty"; + su = command: "su - ${user.name} -c '${command}'"; + gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval"; + wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class"; + in '' + with subtest("Start server and wait for ssh to be ready."): + server.start() + server.wait_for_open_port(22) + + with subtest("Start client and wait for ghostty window."): + client.start() + client.wait_for_x() + client.wait_for_file("${bus_path}") + client.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}") + client.succeed("${su "${ghostty} +new-window"}") + client.wait_until_succeeds("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'") + + with subtest("SSH from client to server and verify that the Ghostty terminfo is copied.") + client.sleep(2) + client.send_chars("ssh ghostty@server\n") + server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30) + ''; + }; } From c93727697647066021bfe329ac0808d72049b826 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Thu, 20 Nov 2025 08:57:46 -0800 Subject: [PATCH 375/702] 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 376/702] 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 377/702] 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 378/702] 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 379/702] 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 380/702] 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 381/702] 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 382/702] 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 383/702] 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 384/702] 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 385/702] 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 386/702] 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 387/702] 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 388/702] 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 389/702] 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 390/702] 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 391/702] 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 392/702] 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 393/702] 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 394/702] 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 395/702] 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 396/702] 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 397/702] 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 398/702] 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 399/702] 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 400/702] 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 401/702] 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 402/702] 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 403/702] 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 404/702] 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 405/702] 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 406/702] 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 407/702] 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 408/702] 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 409/702] 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 410/702] 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 411/702] 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 412/702] 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 413/702] 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 414/702] 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 415/702] 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 416/702] 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 417/702] 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 418/702] 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 419/702] 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 420/702] 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 421/702] 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 422/702] 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 423/702] 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 424/702] 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 425/702] 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 426/702] 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 427/702] 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 zcmdqK3A~QQ`v*KT_so4i=iwa38p7ERap*WW)`V=?vuDemWI2{3Ns=TJO}mb|Nh_q=lA)%=RV(=xv!bIW}mrcx#tm52vH1* z7RgOJUEKn2Qs^5yKo7NO+N^o0!{z3Jlm(n=(V=~(T7TXGH%_t;Ny}Pv>e6J&^(W5= zk-Afe{yW-tx;$rcrTb?Jao244?>k_4zq|v-x@X}12O+K+KVaP08n7^hDUNg&w+o*e!#fy6o|@LcBFZsMC7Lpnd~ORUh%F za9$b)``jTQsPSeX-k0J%eaP^!O=$T4FZue+2$RJ$v zh*<#jgaLmy?1rHhcER6m;5LAIor|1z2fB#Acc(X1INo$`I?OY?*-+77Dj%^EREu-~qRc9wr7~ zKYGxOV(_qjV@HV02{()wA_h;m<(6B-V9`{B8#iyAESfg%(G2QUjXRUtk<`|Wdq8d5 zz75n4ZOBc>_HChdW;dNX5(VuLfBE?wH^gvwztJ~}gyA<1zY(z%K9zJO9Jmb^-cg}c zg}&@d3gml*fk=_8Fi9?li>8QuCHSKHS&8dsWv-vSxn@p5&3sPG6VIc5uH^c;p6lmM zuAd)r{X9hVQ+AibTFs zwNx&oS}K=QErkozQn{LHsa!*~6h2T(<-1f%#oS<>g70q$ zYVs`VZB2Pj<*F9Sq1vlTqTcparBz?mUu96eRh6jTs>`U}s_Im4RWsDvIjXsus~&=D z)Lzv|J*l>;K5CoOQB8C@eXak-dp&eeI$TJZBc154R^I4GYZHhjj zJ;77Z&Bfp1x&}{Zt9`<&Lc7Yf@;2dZwEv8cm_~;tg(ii!hdM`}@OE#gmlv8Ec`5`Z zZ(OKd@PxP1<4v(osB&QPro>I7hiuDN2n~xq8e+B9Nfd-CNivri~J`gjwA zC*uA*pFTa73Ud(mNQz_A9!X7vXNB@H{-bG%P2b;8XL-RhJz|Dtr6q&s%t9?gEu*Dp^RIjbu6uo>bjnju z*M}-o%_KUz$d0-Q<-*Ov(2P)nP!r-9YDabfKQ{LW^+jw3hlW{Ss8Qq5dZxn9?D9=R z^FVD1ePL)xXgQlJgjNT&$+iaT@1Im#Dxu9(%Au_`FG4%02Z`njl|=L{u7ovPTC7cI zAK8Qs(i3&hv}2(Yp&vrOgwDpPxKLa|)HZaU&H24x)a5^$;>yM)$7NpVCN3*3C$3K1 zh`6!VF0Nr*)3{b~9fCd!>FyQRKW<3Wh3~EawR~kNz3}$9)6lb#w?l1t25Q`d^Yb`7 zG;T7v3d*B=<+#)3V@jXg+cL7XgWlt&S$=Ud;}*p&4X4Jfh+7-CF>XuTw(^yuPu!fi z`RCim?Tp(Uw>NHo+@ZMRaVO(WBc3`O4kw07h0BFg!$?zr~$9-!5D;Trb=x z+&tVS+%eoe+$TIRJoLP0Vt7h;dU#fNZg>I5JTE*h9778)4lg5m?DKc_g+w9u0=tIP zhXCuto5HUkU*0Y^4{uPz?}tAQ9|#`_e;qy*J`?_fe3ysRBd<68pbjKeRWNTr?ak^j zQstM1q~V^rJk5flWVIEm zR~eG6hxul>cU(5tW_2#BV_AKM!#d9DaE{gWZ0<>BIgaC8pD6O2dk1*7))k;mW;d-_ zU8^Dcsmg3_$ZC5|MKM-eGNmPRzJbldS?y+3rnfc|z@Z0IdN8Fes|{IwiR1YqtGBUw zAFEwh?aJ!Yto9*Qd}qc$eanm$GXFs{1m+-ui=nFP4 zWi`jG2iuaYj&$oIpAVC&s=8M}twgG9!<0epMy9_AHOs7l`YO|_v#&SVJdu6f%)TCF z%6JZ?5A$!#>O!U`u&TgNMXV;0DxYR^FXqsVIo!&Wxy+##bNGzQv@P?w)qMal>28LyN;SV4 z+g``Mu4SIjvH5=HS%`V|X3EXXvorJT$2rgxD$RI!?t+<1Y0Q7msuFnDn2#;+XJj4l z{&(}32gMZ3VIC7RF`HQ_=8APUgkN?E8>(mE#495q!#bX zuq-D&kqt2mKf$w{Vwi;wlf^O986iv1EL@i4`OOtPziEcq%X-;TZor(S9nW$)@+_w- z&vLr)EN1}Ea)$6M=LVkT4CPtQXrARv=UL7?p5@HvSA6HC0XJ+nBesknhl32Qwthb>#ar*TD=4a~*jIbDbOHVRfUrQ65zz)JS;@^PVyC zxEiO%$#2vIHC3LVIgtFB=0Nfno&)g=9`mOs)fSbYUQw@M&h&=UQ)SWYN4<{OPk*)D z8R#5RJ9!S&jORctcn;K(=RjBU9H1+m@>zyv< z0W-(xW)_$Q&b2&S>cO+6o;+LX#j~Z}JX^wwkPt;Cqm+w5pTC5*+&(7!;D z5%A?B;UxTA1Vu(9%#7a=KPk`?Cd1vo0P#)0TEY1sJ{HligE;(?p3oJ%fcUBLGcLgF z_<8^2_kUv-zYt-@AOWjf7sa83nF;6Ne-rNi)Gw7?;D_QDnE$Pwkco6%1SqRk@k`>D z$FGjx@Nap=Z>I1QT1A08+-gBQ66z#0ycpPSKJ0<`9abkZ;opXxu;03kKL#E#xM-On zpW=5RedpodhV{R77rzJL|4Se*8(s|Y`w|*n3>V8^qQxJ)IJoTnd=Bale7=-2$R4QL4oa2Jab7xQPsN@)7eVFo-oeDpu8$HuwrKl{J1ys6)b z@<}-K_x@wcDmI=}=0TdNT?BCo+_PO)ly5=~+@p=0?=FZ3)BhnKg0QUng7J^#5txH` z1n;EB@(9de!P?mwt?y0n~w#fjDgR`BOUoC)ft}{}WLB{|QJNrSD&1FWmhP5Ss`8 z7v_uQ_5Yi2sIUIlI1ab}18|wg%E+f}y@MJiJ8=80Ac~ZNT_ONI;T&{LZlkm!<%(3o zTPgtmY_0}260^&7P#-3uKXrh-*f5!m)-b8j{mV)Fo2Z)Ei=Qk^zz zMx5b(2C4ELR#Eps=}95V=hbp#_d|I=48LJqVz@a^>ACRj1sdJ!Q z%araED|tPe?;=&&_-<-P}^n2O-9hmb!-B9A`Jf$SjMqy4-SOHyEqn@>{07#O7ONMa+bEsAu6mUtxvC zxt~c>=S?z z44fYz_o_Ron2n?45u<<;e1KCqsF`9K?LD8jL+;@H5$`F4D@4nSpvgsOvF_Tzp7Oj0 z!Zp0g)ScCrSiOwZE>Iopa3>(fs3p7-)}7bz24FRmVvT)zvm@@1-KRGVX`kLSq%SOw)&FAH)&7fNU;bYVJNsXT4atD}mwNvn_ig{KXKUK8->|WA z!2nX14Cpszkjfh{V0fMyH(*?Lbu|fUj+zR!hMEDjrkV}4mYN4OS1p8ETP=ZFM=gh1 zSFIi}u6nlG0JXZ>3^hk>g<3=HfLc@Sf?7-MftsuKL9MM0Lan2YL9MG!3>cS_t$u)7 zUHt+zN1cUQ!%Bow88tILT1!I++8;)yQ_TpjLNspyoJr296jx z+-W#?biV;k(;+wCFwkj-(}}~J&aC!ewJ)oKSsljeXjaFwI*HY(tj-v5)9}&G?2*u% zc_T*;9N{b+gB){~j=5pPU}wdcG1=9fwNP`MjZkYiTcFl-wnMGuybU$i*#otUdUfVRZtlx3W5s)k&;QW_1dyID?4zSV{G6Hs8Z4OQdoZo9}1UO2rS5Ss18} zFN=`>AxtgD>Lsj}X7y55OR~yRDW5NtEDuUZEySxBtJEV%$TCy|3PG+Rt_@gaSz2Df z=7wZ1uau4WjU+xLuaYd0%jW#nQnq3{lH0XxE8EL!WG9$i*+q84>B63}x4e#A_%)3r zRLL?bc8ukNP9=9%$0da-7aCn?aiJdyohw|pa4S6h3$G}=tMI9K6<;S|b;9cSJ@I>> zo`E?hVQj)I=v%PfbGk_LB0Y*6E3u)(h9bWtP9{BZR?%>Y4MocpodI=W(IZ9A7Hd+h zSF!D6S8Px5i6w`X99DcriG)(sN>wXSuEa9Xx0UPz>V#6spfxCUuxhnZXD{iI*8h@W zmz+sIkba=FD!sJQ%t|v$uP>8bX=a%wWk*+@R(V?4$(N>9o_1-?OLtV6cj=*WZPNOe z>r-xbxs&A^l|9Ou&l!Nq?$<`lU}JgKI#4B1~tbgcTI*F`mU6kwHDM` zkkTY&MV$k64y0^KO-`+br%CGU)J3W5Q-4bUr=-tK-;Ma3 z$jHuUm9aQueP*%Dtjw92%QF9{Sfb+CinA&ns`y8xNr>r!O2;alyR7MDeJYPfOy^b! zS4pojy~?~QOS39xHO-olwX|w-)oN86RGm_FVbvp5&sJ+!ZOG-#FYkW&qRZD_{!4aZ zcGv8@>}}Zxs#mMtvigGR8>;`2QzB<@&g7guIVWr6)M!&I|&28)-dJcOIS% z_1t=;>rJk=p#FmT%NbkhA8XX3(ey@(8*OUzexqL+7jB%|xK869jfXVe+<0G;4o!wO zIc1-#_BTJ#LbNE=qE3skE!wo`3w3&nWi3v&IN4IPY>uZ#%f)y$wEUyxA6F+_{R*Bv zttzz2YE`dQyH=-Khgz3vosOpto;Ixqx1QX3TkAt@RGU(5+O!$mW=@-BZBDjz+jeg| zy6xPyZ?`?xu57!S?S{6S+HP~Zz3mg)XSE;EerEe^?GJTG?a-*hxDK;B?C5alnhAK` z@7Mv)l8#3@{?;k6Q>9LAI^}hm+i7Q~k2@Xfbh>l6bGgpdI=AdRy7S!5`#YcNQn*WU zm#$s%x-99kp=)Z_23KB40kN zA+)|N;xl=ZxC1k`DRLZo+^Oh~??!)omn?$buqgV&;^+}e%2M(Y^nTaN(&!z_%1dQA z*K8wKRKR=5R z(#4)-2tW2&j@T4@)*yEO@zEh&ShvtEb%HLU6Lm3NT$j)#bt!#`ZlycvE&65spq{H= z)m!yzdYj&^Uw3cNoAmqo1HDI|(BB$qlyQtU@0k_mQ?ti>Xx=q1o4w{Ev)_DX-ZJl- zkM&6NjrqpC$sOzt)AP*hZjLw2d&qm(d&%44z3jc>ZS`LBwt27PoZ}nb4)0CxJM@3e zP=-{JMes<>5K}>Gg2%xK+Yg!u|!YMw}~h99r_OOw7ys0E0*hddY*Vjuh1*R3cW+`6f5;X z{e@Vq|J3KiT2s+f6dO#A$q~<+)~2=CXl^tk#0zGW87*EiH)9>(WpkUkO}uLEGIxos zW`>y|UNdXV8nMm1W8M+3qlDfQ+ufROE%AnXwR^RA)4jpHLF{zLxMRgz-d)~Z;vG5} zD|Ydh0`KDF$#St9r?pp$_q?^jyTgXPEEwAjm60IEoz6_tv*(V)N%ELQ_e|sDmyt&bEktd-r4LN zan3krHP&Si-c6_xn`L=fN!CE9!(<-D+6s_(YRW5NN$2BK(>IXBBZD*WJ`oRT#iC5 z#CI?9YK-h%Fq)5$qv4{J>>#_!UNE)Bc;8L-hN+Fs3z*u)#rKGE9go(2WAH?K1pAuYrw40_LFsGT@z-5ULZwR z(Y1uquJ(l1aXPFE>3F;s)mIBw_tDqs5qgXsuh$7r57LA6o%$jDq+YLIft#WFIrtf_ z@6t>42AKNmv3i<*UW8ElZ$+)2rytWBQG(zRVUaLPs ztS0Mw^rL!_{#YN;$6&WmKdwK~NA;J8(H;7@KB<2M->>v{CL8(`{k8rHHjn6U^choK zD72^dg+}{(2QBCuQBwENJ<$$pm@#IoxydXsPnxCXDYMKxZI+v7&OaV zeUrW$t$dgnZf-UAm__CZ@LOxv;iHF9B3Vz=GfhoX%jBXz7-~kCk!G|RXU3ZeW}=yF z?ljZPbhF+(XEvDU%|`Qr*<@Zco6SpRi+S0--W`pQ`ho9lW|DcuylS?Z*UUEay4h~t zFgwhfW+(dY-R=-~3`*--{gS!e++prEAGkx&TR3Khd!suJrSKSf5aqqtEpK7o@(#~NMmXO&r=0JdADkbZ)6P$5y+1p@ zpyr-Mt?jJ`>KpVhJzC$QZ`U*QY}ArB^t*bW{#5VRpXmeobCY2*&E<&wN%Om#=~i?r zyIF2kH^*)1j_`0AF^~W-M=PSUbX8pq{cyIfu5)w^T~pUWO*o32FXci?5E^m$1p+zv zi!gB$g~6{f z+W8cHJqEf>3A<^5F*b-ReJiNtO^~GkP_e3x(R&75*clZ@4_eDcuL(QT0KE5Vd{Kv| zF%XF(;zem{8=)zT-i76j&ROR#=Nx)F1cLs|LEmLC8qp{~qqjh;uwN77tCdJ)Y34m& zemSPUMEm~=E&iK8j&TQ{C!4%_5$Oc-h#rM;{0)rLZ|a?~H4~%tS^bwj=hniAIu|49 za*UwQdaJ!PG=^dX-QYd%ZS-Ei*F@jO#0>ca*>n)TiaZUfhEy{UBj}GYF3LfkxdSIX zNs7G-(rSOmrvv36Zh3_uBQ=3k)DyDLe8@Q;iLYfLv;!(no4Zu{#jw6krC1NGk-|V( z>ziT1nO~U87NC&}ftC75In}5G3YQbwF!_tvaP6};4SkLQPxjHI58@GZT>>!(Oa&@VO;X1|MU= zGg5<4U#NbgbVMjKk9s%Gc}kz<8l;r!6lyZp6dOCDkcs%By&_$-XKC~4eEzf(8MFiI zyE=tfvK)oTDWP!v5dE^RejfiZLEV7+2>&{xIR;e!sVwWDSBaEmgkw-I(1ZAO-^QVa zpC0Q&ptTY`nsc=xF_3k_h<^~Os&O3qggJQBl^xL^VQj?Me+#&sG$)asOqa@sYLDZN zhU9_~2WGT5a7aKqt<3T4j&~ZJXq=*)q_FATzF9r6!x+KtY`km3jkU3E>)3cl{MLnu zT%wn&(IEdSj<<_`FCBHgC02R!D4Zsf8LG)-m}mtOKAMaWPLmPzL@2#L>D8o{0s(GR zPUoD?aL4O{pwuT$IgLO;*%LscaglhX3e_YPD$(#AIngLaP2BuzI7S}IJ{@`96g$a- z1-|(gGIv=7evESG36(n!D!t3}S71Ml5!gej%NOJypS~z#BxAFVBAl@BBUzGP0%y*HxMl&Pum)+4c>?0eHdyt3mcIw>+Z@u5HPwr~8dAj|Iq%_-m_c(dUK@l}YOqc@m54xQb^_D}J) zf5&%U2w%BlF&nWyg=kGN!q;1NbjtxJ<}~$vX3dn?DbqSemtvhOAJ*nmsnY-dZ~z62z^m9cA^?0Ej`3gF#$XcV0$?* zf*E}cKZpY!YMH@%)Yis}`oO>rvm-o~E(Gc;zmYxBXzwC$OYi8(n1WhYJsEByxtQ1h zZ(;0(6&P3aK=Q2zVy;Dhl!q|$TTo*HTdCK>Uc!z-=*q6G9ldu06}17Y4&;hBksE8m zXj+8UBG&8cIX(%f`!&%bdY~0g#dlN7v9q=t>s4p4R#lGjr2%*Vs=<1wep_s?SU3$9 z)58>1el$`f8_a^MSy0K9(_nJ>K8ck;odD_ts6 zKU4oqVr7_^SN)y!cM4hm5Z)+HuBABC-vI2g0Ii4bcau={H`Pba3L0)WVIL^X`U}w~ zMoV)pmnO9mJNnk+dMEWT_)vZca(R-uV0rrQj$76569V_PY=bxQZOs&i-dvX;&AFpA z;?1!ls^qtzR`iL)R~0#p65pWnqH)IFD%C*0-+*0|Pu~LR=moBv4)j%YmXFgxW`2v- zOKTgG4&S{WC!WU&j1`;`wxf9Wi^dVLQCK^pcqnim7L9AsRUB8;2(;`{7}I^(6ulXS zU*_nLIr2t*Sq^g^5XmW$N`ssl01kG>WL;6aqj;fh6m5iD9b@i@hAt)|T$*cjh6~%r zMma$mxFAk}WJ__f622`x>U$}i!OSO!L6qjlfckqgnOC9F1Aqh8abjWvd5L3F^CDb^ z*cj@KF=@3lo9nmuF(6t@XjpR`?T;>S5j(m-miznA(2glT%$b;M?E#nkvGLQ6c6GP6C14A-f3gQ?qlW|el2T+S{4aK-wxl3R{AIq4uw|z zf;VeJ{PSRkQ3-3sGG+c{T{i zn_DniX3XdWGrvi!c1K5}(HvLnl0v1{f|Aqg*p-dRXpRrJxVq@UVMo1=jhhsZxe@Ng zL$3-~XI>#Wn%4_QBvSkztwTIjqH znpWfBE~Y1h+3yKs(>FedmEZ6284uql#fl^nKee@UxsUSSDo(yIRGRIf=F_Z?{-{@g znct{AQ0Ws@pbklX!wiw%Z)3XkN%oU`ml(k+miFgultx+M1Z>Vv>kQCPlQqd{94eXl zO<~8=j)}w-G3GZb=LxLJk!$Od?59LStuO^;5y@eS@A;vMeDVu5A3ix$Hq#sJ1Kr^B z5|CBsWDaT_`w+NejYDPE6zVkJB~piZV30@j=0YO0{-QDI4@wAe3uAmq#R(>9CtvSLIVA3c>8x9q0=rU{Zo?z~fX_FqzbRLfIl4;a`2W2eN z-p1NSH0Nz@@M>D|REitVyC}u<-A5iE8f>~bB<{t&Gg5eRx53d-_mN#O5KN1dFAcf=C zpqMqk$0BX88d?mk080HC_z)+By)H6Ky}tmRTu@k#u^LIC$!5Or1wQ{d9kH-xiWevS|=TdS+v1iYBb_S^Iy#p6U)&QS5V1?Vp&iM&wblnD-zw0Q#$f` z2F>#^8)TOhI&z+4A;iV*NlhWEoMq5E+*Ua$RF#vLq;uTp9sGb|0CA#vF^s~kf?Th%jcI~c2g&VP z3sBdb&clvBSBYs7&t`YQ*0M#7>`7PSK5ya>aC3pR7h9;`UBCB zW0Ca;`)+6PHoWIJTl>6BmY68rR)P{Z$zk(D9cMnjB|DQGQO;G9eW{$>=1O%hA&yt%+=6BE1Fh(EX| zBhH*6^osO{lHpx6YsQWmhw|EaVcl%QLQfbCOI6&CoUci{C23nwlH1Y3?MQ<@4853L zpEEow)mXn7LuH9Jh}mZlGERum=!3RHEstkY7;Q0n!W~=&qY|wnmPOxL6Mb(R#0>e4 zC(4CJDYRIo1vYktyPzw|KT0uC$^1H0;)QrI-BSEA%sh^qxfQXF)Qxa8_=`wsF?st2 zpcEbEPt%l#PYXwt`2dtHH>8zlICn|A&W)K zR>l)J=Vkj1TBD&9S&E-W+;6|2?*N@7oc+-G6mqU#9Q7`Bzepm-C2+J>*7~k(*kWyN?x1m#gwq`5W2%KVxls%{GPUb}8>+LaN z#Yiog`>8~19-%`34`6y6flLg=zWlg0#?hXVXHh(wc+O z?ZflJy30??umWlMyE2Nd)J0eAg7vwNE=U(q3Z`qaO;_y7T*0~>Q%0Xqx}0UvoP-1% zq=m*iAwD^e|1>`>qyJS}-u1cBs$*c5FcIfZp%yG7q7+QabWo^nzZ+9V{Xr|R>f*Kp zMM9-NxX1ZLGE1DTwEP6_?;{&>LzDwPJp{c(>^?O@!HkF9Me0P_E>Ji;m2Ou>cy*3e zHo-Gyb@HGM!(Y4mA{s{84C2YQ2r+FUR%;{NC1e=Xcb=OoUYGE>pI;7#!GUs%Bsofp z5JVvpBnY_yxwKCAkMfNjp`B{ncTrzRJuUrN6Zt<1Gx)}=2NK2SODz#=;X^^8-ZDn= zDGsUmyi&0IgT6Odd5q=!r602Gm5)clk;S&gmO`=h`1&uBLzqQS4z;A&Q0j4BTfj;i z>M5mx+P!TXzS)l2Ot-1D{lV98O}Y2HAEv-p3k6cCpVv2m0(tfy`h%$N6EXb($AoJG zg&4ceYgfO8IB{NBV>lk?~-^3?gQ2>2*LqMzNsNOj*d)HaKlD8E2k(mXk=o zIWpUy7txHPGWku!jE8_TPyYM?epKSJ#AW=>=c5zt`lArP!=0la!U^~x9BwhZH-I~e z0oh6YkPVAg0u!mG3Gs&?dI!9bd!kUkLi4d8=Jwo*_MEfz1vM+0HGawdMi1 zp&E+w`H>j(73=Zs4{=1BlGM*qo3a1OKaE`muXHm=3G|1lI14^cJdBmhC&WtFU-(79 zuV$WkIN*rY7|A>MU=EXmuLVLlB`j%;>2G*m^n^J4_;KW&5o-UqPzCv7<0v@HLJ>F3 zVQ<~g$_s^Rkb5B=5@8(=UWLd%VEYnVW9*xYFTW8}X=g;1hSrVzE(^NS@915qDNFG6 z%3{1NflBoyo6p5f$M@i?@%5p?SBceRQ+yeeC#T`JMApjfxL5ZiB8%U$QB80U&QLW8 zzwoh4ZC1O~L3|q(b}I0fQ9Yay&J<^%v)0++eC(XS7d)l$%|b)XbtD089#9E0%k1J> zB5}_eqs(UeuIL^0CSfR4uA8*FRR;Cb`HfO6aqQ0%duqTQKwFJKYS;}AS;F}%( z9)`Y#!B;E%4GO+J;jc{abqRc{xUQ-c{I>xpmwld??8!3NB`lL$2ID33CAZz>2_fUQ z!;W&#cT2zg6So?+tIMF5i@5C}UlB5XwUDJx#kgG#+I+0N%`d$#>Q-gSIVk=^aJwzW z?L^St#yRY_OLNOZ+K^f~7_Jrq^VlV?3d)X%K^ACJVrUH!QZ>*<#L&=3s_~%ph@m0% zh0cN&lX}!du5YE$_BfT)9%-dcg(}h5U%G;y+i{%#NPp?D1zd;OT{W>)$Pk_3Bo5Xc zQW2VF5ph=&Ukk{>(+jvm|5SLaMc@wgHxFs|dnKP=FHlxk`0gYm9eK$lpPOwzxd_o; zslr#4RA17;yF>vus6n!Cgo1WKwT0Yzb-)j~cu6HrM`6$q)Fowwx}@wSwncJF_K@2i z;(||GD80TrZyvr(npf&1)2zFu$Ze#!)V^4Eh0u={np$dm)E&3>-Eg7<@Rf~Tf!^o^^Ex=_$eL=9erfvT-^+b4NnzEHZ#KuuasCKd z6z`5$$zKJdtWX!WM%)CglH6#FY*j=*Nv$9Em;pilqHdCJwsF}1j!1Y-BfMx`l**|% z!Z7&ubsZMJHmK*(8|_kG<9oG`Q$(aVMV->(G73R0j`*w=<5Nd`qRz5UhkYiqpOrDO z!k9!~wu7SZt?f#V5!VOVT_i(lNX8k;%{a4-L5IJ8mi+xQ<$!~)-{H@`>-M@mXtV=I zUsU9BTqp-9EhSlpiQh`GPGhdMy@YsMGuxLY6`!_1fqXEP3-D_N3Z(|{-AS|)naHa?py0bT*rWrrwn}a+1^u=@7@^QO zfzn{N7zY#4pUbb=buv`iv*6LC$PZ|@Kgiu|lM=YbY)Z<_F|J2j*BFN|7Rkj0UDFOM z?Rc|t>IYTiJ;f?=}eJi-_2Nj`py@Y=s+q6BU{9*|@inlSLw+!jou*!rx6G zUXb74=N^(mvkyMR2~5z1%pesA0W<@LEBL0lLBJVb7|Tp>PAATM`i(dbmE$hpT#n~KI}uQEZ*!b)As67Bh;~E0I&rZNwjcu&bZicj3RupZ9_(<% zavlxNIHeGmN1XA^KXbOFhQjSGFNqMt!(GPoS9{PcYai52BvK;DVzI1*;-QI_D7xv3F z#q&WsuKB0sz%?2}Yr>=jG@{#BXS428WObiz)6j!_Bc7B7d)~Mnbh=wq@{AOz0FxR_ zLkq&J-twdtqv)0d+lsJmL2DP}wic-X@lO?0=D}MD<{G@Yq7v+A?}5Umo!V;nUaUJ- zA7*iA=uP+p=^o-BYeQ9Zj{@6KNE?yD9#E}q5a-2sCkRRVHdbbu>BqS)-W}p$R{OEe z0}ua%qO1p7I)B4`0MbcmB|h~@1*i{zbfVY5H^t>$+B=M-Ej1tI6?rR>e>mpN%|D8_ zV8uSDmvq+7gG@eIn{Pa3>D#lc1g>lkS!vw*aLWaw_i9q5E@w21Vn32i))C@7x9{A^D9? z76$D=Lh7d2QSYhIs&k+dKY^P|h(czGn_&V<5EgRRyw9$2(}f%Jiyucxy%5ECl+RBvB_@9EDDwgA zXumn;B;!SP3WpWouVTKC2*PS@!{VM9A5miYt?>Ef{40JmhEsZ3Gmq5z|=yR+_}f0-1#3BV-SsGu6dnK81L0kVJ`zGwvJ9lawaRDB1S!3e@Uq-}>OGi>)MBE2}D^iYnBv>xE zn4p%vEh;wrr~5x5<)LufXB|i|b-=$f#y@;F$+vM8 z{M~N4jZ5M#k#$h%9xlo?jhIZs`$OJCP#^XlhWe8CCdU_C(C1RT?IYx%z(n>W4MkD~ zY4TsBDe*t1D9SMv^TpD*ovJMEp(=+vrz+rnsbt(G^|Ag0cOI2Q+#P#o4CJiHtvitz z(RflAZ;Ed~3%C*$YjvQ{!0m37GFvJ{r=3d|Hip|jWn1yVV<;Hmoj-ITP-uVSY>Z7Yx-HDV|LZ-!1wv+`Kd*hF7wPH@`>j3B)ad?g+XC5H4}c7u?0e zM7MrT#NA$e^B3J*2Oe}IRuB%AWF5Rkr59>*BrAQ4`=+S>q?@N&x+F0KdFbLRWcYyO zcJcmA@>(C~nY;}y14)P_J*EVCNs`bSb{#zzrbS1b;-o%_dW^}yBAfs6cE3MD`@=21 zhz}@1tT5+*v@*(%lii!EgL4hUgGNI_5Ad=5c~hjZ71}?=!=d$9tg(_y8a;wh>kgPn zLOOp0uyiX|RfU_Fv>^Q5ppeuQwV@pDOT5fKjt4wK3po`o(JNx@+j6#)m@u&aIFiD^ zs%q?QLl;PX{>hLx5I^#5=fXl#-VuNFX-v12SpMrY`ZVzG9W(!x7m@r1`dlr4d!IqU zuxRwAu+~SyB1&vn>-Bt4=-gIxu3b>7wsHTQ{K*+miQi8?7fXrd_mh5v`Qg5@n7T>z zn`$-n&cRtZ;%Dbe{qGC{`rv+T6BhLajNFfakzP!BQqlY)-Ay3pgzMt)Eg0&9ZR!Qd&x??5ba#2@Il9S{d{2s}rsy6mox2QMO zTk3PHs26sMI3=8lPOj6$Y3AJQ-0sY9W;(N-C!A-n_xhG|7^7gCuBWfU?H`wyFeSnj z?y9h;!cR$FQjw&RN#&C&CS94-EU9f$x1?*6dM5Qwx-IGMqzy^ilRixPEb05CGfC%? zL&=4b%O+PyPEF2C&Q7kE+%&mWa$fQ+$+suplRPVVRq~qT&B-sPsFe7W;whz4%BGZ0 zNli&lsg}|?rBBLrDfgy4lCmP@xs)$bPT(5VBB{kvOQv3uS~j&pYL(R5sjXA{rw&TJ zH+5m^nlzE-rX{A8PD@FvlvX8edfL5d&!)YXo|!Q>j4w03&G;4fD<@~x&TNp`D6>gs-^>A-gEMceSiIt>iceJhrs7W(^Q(B( z>SxCvatJn5`();VuGKR~`n*Y$N%J%e+0GII8Gl1K_AB_@?lN=~Yj)F`QC zQU^b0uS=SoG(Bl!(vGB$lRi&6o%Bnxhny{zTrN48bGELZvtyGdBu`GB!8!Y4Bxg&e zl!@eQr<5+p*%>LbQ=Um#n{qJa7;;vn7Ui5RgPg6HS~InAYTwj>sZ)@%OOUgQb2d3G z9XWe9a`w@*-Sm?u84qPF&RA6-XLn@0m9abHql`~8zRLJMGZ8tPomn@Mvx72+B4_iE zvtL*Iq2ie;rdqx1!iU76#fR3O63E&9l(QP`#CaL{E1WT;o|}Poj?*Ce8+wC25BK0r z!Tpkr^_7;AUkSf?;N@cs&A*i>f8kd5zYde>`-9(OE&A{_A&w0>y8YOwqi-F~|e)bfguLj$o`j{Cr@0IQj66cpq{2#=}Do4?aBLaKFQ}=fr=9;qu_02TvXR;^4u9 zpB>zP@a2Oq9o&5I@q-DU=Y1g$jDYPDa=3R-#Sbgiu2{LuLDSYu_w3pU*5c%7oP89| z24^$WR{}UYdB?>D0o8fmIp};1{WOtC#mN!$llTr%=w*ge;)0voC*noWd-Ts858L%# z*vs@9d9Az=-e|J(CV8_w>K9}FJj|NC*+lT>24rtmU+XhzG z<3_?=-uvDj*dVVxq!8-45Y!NAMaT_R4rPT9BU~}Lkksg3+}ybNaZBQs!3HJ#_kVF~ z;bKGFrVCwQ=Wv+L*SF*QgO*qg30;4;&&8I;TIIn`M;eIrmqe4XKs$}phuY#=Ct|N{A6mI%e+;l zvb;(E>DJIYOcn11Q`OC;+eW=LZX;7k-|TJDRpmI`30Ys)(G76(GuE6j{)90O#o^>o zQQTfw25WZZvD#J@qf9n#s&0leZ!IAaUL#t&m2fA}jr{u&L&YtS8^06x1M-lQ?Vjv`E2MaF^n4=M1qLv+ifH zw!9WA%4@LR_Z)Vg*Na-1)6~GsrW$U2t%=)StK$aPES%J;jdlIH_-5n{Q6FD1T!Fh^ z8{ihNhS+7h5_6a)xO1l|&bnWP-L&^abMc{Qhm+1%`{)qR4_n7y%deP~iHsklbUE`^joQ}A2x|7q{>7u)+hcPq0Tdmcd)jG9a zJ*S>m8=V%kqwchI+BxkZ6|}-l)mJ;Mot91;^_tTQzcjv0y^h}*?}OhP@2R#pUDeA@ zH}#6sUA^jDtF}5loW4#!r@u2mz2OWrA+^I91bJhpGuXKvQpels9qb6-DObu{<&$!W zd`d2pPs_#f3AtR}E2qnca8B%I+_HU(oFFI5J8;|fT{t^-H+E>}$%o}5az5_Hd{jP; z8@L~nOYz(8E9iIGRUB^LPEe!LK6eDsApBjSS!Mj23 zS3~7z_;vXMY8ZZVez-iSM#wMlJM)KB9)5RzlsuwFK^R&&yatrdm$aolz*xFVWzjrxCwUhn$C<`OYKG0_RbjMSRRzq|T_H z)i3H-^_%)#{h|JJ_Bo$A`<>661J36dF~4xqoeao)6`e}XWw_(LiZjL;>x^@5f@D3~ znT#9YKf!5-kJLWz9_O$(-J9<{=se`ib>=w_I*+Tb)Ys}qbz1%8-Rs@w-S4b)Ryof) zt066}aa_l9v}3%P7(Zt_Z{Sqqn;5ex^*1zjN^w0WN{hR*9)HU_=czdTb-7YQ1(mI#x z(TbP((R}ZGZ5o(|%{=!u_h$DN_g43Icd|Rl9q&$XCz^hyuX~3(#hvBe>&|rVGuOI} z-LQL|n~RIR3cK1(c8%M^O)}?P*S*4R=vFjm-7DR`=1;e++r~|GFEM|(70h4eSGTNN z-7Rl^ar?Pd+&DMIz1*$lc6WQaX>PjP&Ta2r>Xvh_b$hwp+&*q^caZtbE$t3)3%UJW z$5n2IE8Q}tySd7}-^?`kx(RN)JKJ1mdYK-kk4ZCK-KK66_bRuA+stk5mT-%?#oexM zXSa)6)J=4Yn0&XB+tIzo3^2XT_2vOH$Fwu;%{3;~Tw&5pH#5*IFr7^kbHABonwplT zmATrqFoVoPrh{n=8S6pQ(R4Ch%zfq&Gut#XSK?O+2b<<*u6xpJ;C|;_?e+DVc>TPq zyx!iGULUWKd(^ApRrTt-UwYNODqcPJn0K=`%4_K!_ipk=dd=Mv-fdo-na{n~5i4f3+R%H9y~8n3SVjo02A?2Yk;dpCF;y=LxLUI*`b z?^bV^ceyv->+Id=we<#iwY<^ZP_GkYvhH54cdb|3&3AwDJTK(_>Hg^c;GXeZ&$xfM zzq-G;KfAxVzq{w$Q||ZfY0vSr`=JG^dQSFelL)9deD=k@Te z@S1v!y*gemZ-Cd(YvHbRpLCbHPr1w7r`_f5GwuraId{Fg&Ry%SaaX&~x*OcB?rZKg zca?j{J?wtr9&tZ#54zjkJ?`u74);xWr~8(>3*Y{~<9_Jwbw6_Vxu3fG-Ot>Q-A}xV zUM24`FVlU)E9aH>DtJlmyY6oHJ@sPWK=xqh0nn>jU_D1bK4D-LML^DC=o!$nfx3{*zk^=S z0z2~pQVRp$fzS=C46LKk&8rMv@xUww5{WC2N4Kq9q3cMPcqixdFm zSyLY+pf~eT8hUe}1weU5c|o9bU+sg^+R8^Rbjo)^9iWX5N=I9u9nc7%@*oi34n8_T zzXs?CbOGp{&;#h~qaSp#BMb&;cG3;N+z@gZ-6SX=U0Xlo?>3;Pg+2himxZ1Uo$?*P zT9|+=$G{Cf0+JX*zYD#u1tfF<>D_|Ls6RmEIS-(+Av^{Q^6><8ir@9XN??eObSjI0gMCA3s4K z2T(qp1I7cWvr^`;H-nq)7wD*4CYA|yo0_NWx{SbV zfMHHRf6~I7hQ8Fod<*?43qx^RW?^bWf7-%a27S4Ow+i|*7N#=v6&8}xM|lnCKcSO5 zz^wuOSqn}1No5I`D$v(hcrQR-YhkKFr*Z<^Z0PHO=fJZXbSg8zBY)3ZxQ(E1v@p~z zh!=nx>FKTlhDZ8~7Ml2Pwvbc~FInib(6?CV`p{pt&~>1{Vxb#Af7JprHzBtAaG}5E zLqXr>gUXcZ6M@QfyAK2X4IgpPDPIVMfH!>@D5N8pn6Hj0p9gN zY1|FG2T+}P-$zO4ANXhneUFbe&_DFi5<1myLMz}SAJ;(t*hg#VpIBfMnuWA=TFdzzqPkc>?M^W~COLp?~4yJLrde+z&Chnieud%>t0hakKux@~j zW(%w!(ANwMtR>LbCJd}9(02w5tS{ih3k$3?&=(pE+VjG^oPpH``p$uYbqE|LwZMu5 z4!BxiZGz6=GsI%(6doXwp;H(D^;NQj1=cX=+&P2#LP>l9tZUehMZk~xJXzX;WENS* zg8Dg0{sF9i(79y>^`o+!1<5Qp|I4604(EdzB-h~FE`$0)nQTF_jHECC>i=Y_1<5-y z&4T(!N&W$nZzP2QP@gEt9Y8XWBwhgZm$H%t)?$PtJAnEvN&W$>&(Qfy2K93?%YtMg zS=EC2P)Y9q)^UWq+=BW-nQei!9bD{eLH)7Ju^^dCQWyaBsgnEySOcPyi45u^Wv&Iu zTR2-trvl=Kde%q8ntP`@bY9Uz&D&RY|xU&OvUgXA*W5#n8;ozSneAel@yvY@^b z=kpjOqsb-~)St?$EJ!YsO)aP&mCYZoFAlZ(7 zyOe*w^aS+279<18eiqd4;v5fyWH_9g5)@PqXqSya+n3lhLY?6>W}3J3zGGu59(WSGK)d7Ax=>-s6UpYEl5U` zV=SmYmSZhQ1|->tA)$}6AX!h|?8AdT-hyO4d5Z!h<1&z(Bv<1oIs*DAVhpMav$=d2t3mWTG zZ3~iv)QcA0F6f&rymz2)vGC9a@%04*=a+;++a`F=L4V7_TMvDog|`9vrxxC`(Dz$- z&qM#r!lN(`Sa=(uQyKtoE#`7as)M}u9)+$fyhYGq;UI6kCD2_9Z!vU~2fXC{t&qg+kdmvw-_SM|nB3 zeYAl70D!zy^PxWpJOeY}P`<2$`32~!fYm@-V2uyTCkmU;3ZU>%Z=5XPZ6D2{qs}p| zhQ7;3Yv}L#XbF9{k2cWXvrwqR&ilXzKrdhquov;!2K^)8W1tW4iI1MpQCA66#~svF zMpx*ls|58j^v`^BgO0jNP_IBoJ!N!yt}G_1k3g?zA?HJ{ z1Y8C;3!qoFkdH&JVj&kn&$5t@L9c2dmqM=wTn@j_K+gthAP+t0HGx_%Q`~cbx-e7R z>se6$O7l;B73k%lHwBu(OmS>(p%5pH_Crt<$Ckj=FjE{`Stx`}Yj6ZbaY6kiD2iiS z3q^5h2ebzr@z5QBYhW%09sLDCQC!eR{6CC+2Vhji_V=Az)00i_$!1dsp(i19mfjMI z)PR&wq>1#7^j<}(f+ARm3IP;BK@?B~?1K1w*n97lvX}37=Im}3#P|Pv@5kAkojZ5t z%$b=pXU@zL)KJh~3>EdE@q(Z>0PO=ne^u0t{D69Ye5QI2V5p5iZ)MQDp$}xxJfov8 z5mdB?KA54BT%pqhu?Mt*p*8~@$`E@&hcVRVpu-uW3UmZRZ2@{4L+k?`$xvH@j$(-Y zpraY88*~gqU|iA1GSpU}w=={OpyL>7YtTCw;z`i)3>EFCPhj9qBk7CLC!&tjo|72j zDbUFb6>Y3fVTePZQyD6?`80+&3`)8VsAzkA217gzI+LN2F05pT6`-paG>7Y}87lg) zzJ{T$2VKihsZXzCsCR?j#UNWlr*wcC21?%nWM}9b7-|IQMg}4u6@3#!jRf7y5YK>a zVW`ko#MeoPBcS&%RMOvj8R97DHiim4*0(dnv!K-90JRkKeug*(O8pa1Nyi^xi043e zGSsf1G#&xsdC&(LYBx~ow}3bf`Y=Q74*Cc~oB-X;P;UXPWQZ3)A7!XLK_6p?7eOCq zsMK%vFvKZP(p^C94O+zzFM;l3sMMcGF9C5H^Z-Nc3;F~@oB<`B1Jr(?q-TIQ3re~L zsO6xfPk?wC^e{t3|I|ru0PzYawGE(Bf2H;S#H*mx27r1iD9IfVuY;1T0d){4$rliB zfRaoBbucK&5i%2Rf}Uik6`&+9K%4_583F20P?8HE&V!OH0CgBB)gKUVgHoLVbvP*1 z6A%|bscwKe0+i|lh<8A#4uEnj7DhHsB0i|~X;yqA$AE1r}rF;SLJ}Bh{sJDaC^MLpOl%4_9aiEkA z5FdilcYt~aDE$qHk3hXp$K&(I3_S zhB_JaYlfibzG0|SK)+=O%HunRIu-PLhM@d@V5rkTe`E;C`zMAv9rR}g&MzzaFAQ}C z=&uYx@BEFS&IJ9PA?W>oFw|L~e==xor2oZG!$JRM2rA#uQ1d(pjR1! z%I;yPl&8%QRF7*6buR30g&|NULon2NpejS4eul2xX{?LBkjVvN2G9g1Q7Wf*~L$Ba)#m1&v||$jops)McR2 z44lzdj2MQx95j|8AWI{Tp{@XpX9&pENMNWdK@%AQGB%PJ>MGD=2CY$z6o$GQG?hW? zRU?g|t^rMF(E8QLV5nx;|T_>+cTbI(0V@OAcNNM8B`{K*8drY85))4Y2X>W>m5*%-BEmg8}wNQ`B5^C zF)V7M=NPn(%y^z*Q9X_`EGqj6hP45d>JMm}Kwn^J8$n-WXq!P_VrZ29G($uEGtK~K zQJ$|rscoP~>Mx+^R|E@vKjU?VwFdMJh6R~sya}Agdmjg-x&h=H$oLO~`~ewMH^`p+ zHyIa!_wZQ<{eWR%48gtL5*EgXjE@)=wbjSKXGnhv^mE_~e101AONK`EqjCcp>X7j@ z0KL`{K)(gP!)Nq|jPDs*6zC7YkEj>v)lUqIWc4e9dYo(%OT0juyq%zP@2CeHdqZwo;WySy~JK0s4 z@eF+~DC$Na+bA;;NWy2lJ2ROL?`8G2XK)H;@pjixBqh#hXXf2gl zk70HKrF2b@59*QGmO*Rc%+3s2V`NTX&^kMF3NRDtDUVqUvkNHtD8Zz9pq~;<)Ial1 z0CghUCvzS{P#*Jv72s3-NM3+R?_9+&A(u?(4Z$Q?LN7C+J7mvg?qL{c<4ouu!9bg5 zKEp7e+nGlg2HHRK7{mA(;V&vfgMK-~85(rUiMAjZjX?2kf(Bi4qCNzp8E6uKHqfAd zPRO31eFq9T6KI}sW;3+!LF+J#rl386UMN50?d%No9MU%-SuIJYuPw4w7JhKV+F zJ_Sn`++C%8FQEOLxxcSivCM5hJbz!pwF5MKuM;6iFR_5Od%(e>hUeZC|4AS zNx~csinbt_tw1$~83L*^Ovon7V3-X-O@@iK$bwD~%rH>s8NsCIf*B_IK^El&n3P8- z!|V@A&j4lyD0F~e*q{*%lim}_FwsU?4u;tR6#bWA=7B;-2uQAEB`}Ojph*nlDk$`Y zV73HJXPCvH^n3yMC7?wN6Y|WW?*QWpDAgG-%Rng|V3vZCyZ{rz%xcXr>HTdO#%0j9 z43pm3j$wE}+cS*6Kszu@Dq}~6Ss%0$!%PG1%rO20?ZPlCK&h;Ni8^G_-+)Q7AIdPD zpwz~ISr>E!!$hCRTF5Z_fkMv+<^a&ez!H3Rfi4A>;d58e<-iJjP61uXFiDqIG0a;* zw=>Lfpt~4mU(g2`bfP2cA%-~+^kIfc<$8o+{0+LBVJ3oBGR$G1k21_q(8m}i)%9_P zse$fcm@4RAhM5Ce#V`dZ#wvmd9YHlE$PRVk8G-?sxR4jYXalM<%ypm!!&nUpr6U-S zfh&q(b_Yed2?omM%4C=kplA<*30-$(Gfa#tE|ia8ps%@d83yF(f_@T={-8A&24v_e zU>ImG7uttFw!8~%L@@e*Qkek54Z4kCwgp8Sx$Z+5T7%xtFgt+m03HCp9q3Mm84J1# zco2N_Hy7HDU{d}M1CQV{##7gBhKWAvLVFQRk_Xz0U`B$Xy$HrN(8n1j#$y-Si(uXY ziuNLyJwRV)7{fr{U>MLDh)u#635vGNR*~m=Pz})Wxhtr_Fpy6++J`{vo@|R@GzSf0 z(7Go(m|;LyvqKohaL`bOaW7~X!$5tr(S8I2GR#K15)9}@HtI<*)`Ft`1fwk|!{`JGSrZJjMK;DWf-wR#o?&bP#rQ%nx`RT$2u3SV=rO@41I0K(FfbQo zqpxPC;xlBMoyIVFgJL`)7;8W?7{)!I&{cwQ7bx_UV6+1z*#SmNQ0N)KXaP#H1B}~1 za~MW@&|HSGQBlB_Fuw;ydl9T=pen=s0TgXVupoyVonZ|DH5eA;oP#zbSQVgXM}pM` z6zxc`pc^@8M}mcN=Aa!3)*w){Bf)Zmq8$n5XP{_9f>j8LHY8ZJLD7Z;t0^ejkYIiR ziZ&!z^+C~w1giiP?MSeifT9fv=I5YjLxMFO6zxc`CW4|32^RDr2W?0&zXU}a60E79 zXhVWE9u)0Ju-b#7JqhNwplDNqbtfp=lVD8%MVk_=I-qDzf;9^i?MbkTK+&EAYZ|DF zVU>brGtBQm(Rc)F4rngJx&t(iVbuby!LVk6<}<7YpfwrR6i|{8V08hdX8`kSP?9xZ zbp@re0@eu7x(urxs23LHM{)!|DdwkYU{hTEeg< zgEnGV<3QIK@CVT}c)_W@QJD78Ic4F~POu&92dLx432v=hS`1KOEk^#mok z0~Ya08CE|~$``OmHq-`yRSrt>0xXgf)eSJOfs(8N^EXhE3-Y!sP%1ZIUIFdRFnrdmI+Gl11tlyKf}BXI)GvR1$ryP{1bE_!~6qu5HJ|;{Tp-$!?ZzZ zj2H_3kD$XC=C7b6yAgPo26`LA^ni{8pi`CrrM3smtDq!DzzPAS_5iG4P-;s6RabII zZzkaLT+oROi|R`40azph(lfv!*-T+rRKKYV3uVoj#;{PIoaqdU{TSfnGf z7#8Ui=^0=(0-eLKNM?63ERq4053pK*&SO}lNAnrxWlb|aZ<`bZ+ z80G=c)eLh#=o*H(4|FZVtO8xfF!zGq#V`+ot_L>YUB^K;GR!@on}8kQKLz>#fO1)( zp!6N`Fh2&Rd;#+#P`~I!+Z<$Wrq17=qn79o_n2Po&$Y@VZH$RCU744P<|H}=BuFZ0RI7>^7}9F9zG*o z4!sX`GT#NIdVYe>l$PrDIqH=M`US(v1^o*64*UerpBUyT(4T?dz)uCG{`MF0c}-Dr zqkw44*N|r}WJE9`K7XNk+wg1# z=onxuKBK?n#V`!CMcxDe{Y3v5bSJ~y1^PAc4fuUPQU1JNP-f^w-md`aVnQGDP?x-G z_`F$B3Jiw10n`L6@S#(6nlNaut4>pfj<%{7281Jjv>jfpe5^q}=RqF;@P6w*p!?T@-9`6 zPfm`nG~0D++ATEHOo>TxMYS5;Z1j{!`%g-mQBZ)2DXNkWc`h?5Q2P)}mRTV}S5bEz zUU*#@rKoD7GE@(@OLcYNzbGR(r9e0}7p|Uifoj7%AKme|IR2qHe#mpbIQ^*@Z0|HG zF7Lo`(vOin?$eO@Td``cBET@M=b=1)zWIlr|AHOBx6=Il6JS@Tf5*>%ma_BbpW^2~ z7sxjc`}r^V_;xYX*UPu8XDC|RpU)tF`WJoaNj~PEe*P)qE9y1$8U1<4EmDbAnz|cB zq?BfGa0W}$Y2lWXI0;Bq-C&O%c0g=Y ze~z>BZxd7O-6KU`JIyW=T{f_Q*Vwh!*hy<}SobTGoy#I+j9vq?;CW>}sHU7i8B0*c zSS1a*UDsX9A#@F;)D+#&^pPkSX{TxDs>;-1cI$DTYojLCvMrkRp&#?EeWQ;zrlYh) z?)p$WB_>*jSP@@=&lNf(-=YkK)+!*^mReV#B*aBU;3kMPk!A$P7l;P&2^I>LWtwrZ z@$o2UNrH(El&uyQMQ5w8E<1EfMXUAQCLW(U{<*1>w>0eE^5qSc_OJKv6k*2nwyh_Y z)E)cR`PcrwqkZj3&C4GYEx!6%G~Y?OA{r`L>Jd;pLmw zX$)G`=}EUa{Rv-ss<(N`&p)Ot#sjLd8|~Kv?H8sbVZ#cysQKGV)BNpK!+RqFbMW2Uv#&f|cLs)E9utP+y-|u&DRkEhvQ$+U!cfIXsIfK}(!Wk+pv!n~4Jfd~ zksTbI<}Zt@xRA?Z;H$z0Ezl0KT>5Y>)B5#XD!ZlqqAZgvows1>yrt@`RJIjM?JyMV zaVnzSfaECUVv*cqAt%#1`4P*P#u}6x`*_|D!Z7r~imsy$ z&?Tx(W34N`4OK#^S2`>j;~Z2Q2@97I*tV)py85ve5v!lK>#egBRv8s*`JM6|Qa2L8 z!1#sDNy21Ptfqaw3J?1}4>%R6^aqR+$Em#RAshOmM zQerNL)4~-9sh-dGXa#W)So&(uHkKFKWB_!%7=1ilY2$W>h2nmI1fl7vxH3#DrKwdj zcwnkP8PMCI=oKb9eoF=2Jta9QF+Mgj9A}i%MY@G9Z)7QsqG&m7qU+0^9~E8Lpv2+K zb2>~@o&1OW!Qh8pAHVF09;0VJJp9QCM-Hx@W&aW<-W+&7K@1Zko)95V^egK;Zt|Ev zFW7I}f3_A6-Q}53*iOn=mQ}W)3l%l37hvF!>?L2$QOrL9qfPQXFH+qgOXgQL4@fWj zH}j7XAF?1l4wm|v=caqUU_B@!bwTrmiQY!DF46_+C3Mn(5g7j#I<#9ofA8I^2JKbX zcqSVao+E1e%R7*s?s7q#7xIl!5@>D;3$`>PRL7mR2BsujDJVkJkwL;V{d2pbB*4}{ z`WR|T`CmBAO>r)~joyf!TwECE1cbBLsqfiY<@vK}=gulMq{`D#4e|V`&s%cUq=IOd zmwbxjT*7K2fyaKt#3$+vHk6G^e>}5scXs$Cb zHjz-Nim0s9feBBy#FAJ3+rv(H!1LvqGkSF6b=QO_=)M z1Jk3ap0Yl29H5R>ZRbe7)FI~k>>N%{{hj$zF1~zZe`mhW&f$Ehzcc?BL{|M?>hH|= z**~0~>>uX81_mlD)OmaNhN$UhJYTBrchN)UstY|UJn>R)=g?P?ALP?WzUOFd zdXErTJLvWC9z)K!iX!W$4$;snSs@L@a5(hf!~*Jr80jLb)G4t>#g!)8WWMw1d8r39 zSily$hLQBzHE+I?@ILT`X&q^NO$k0A#M9TV?Jp*gQyP>B@8&e9`_YzOK9|Y+tYz&L z<(?t@v}cHXS|~k`rVh#-uGDhphXe;%WI0h=Vd3=8N`x!nadBJ;E3X6<$w@G%GNF?? z9;p%?yV&E@sj+Hehlx|v$34eiop8xcK=r8KV(8K_+F<6b<1UB^v2Z;LUV(uNtuWuv zaaBvBGL;(y>0^~xM|?a_4lypRj#{F*wD@?eZ(K2jF)?~h*WCpMhPtn-F5#9PJ?Fu$ zzkYOq77*$iL(V){<7%5BGqr)_(0j(R{2q5g4;)-A1& zA^J0hXXAZP+*micSmf?jTzo@CcP#2UKD)K6b>W+;#1Sfi4OO)J)DD++yH zbEM`r;@@<&N^vVu(lWouqb-l3-Xidzr@U4kG z(3|tn8~XLxlU?@5b?@hP`4*d@K_r(7m>#r>3&vD$R#>n>8k=2Hy6jIt(uOxA4S|GP zl*#9+m#OApEJ4sIf`cviEO6sa*AzGXD2ZIqhKisdrGlReSS;aH|9Gmr+?|n}BoI54 zl%1R%7vqSEj0nZBX;51wio`H(0W8ep;}ftB!1`DI>&k>`Qxix9cSNaTcjtEMsyI8T zq>UvC?bq~<&DD6%KxdmAyU&FSv!>0OHh0=&V|x6WHlOcV^TdxG)`WzsFN$(8ajpHQ z{W|`4RhXgy{s)%<^}Vk!zV^rX8Vc*h<<9haVgfx0(k{d@-Qkkn7+5M;>cLEFpj!dU z^8PD+-oC#|4Ea-}9qF}U;OXN-Huu(!>^#}e7DmPE?~R#T;yGBfh~EkSo8DK>9SOAZ z#6|sXb!Zd+!gr`<5q-if_(;^uU>c_BErzUhBE9Wa-SEt5NR(_9OUJrRk`3 zmQvH5huH-4ie;)wCu+%?4L>!#!;gg)vaqiF*Fk9B{p%|m9L^uJefs?uCryb20^U~Nm{W-I-9*$*CifO?bsct3qqUePu*NUxg+RHKJ4!tIC~aT#)B2EgEv1G#CnFuUf}F(Rtg3*pvFZ|ji!P^5j(3~Lgmue-#p z!xO}v(+`cjwbzt3-AX3*Z9TY?tHy{8cZ_^sK#$Uit(pxV*{pZR+>C)(&!8T%yt3Z? zQGSd=QCvUfQ@xpgGSth5{{{8)^G{z2OiyzJr$6CKFYT`C^hYT@yZMOG=E5#(bA6a%m|~-n%#)$uqau|^UR*oKOBN&7u}+KqOfJVIVaVL=K_bZh zd$0QJGO=RQCVToaPh0g_*$-ryrR`aP+)V4F*Js`x`i{ClubF?^+dsNPuLJo9P&5%m=P| zd)oB#tF#BN_PuZW_WQI)(P!m5WS_lFmeuF?mwaizG2dsuaeC^T%s+vrsPA)nvfr5R zv)?#9*>BAE*>B7z`;GZN`;GG<`;GZ832*-Je?b<0{xh!zyw{@r08am+FFnal+Vjl! zt&=fp;;!Hd1-?gjG|d2V;fS7r7S}LE=EyCRLfrT{WuIMm&@McBO5c8Y2c8gE(JK0G zd+~@c2_~vBQ zhKDcQ_b@G@^vEl}U}ZB%SFhOmF0Pk+mu#0wU{u?5%qRIX{{+4z`Afd^M=;+fe@;*G zXTFp_>e2#rnS{E8Dw*!I&>#bsfMN|wrKg3uG$Ep+qvb+925WS=Ovl_f>@@w#$+PxM zap<)DsQvI!u^$WepHzousV7Oj!*jR#Ar(j{b&>x9!Qczi&J7pc>=(L3L=|Jzdo&sBSVZDT^6+7P1Hf@$*R*%s*iT@TDx6|18;T z{`4dZ=07KGIiajWS)C}W^q~f0wtyF%PK$co&d?Clh<0E|O}yTV91IA>w(jo#=8th=+u`;oXW}3-TbaEce^iDqi zOko%)oD331s&aQE-kzW&phw2!xPmNfaq)=8+e8@Qd_%E1tfH)L--6l&^SZCDy3$Ab zveA(q?Aj{UF+0w=ZH4xd*Q-tSx$=OwK5@$U>iWP@Mvcw&!5{dUR$qM*$;Zq7PH#nM zwvZJ`z3wYbyP*cuPH{?{tbk?FK7|~T11dl>)pfc*sXec_*%(-*RWO}hf)nL{sB zGO$zI!QD0qTAGNN%VnupQlVA!QcP;}<2Hsau(9dgeQQ-_Vk^KmVK_5$vorIZ`8lra zdz4(${)^$zYtySy3bTkccGax|hhfu_;s*8W6xA)t&&$e8 zOG$`xM1};JhEiM9j*xaATpYB__V3sJtKuXyY?y#p31@DuklG_3$<5V99DGRkd>Juj zOp^iiyEa?Wd(4KCmWzh3e)!_~{)77#_it6F%_{elnQ0k|2lekCE@o-n?4n+G-O-~> zn_>0C!u3M?`=3klo~*2yGr4Y)K|@FPePY8y`}*85G&yx%M)u(DJqE4X^6Vpf?GaNe zMvNSMYw;ARSKJm--!_5)eREJ7smfWjQ4QlFT##wXY#t39DflUAO%F$yh*}Dlfzmm} z?dRd5Z032#x&*maKvu2F49H(pkTZ~Mad%4fH;{N^@7EaH@NKy}A|^U9J~}NXEjl;L zr1{SuOCr5TE=NfWOt&b=&4Nc(Ol*8l|20+J2On8}cvRSi${!c6{kz}Nn)!EX8%ujG z|Lfr$m)0&GXPk<=yyeyNV*6;f=g*d%sD45@jQRyb2a}W%caZ}%iiX2PPHfn}^R6IU zm0`?K1TET8Hlf7D$Zh&0kwn|{Ilg`Q++1Ei^3EMrkLttSRy4V#-N}LXeK2Lphxeb| za?6Tk%lGe}RhWNA)3(e0c=-Oy>t{_E^xB)}kD(~&Ke8-lKKjWPybf*9?}S-8)1WP%CNs;QfAzBmZuTt6!=QZ`F2mj%(?BPdV}AI`s}mJ& z2T3g@S%;?=cgLeCJF}W3Pkq! zb3$K#%N{L5y;nd~zY!GeCNO@)CsT5j3m5iBI? z)ydCGNs7U436^&ym=LfP#7<4xUxPcrTfq1Pd}j$P_59HrD6QzZtHI=UIsG=ZST$nK zle?!~xNGhkv!|VybldhOsmq6K7Y8-1@PUd>cXqvh&YIo&@JAC}5sPE$Iyd&4Kii&W ze^U9^vdQn?zwv|F9b3XMdA2{#D$Y5loSDN&(ak5oPSxa^yaURm3G;m zib*xh?_k^8WvwOUr}n;%CB3q zF5aD&6PsO-6+#g&zUHzb5blMUwZ!t8Z*t#(W|Nz?UjJz$M&;CN*{E%FdgHuF3s0Z_ z;$K>_LuT!=_O9B65lhXWNj>ksB~sM<_8$!^6nHxHUg&cP$}o2b%wh!wG4`9g>`#Y8 zQcUtk!*QkI=V0NZ#IiwYLZe}09WqFwKWRsBbU-rsUAa3>AefAt{NZ7tus&#$CCKY^ zk|ASpaaea@$w+|?aB6BLy;rtqSU9=a2Vz9Dy|?npl@V3O#q^Xrc)Jp#Wgwv zn-v!AVPLFKRo@PUVaV3SW?u?6ME>Pbyw=oJb7Wu^)en}tW12K}w`km^Nt^s4S4>Vx zc2;O`I(|78?dgpu#b82h%)zD@(=ZSha>N`N>iE03sKnc48#Xk^P$yHXxJY*vwcS#F z_^qSG9h`|7&3ZgHrgEr#>F3ImJ-61IJFV)0<+nY%V*Zo^W1gF{!s2u@4W5ukp;v(PLLG zoboI6V<}gY`tcswzYii1xtdS?nE3~;lEr7xg~QkFTk*Z%VA=R zrv~1;wzA)#X1DiAtu!upp1!5mLw9>L_0r(J4ZGL$2;&9$zC7>yQWZq##AvX$MQKX1 zYIer#Vj2SGj0_)@(>&Qz(sP0*Qx|f?ai8X?E?Z#57=k zdP9ly$Cut6{Sag>Wne9UYz{yq6st2FGoldthm-hJ>=$J7S zVJ7{iYQIKp+B7O|+xDo)zxu6cXP+`d+SV^@*RHUBTRZ0Js3l9Zm?daeS!O*Ay-vnh zX>f>Q4r?l76nwrHi1o%OHh#y4zP$A`Uyh`MEC*U^K7~gy_jqFim`^&({Dar=NoSdV zAdoNT80PN@K;5eOauovEwgA3R&I=93)Y=2lOQz~grom08J2X3iQ#Ce#M+6qYZcVYuaTFV zlkJLege8P0aEpcC&|-*34QM@>eOBJ}u~uSYal3ZK4coR=+vA^x!2PSfUfp2u;pz6w z=RKbvJy6}Q0qxs;{i`eMEXo$xWB?Fr;&SqGG5psMmV#(_zgqbU{(y$ViVm>i{WaH*Zi;%(}4R_jnUJvdfI zOOZb|SaqR1b1JowcCPC9z;jugGcI1vHBQ<6>|D<={3;imym;^1*tbCbc<&2%pJ`Rn zJ{{hR@kt+vI@0gdVgD6_A{wF;+i_4^?$KB-!Ht2F_$DHtkj}u0D4{^Umu7dpo}Hz} zeTWyIw^J(hG~4Alt`>{0uY87Fg)&3>@lIjw-d%bjy)HcNN%68p$;|r=?#CFE>b z`>q`kmAt|gl{6&#(ipTq!fB2m5|P$17`1sFLuouO!ryv|(~vy9`^m^(f8WPP{(IF> z&q&VSGg8i&)iOy^y15-mG&%<(u1rxwFs%5@GW>Qr2U6c4nHXruKLm5gk&u%kx9$_H z8@!hkibkWm7SrLc00-=<4(IG+;ydl!xUF`HUq+(5_qYXWt0kVDBz9#4bfXLGX_qp@ z9qder4=1}3HW>Mj<7xVYwP*&RW5cgPyM1!cG8w@nJXzt}AnE$@eOc~K5V&0m5yuI! z(TEKRhQ|t9_}&RAUT#LxtWT3ZAK}4=LU^cPRN-H3UV}l+wkI~OJE-8k#K!e+E!eg= zq+o2fW@C~HZr2NI&7FDmXZ4R^{hGG9`nr$mX|tO5Y*q@M8xBw!gaFF5a?`RPQ)O%ZM)LxxXkF_kV4tMONTEb#M`mMrjbmAkC=3q~B7&MTM$S-{<${Moni$?zyiP## z>a>h?-ZH#dY;x-rcZIb&K43e^s(T9m;^lxA%wWIw^IUUv~a4+0j_avcp+7 zIo6#;-~2}Uo|s<=Eu?;GNCQ@|=68U5a z2}>=f?g_D=kyvufFF)jR#MY*cnB$+7DSpUn$ohn!ebaX3$d>H})Jo{uLz=YEl;>gL zHr}*y6#=mm-{@&5XE?kkwcRzV1K+UR@oFi2gOL&B9gIkd zOv0KhT!izqNDEEVl_kSV;PCaWK62ImAALUR-%U7*fzI?@UcyHgiyz}oh zqSoKLF0ESe^7*&UzI^T+ghw)zWsv%c8Ox`yem>c0%s+^4tJ6~(G5 zda}ouUv(XSnV-METR?hgn{fKWlpbT`2Kx$r3p^ET-b_XA!a!O|PIfxnfez^pprEuH z0|>8^BLxuF$h~O*A=wxZrVJpIYdV%_LQF1Baph?=E2;ep>z1zRHD(foTU@Wx>Rw}~ zkc3wj6@8!nZBc{o)3cXD(80gvUv*#I17U~!UGrKi&*KpI%CqNJRMl25oAzxhs%m+H z%(GH1R39n#_fbFZIN_H&=``1$by~Z21-kM&bR}DjPNUa zHz~q`LN(f@Fer$y6%1HRJHB0t2K94ugi>5ozobD)t(rOYa_eQL!yz6O8jPdv*&>_m zQt86D$zFqZu}dR&z;ZVipJ}&2TU2$QCOp4Jj2P9pPlK{%^LyQ~xkm21X;U|MY`=N> zoVl*-MRt#&J$nuv+O@klLVFB%=pE#~LQ1_D@5aKIiUAYgScl9e4lJL#?6q_6o;>== zxmR@BTadO5Y%S_9v~MuR?TC+ygjs`0R#!`LO2*f{6Dbr=slcfj50Yl>|- z`X2V*^sAP!4T4Yb389RgbKN2w32-rc$U;EKJY zE7wIdd7x2W+9K$(Xt7?%djzxWkT~Pih93Qv(H24T4rj1gfWCj0>H_;P3aiy-?#6Mk zC=`Bj6m3}=g|9nDA}khXGW-kcfB_-ek(A&_jZV$YG7w@f95kH<6p(y}zQT!#GYhO9@rBT|xsGz9O-a6>ZjX&m(m8i(Gz zl2j`&inb&hKNaKWKLqXj8iI~RcvE70Oj>MOScsv+qZjQ9Zz?HtbH z;gwgj2d{4!v97Yo);?<=E4}r}J@%#F`YtNSpQ{(PZ!`IF(>OJ;M;BBIp7~{Vg*~dm z>zEhHdDuNUdJ)Esqg?U`_-I8e{AOxtxKOdx6nPy(bio8n!D*TBtl^OFB#K4z%^2`P zDi)UTmem-6wVa=m(;*P$UlU?GQRc;Iyt9lqQO%_q%OoaM^Wa2)6SLl24UjOJDAYPHKwT&Wwsj(%r>ma^c|!nNTc_V$nMN9@)M zkQv4nDF->`e8yu=^FTh0>CAu9$Hy82a`W;ntMb}pJU}sj-)P@KH)>KOEctrR0VJl=v&b!YyQKECauI{Nufd*dTz-2>7e z@TG^15}3Zy8y|Tb^MJ<59u)-eOPP8j;*JH+v9wQ$c-hFOS+nhlKBc}G4Hm!TzcJ1qC_3SudM&$+L z;sf(K4{qPKZc&#G1D1@Kvf~z4*5tU9(PPVomzIs}*0@{KhVEK9T^lakcI9Qg;O+OI zPdgx^a3d4?j@YgQ1Plur&(+RUb#*W=mtoZULMdF17)J~?a8nC%>Xd~)#oC^Qp0)jMO^qjA_PskpY3^>4 zflWG5ic<)BXH1+3PQf}(Mt1u=Vu-2tl_D*LVq^%|8_2k>DAKh^#fiiJt}oL2@B0cR z`rD6G7fd8Bc#n(r-U9nuDjal^%l0dD@JAFC`)|sl$~<1T&XY?|N`rk_$d1#%|L`?dP1Nv?&z`WSv@U8c3(bGKfqi!A6RIi&LC`QAp_zf$$s#AnsD|nT3$}!3D4Y;-K%eD zPf;CY{<3YZp;Fat!+dH3=KI<}@?{$^-`575p4x!Ql)pL_lEfi>a@B_x zITq3h&lrVql1N0|<6?>Z`03yOI&SX~i(j`t#_!kvV$TrE?Rd{?0_}plXWQj^G4hU7 za@{VhqL48i80&PSuqoy^nNIo%5uuXbuY_e@Y_H9k_|&T8adSJRHnQ(Ji15pr5dQmP zpO_l4G`jQTTOg`o+I_OE<-15u*o_R76Z0V_FW)C8$tO8^`93*uddSJk_sQus^t~@o zmw$OG{tG`K{!{3EmOF&Y9T&)_ax>qz7tHD5 zBlhy;-Y)D##Bb=$VH3nDPGz_|HV)1VoSJ}rjzJv9^)RM;twtHb0rMO)RjS`QmvMb5 zcyp=@*^!o_Gw@r4a3sX1CdWGyoVoc?riI|0LPtOZop*7PUsFag$-_O zRZZ=^e0#gwH;ijAGNa3ceoHs~TLYh%{@CqUw$~b&*(67Zy(=dCkG3W`&|a!Ylx^+X zOO^5=d(X@F?WHmwd#S{id#MlJbT5_DQGell!C3eK2z#lINa9hyb^6E26k2flfl2awlvd{rj(l=Iq0S%F_0`~|X3 z%=gZpbeoU1L)MA;-Z4p_18N(jo-*G%Cf&TO$bTZzH=}q@Zyl<<@9hl!aPr}J`QG_c zq}s&+b;kY@(qn&V1(h`t&+MfACGRtGu0$Wa0(pEW<&-Z|%Y8nK?UD~U@fmcVofsf2 zK7(%20$IlUepqRrB8h2R<-P%)*SbrYalXF&N#ZY-GGo4Pf0FpSq|BJ_+n*%(cN@Tng$KR|xECFRHb0Qvp14*t54&T;wx`5_d>omMp(w5a^ubLw*iA$AtZQ@7~u9lB|H8f%yzic(=BsB-uKq_!2~pQ2hCa zLrd%RDX3j@KEmhiV-W3E{%sp|6*eqUS1t$1n9H$`%HijejG2G%IzGvm`3C~|QpU{R z6Ue`@p8otv#+<$?Fujy9^AG#@*f)l}6iD|E#sAbZyQ|AeGUjIv(KEhr&N$^C=M;fD z-+Y`iPF-$loWd9}5BlEQ!d{=~ge0_5tnndD9#bh?2T~uTOmDc~O0Z z_Vg$XMxaV6(zR>!EbsMspS{UmpJ;&mA4IuB72Gxz8iWh*RAG^23H|U@AudLxaR+;M zE(c#7q21cyugaAj&sEX}ou^Q}jrQeUlG}24?gWkb$kR};FO8)aSagq+PaHRnWP2)U zD-<$ulgQ9scwXx+<~^^6eMw6nXG*EiBxc} zPb6JLLOO)4So8+}Ua+@njyOk*ZsFc2NQ}G*f&0HyzkrQj&(lj-v3zp-1!b`yw_qCY z5z>R~b!{YB-T}kCFRF*$Q5`wtkLrP<9bd9UKe_~ZCi9cB8{pNm;(zEFrHPdNkJB7M zU%H|HDZjusfpS51NJFwKWId*5~#_JNOKnY z@{Np*c}>>mOgpX*e1+KK!Pw{B%L(Aa=SY0^IiBgo>G8Dog`AIg&2@&3iplZeH|<+J z6=RP1BtobgpgKYz>Nrn`5~0l-YyVf|;UD|$v7(mzM^C-(Px&qKIS=`gA3R>Ulg;|D zmQ17NA~96V6P)vj(0n>Z?!X;DQ-iAV(ZZT>WmZqfUA(J zN0uy6PcEi5^TtKVawI9m?gkVX<&S$pC=`sZz7!Ef64TUf{|J5hM-c)dl5Q6IbX_?< ztPXv8lzc=Svs3Y}c1v;@soDRh-4b~04}c`x9D4j6?4)d^t-DpWGd(3S zE-F04(sB0)d3HmB^&pfgNX)0!NyR;G*t>;;00X%zD>EZ4H8}~cD*De66>i~*wL(6^ z8~86D=MDH*>$L08Ps<5$Cp)}-oESAdmlP;@4c_CK6d&( zxd4EVi~L-HfceEfFY4n_Y(kd(ZGcMgmWZ-lmgZzT@L39IAi$egEe&?_@#%=S267`OhY zH(XSEMXK@%;Sb{WMs=%d+xkWA+7;DrOFDivjAH+=x8X!YC2sXe78q&;BHg672WL&` zo>#)SA(*99M{iX!ILGzl^LKBJc~B{F{X>7}+xaORjr;+u?ASnGvbmk%!^)FBg^7BZi5fJEz z36n}4xUJ(~J>6U@)(7nNw2~V@SJSUo3GEfpD%O5{vFB6TVVNX%gfef`2kP@ebW?xV zh8d4wiv#DCbEwB-2v@><`*!$4=ReTn>FWeUaiLGZavv=}3G7=z*}J8K zI{7t4B+!?-=q5Mi=)gq?E>%)uM2s2AQx7V5UBH~Wbxg%NXS3S3B(Ixq=8rWV8CPMa ziY@)SHFMb+>ch9xuY)01d6n!3v}uM?6W%=B(v1^nN-DM+YYGE5H3|c}*1{+a#SU8+ zTzlu;Da2ivXw%U+mjzSI9HtnCoC>nfqpNKzA9gx(Tl9!;u(ou??yp{%I%-hO`5o)@+xFX()pG~*Xf{6Y z*Q-Ao7dI`mr_`<$IxeWBQRC+8r!QUItXIc`q^ViSc1icnWo6BZJe{||5Yv?7_y_3` zPnV2)QgJ@O6bkn2U_c`>8y7Pf`Y@O!zM7<@L?N~Gt z2X@|b&&~(7UTHzagN*`@jJ!h|5&h67j>~Os(A|RxN<&=7g_gh#>bO4&^%zVMDVT&2 z;ztHlii()8z#F7+k5v#Yny|m=yF&`sqx)}=(q3_Rj#XU}87qF-ZtoIPUV3TpraniE zio5I?CokUrY)@PgG05|JojFtPnT@we`$^t;jXQwD!4Lwm26`)$&3B(8@42EtzQtAb z3RE5d(pyD)*6uYbwqC)_*;tp$=jpBmF$?+Ot_7-jD7dld24r6$kuBZjCI6GxB48~hCt8`g zR&1Zzd-kkWKgP388@j~fQa@NCu5GpN!DB*s4Es=B@Q!SytGf%`f{w`_x{0Z<6n6Bw1zIV!74KRRIkcpH|7yG8AB+>=6d`uX@T0vwY`fi&lF0NlaU{Gzn2VUyK zh}H6OtzoNdTx)peoblV+?A1iP=VZHm(+^(QVV8UFG!*BT-qowr^Assfa;I*eowf64 zZrx0Jt3c+XQFgi?9V;%fVYHs`?8_Boa+!qyC0r5&L8a(j;~kD34o5tE`soG5g)P+R z65QpF{b0>i-&r633MXob(+$qv`9--k(u&k4Zd;jF;AvJXZKc-J-khG8li#$q+GlZf^{A!ni^lKvl{f9LpPGbDMf!UMuS~&xG$xKx`v0c;oW%kpFsHt`mvNSFmM@iqTD=FitR!scLdrFc#%xp~b~wI}QKA7!9Er z3H6Akx_8#$hu{5U&ug>prJH`$|EkOB(qV}i zo;>2AC@4dFtPLCEezDqQBNx;}5HPO4&|_odrVHKEflIM)xg0iKOlSapdeCgt0mBXSH%4;cuv!U1I_N$!ip9qL5^DXsFHG*bplhEkgXVqz z;N6${kL~g7_?-uyUio|Mxw7TG_spHVr)=nLUl`K|>>Sg3cBjywZg-EJ{o=&i1`TOn zx@Ezt*&FH(>wd=_9h;Bp4Ic;UvK#Wmy`G3=LqCrsmEhZKlFMM&%kd1!qLe6HIBH-V zBhgVfEw+6y%|(}p-FvL?VDbA$dtyTE2#i)8XC2&Brk}wLr_bCktie~>pv~nwEFEpU z6q%XU5BQhbp83@F%;!6Ha4$Uc_L!xk(`J!v>WsrBJEAl+gf=8`4jXNa$rxcWxU<6; zM&S;!#Z43I+6ZhFM`T50rKQqk+6nPdksNp%64>M9F$Gaw6Rc4X3qiF}dro>ZY-(KJ z_8kVsPY&DT`N&(lgWHz1T}U-FgC~zqYE@FwDrx-WOMg|@_e#FqUF7=e47nbITyg&{ z_V#gt1Xni7oqY)0djobdmg7=IrG8>l6qmI?7fCVYok42Ij;g9%;*n?Vdg9M>>MGAv zyz5!Dtp|!IWhe8O>< z+q=tx?kzXawUQV;c(1*tUShpQZF@H?bvAF*Fc}xf;<}|*?NQI$S05YKC1*iu+l4=# zQ3oL}v}1GBb+YW!?&@}xf7g(I4@7^#eXHp69b_40yF)gXf*1;LymJ5)&NpdQ?_=P~ z8i)mFCg?VBZK(e8m4;LnneSJ4FYb~=zCGpN^1dJW9!$X~S9g3rSbkq2-&>4wEXR9q zj42?wEtP=<$thO*+XL!Ne|mlLsD*v<+SyGGik0FvTn}bVx^GbQs>nv8 z`)XfaU8zlz@0RbPd%(m-yxX*1z{VZfKFQZ!(l;C1u%4$==^0l z7)&6=QMu~g%RkxQJ@&ol=S%7X+crJ&$RnG!8QZ2r+h^=|aV_WR(3vws5IeZBMXYxZMT9|}5jlu@VH_SU z!;PR{zEK00Bg+UQ>7A^OF!Du+Iz-Hx1$W$2w0Cd$j&U;&j(~z+S-Gfvp?VJnkHS_> zhaaA;(#nGF627P}#(P6arxA*&_kwJcWO-oK7-C}AI>o@k2;X~u;Q(Gk8mMRjqbY1R z-6B7GWf~kFb@jJ+W}*;S2WWFfx8&c0;mr$ek^3F>^7G9!>A%5d5;Kn>Ft$+-k>NgZR^fy+f zT`lIfJm|H2eDAqZ4@LCKhT6m{8%Ve#Sd)CRcXOAabGvv+gF zm^Utov=u*ZvlohuUwwJ&hCaRTHY#lO>_-p0&<`pw!*j6y!kJ^{l|XD>{lk_15%7Cr zodEMt$lC{lbPN3zWq>g=1kFa1eN<#9&e6p3w}m}7PN z0TB!*Y=0L={W$l^pSJy-{kmSX-*asJhBaGI6y@TpAHTY3xITJbPUSlS0sbo?p? z=A=Zq2Ec-a?~tXfSpOQp=bQJg0TLqMcVRTw%K!j zRoDOg+ko}GdU=Ji^M$_p*{z=GoUy*EwraQmgStHmE;&W_$KGSU7X% zLe?XU&a%AGh?mE0;8;x~*iD7&h5Sm=Ob-h3H)CuJf~VtRQe#u;u6?rJavbbgYACHF z+$~E@NWB}Ac453Uj?`0s{xRjkUB~Rt9(zory^@*PZ%%6z&vQ|nICtNXTht?-_VkyU z>*?otQ`DL>ecN=5Z}MI@IbV>oUMQ0>KMaRmm#MUIyU7KBu|UMb`k>$LzzFBRm%)TK zz^v1CG=gDd8Zhf9G?y-4fVq(=GQDPrC50B2J!x2&xs{Rx|g@#+EY@S&-S26&rF=WwZW||U)#9H{{5a^BHWnX&ONrU-mT~MuRrwZ=+3n#HW}#u-5!*; z7s^Y&HIhnNkemcN6gw((;nM$QgF?QTJ0ikxq&Zcja$yxmlmef*_XgY;?Cq(3n=|=u zTo(NHwdEgo9o=Eukb@_~J-gIF5zjp|WqXSuUEeXL+m-f1_7;0xMYrLtTK_GA?-s_p z-3v#y#e_lg@aq@@={Fg0k5~2IH~9?+++j(-#ej4TsXea!1-%(3^&EFG(M9~xa1x+) zu%_Va8eP4i!J{_T-gt~OJBI8zxzej%P6c>n@JkI%qV4BP)(kqhziRl*fz$JG08>j6U@yXh`9bKNe`dfD)L%uJ5DdZfZxRlA>peii5RZP_K>JkK3(cwyHgk=fr zwg@4ehxxuVaw$p|mM9hExUK)m%(<%G@tMJ zkJ_W*i*rx>XXV1xtCv!n+rP**m$nfkiOb6AeiVvm!|(=21YLhZ)0%I!?XxyAlngi@ z>$>>bK<^rwmMZcrxZfiOy|%WNudKKSz@4W?1{;)tvA%4q;&QMVJK87|jmgv`{)Z9E>O={bq$K8V~rlx03 z7~XAjMPb{d$^y5mUeA)k(#Lu=cD1ONlIzYhD%!tmUwGkj`{(InM-0DxvWWcV1yS&B z`w8FJe?EBVT>n9{#mjc1>BDnIJiGN_5&q41(rZ{(`UKP^2L6G@?uJlYY;&Pk(Z%R6 zRf5PJj5*e_%ps(`SYF{Cq?CC2A)RR4{f+-Zux`PWip!et(=zy8l5C>Rq*Aqn@+i=+OnQzW&zs7Y3{;nm={>oh0Ax7}Fj`U&~ge z;m$W4sSIDDU!HRNDA&Jy@brMFB6bPeruYjreVmXcnT+m zG9fKuQ?oMRv(7?gqO-_JAkQTz&@+^@Zu8wGwUt>~A@~v;}yW9Td zr+o5jRR2CeJ2qF$P4Cdirxhf5T^90w>!;&9Grf+f? zP88xUOtPpHB{6}{R#QvMUrr*oz7s>M6iq@2!nv!Vh)C^vA*`mUPh6C@>2#F)4sP=6OcKvy2Nd+VW=11`sWx}7i? zvmp*6(Yg_()0NKo(gf77XhfueE{37|%H8s0wQH6mx^6T_Qy8wCk_1gU%uCp_1Y;Ck z7}8@43m0QyEK1M{JKMiaw0|kRJbmu`2OrzE_uegUi$^?Fn#=A~{Lz;apP#aMM3?rK zqxXOQA8FqK5Y?6SKli;U^gaw7hTg1z*c~gPD2f$3Sg`ks4HZF(y_aBDY-o%ni8akc z)1&E$CfO9TscW)nGME4F-1lY}(CmKy@7pZGTrTgO+s-}bciQ`d_W%A>+Uh~$rh9sp zmTW34V*l8 znjRJxgmX%iZirVlX&2iQ=|~g?1@R&eS1L}oXwSV6jldp(N{a|6s!fo>e3jG% z+9}=w_-j{r!6nx2Vc3jON!`P>U+SIH+B`%xfrk_2cV`#)d4XO*kKtA5@E1Uy z!Em^)ly#&05z^DFoA6mcLNnmQhd{F%&Qz>sj>W}HDZH|RfVn7ihk-Ii_k1|w1a-O) z4=W8+RZ;R_#RyvfxsI9`1-2frULR6iMX+JOUtRx-nEfqZ)A%)rdDi9*T02ih!*|QN=%rZ|_v|lslL`B7k+;;79hqdp_UJe*M(U@^$Tj8u-heHmV z*j(1{6K1L5Yxo&n!z)*#Y)w=A=g;r+cktg&*6)e}OG{yIN8rT6PtM(-H4A{Q`x4Gf zpazk!^>{20FPIez$Ubgx8mp?muJHFEzCys912ij)g*YM&pjiPuKL(zCan$ON){jSM zpC-gvptM-K9*C_1MvJv0n+0lA{(^6y_X_CU270|nqpio~bVRI9gA`_C6)p(jhe##t z82=NnxFbL($a4hgHjOV2O>ThG338NoVqZhE1c!qT3tN^QvUaAoYF%)T+Xn`dQXUrhrbG#c0H7ytRxdz4aNGn4;9Zi9sy=a~aq7xmWULGZG zRN=}d`)amst@-lH18n(aK9s#7AWLods%zI)p}3?9ThjcS@P4|3JSb}d@JS&*;TKxW z1hFgSI@c?&R)+6o0EZWoPnGM`7j`Xw@?Y#gB`dwc`?GiN(9Dyfd`0T2K&14p<5+9l zy}SVHJO$kNE0qcFs3&`NQaE1Lj`Hr@!`iX2@9<-lIH}D8R|%ub3V&^mjuHM` zs9UHG)NXWgtg$7mQYgpZ%s;7-isHvwN_HJt0((@~sLm-%vbqc(q`mR5EeO05n{;xW zwbRa>&4TCp2X2$z?%$`EbtLGm$L&b6AqPencJ0KG_pLhjjv$*dAq0aV)J*|>0o^IM zuYJBHO-7xswi0}|2{I~VSu&B)8EB+dYQbb(at!I}Sqbq}Tn9!18J;ZITPhOFmZpj|7Y4j6ER`THz3^4YQD_KkXOTPc6oyJ>#<;P$gydrH3J4)z_&oNms+d@kpaqJD65 z4eU;YPbl?+Bm7|>35P0zEdXx?;K@Y<0BGe2xOJ3xx}m?4DgZr`zc88lk106ZYJf|V z1({3alPNF7^s8N*q>=V8x0AQ*Y09+$*Yu z7%CkM1aak|E7bxc0xP8oCJ$dCxdVrXYAOI%SSa45eg+RhEP$fJIarTjoOZMvduTiq zHdJpphPFfeH-*Vh^13s;?gpQ%6ky=XXw<<15so5Gue^_Gm*JLC>qqc;_L>r7#DJAD64ENQti*Bg zZn6OvhsZ4G1KT4D$`5N~*d31VsqZ&Pq|hWJnEobOL;@7Zzd zLbbQot$TRI5l#;O{dZM*@R02E_^e<@9{ zvZ87L8D48Q8)%EmLsuwvv;{%yU_ zB*5h-9NP$a^PHSv?5HW4r%mIL?j7P3b4OAu z_xD~A77y#g;+1;#6`cvPll81S;e2vMot!$DAS?CPEQaOo&2AHSjP zsZ;Hb2tuAE`HhIl=|!c%tXsN}X$g7{112yXygo*gtN}7h*@E;TaK#A47L$oA>@&rA z2x6`xYJaM@YpY z0|I3&LuPvi6u7xAcxm~w|Bz&BKRIW0zmc<>4M;yQ{|(Mvi&sl;%-ifC`M4I#eY#AD zPmSpoGvOwS*ok9$+$S~o+{a_vm?t*tk5us%R?2E0@Q2L%YoY%z#NJ?Bc*z?WC!*;H zh6caFrv`W_kOJ)vVLyk*i_Ly6)+tw;jCBgtbh5OEcNp+dtsdFe5wnZm>cC(MuuTX@ zAoPatYMJz;Fkuja4{CHw9Arz{yL7&Zr-Apwyl;nr(`ebEypNuGi4A*(Z@hY{s<_Y0 zsLs*2|JR-R9!mrm*t#d+qYJ#Ht;8mKZ&RgpJ;($Fao9&?pwC~^($dTWdk8uVuspl> zk4-(TXYOz{4+BYhm<(ftQJ%a`Y-VW~-O6^Y>As5JY+-8Ax~ttbEftoN@%=I8>4yn| zj-!y9h!^;4I$Jv0{CB{Y0O5ioK=2j}|53LcHC3yUQQ4ot?CR*YV>d&PM}+i^t^TMg zC))jUWG|bv_*X2sC z2IRkli-s_CgbqLtAz2aW3RIm3av}_=z`m-iwv31^QSM%qqv3A@HE<_4!8b|V z4YVUrV>b+NUD)-G%mO1oMYs_s7eUlXzfjE(3g{F%hrT%r6u z48Fv(a>28*!Lv;800UOzij-ayXa=zc5-B-ROL@3-2oq3GB|L5e144oW!UDr$Vj_JF zI0D3{>a8#-Ej8YvR;otVvXw7@$Xvi-9bKN^l-zU+&8Xe)6WyLql-CE${QCQ9E5P2 zISQCYC<`TX6ol}4@iWSfPpP@Gq|~j~bB6ZXG;i15t*alf%hm&OJntHH?2VC&?)qo< znA)P_p4BTBtYpssh3cDsX7-8~rin4ped?@7jWpHY=msq<#;UZ_9F!bc(J-@qq>RUBz83e2((f^2x>-v$=fS9$P^k73Ku{WH;zSdCODUf z#k4CMKDclUl#H#VPrBK^QEg$atJ>ivt#sX9b!5BFx=6q0ks8A+|M#DM`u*WnX87|@ z{wK)9xa#Sr#a8J@yRaMT-^X|lVd>ER9U?k_YMo&KweWkzcZKFj@m;xLRGz1_(>y8d zmmS*;^X=`|XDI!J3?}Aphw}dKm3Cp*7wuI_yO>wfZ*sM;+CpXAw25ZrLFk(vW6M{%}P(4_{v8%9po2E02>Kp4XP6PP~9nQ)2} zS6i2sOBTHHitsgM?fHufs%(Cit88wetF7nwZ{Xyx*MVN*<3Xg48+|<8z|m}EW7mVd zY4sBFiYZbW3{?uHBu5FiP?JBf6)z8Wk+DCB1vR{1(i{bUqEH^)cZPEL_G+L4G{1zh za$`yyC>8bO>J(6(c8N7M;v+LgYQgZui?^>zj+T9ZiQ;Bc-)P1SljCYeCJZ3d2OhSzg zs1W3HZ72dMsK{<0bsqQ>Jbqzm(~<2Jr_3C|G_lbUiRQ#mSW`XR1WDxauJ99z1`{Ot zskW)O6rt%t+KJQ0QJOl1P?njqZpgiR!)sOFLpb+HFII7wu>_^eLHpUzO{n~v9pIxZ&Gy3mf-)S^Tm@4?F|(|41#d3 zBJ$EeWk0>R1;}X}+gO&-SGW@3M8v~M)IZ)F4X+Uht7&8uBsmN}P!VE-udowRTm2A_ z>2mt|Er<4=Id>72cDL?W*EF|F>cEzI?Mba}#DRhphdb|;veztHUp%+`r7N4BO6J!l z49+;YtIMKJZI^bv^4+wuwX7fBtjH#VK#S1fOmqZAWGn142$E6sH^`B;)90YWfm~IX z6gnJP6coDY2SGQ}4k`=n$xHdCtmT$NyY}*}YzT-&k>J*fE~gUs!Zj2K=1oBk=y9q0 zO88Mm(@u;a9f-l>lcWczB&-OuXhx%>unEW{-QphgjrET-k+H)hyjhUO>yaggVipC_ zQm>yeJ~%Nd+^f0dHcMS^-Oz0#79743?HQle!+%jokCI<*Nx6+yyZW@emHXc2wL3(>*{kFpjMJdxFrqd<9{4LPm8!}sSODAr^2rSVTNa7o+@Op`{Y`i3DXF_V z7zo<`?4d{(ht$rvb3CaQh^ml;0%LmKvCcyMWl(SmOO{u9_LSi zrNaG{%*eoTgvMXLxs}v1X=$vO@0L&a^1?xVI?U*y*B;mDdN0|MnY(qElqnVM*ilKsLr=IXoO0nwc4&GMp ztF2;Y0y0Z6C`<$M2<%L$G=lOf=}PHnX?f{CCgk@yK4{YQpqug;Tc-aB=Omu;27U z!|5!9sd`K(;Q@sG*>6ZkO-f1iBQBVrKykG|sM0mK{$QANRI41-m&T62(nY%_qsxB8 z|5sIMK$|nAs2813U$c@FOqQb< z#Cj28K!^4%w&Kp4NBQ@x?b+YIKgL?UJ2wjFF{8=2pv0&MFKC{>N^aI7Yq&Jtx?B2; zeZxb=VQiwDSKK3z__Vt~k2mfqXvc6kLs}$;7vNq7uA`SJ)JUfpt&jx)UeE;(TaCY; zPq1$=8JHt|jAm!Z;kd{M^))Ez%k04`UdYnv@ayJaDaW&ABLrmzAtt zMY5z?zaD2CaRd154Em1>J!rN#^Jnq79OLmLK20u6#Cn`iJ`ZZ9P&XfSeHRAv2Cnbv zap|V?6xn2mgUwdb?1KyZ`Jc0X-GBMx53)W!;@}AX$DWy0clqmUXRiN-{NaHR{erWB zNr%+X#nK7(G-f9K!vu2xrRfAR4j~4T*d&OKwh0w+jEF!YMJ!B0h1w{XMNDLDvfUeY zFCs@oBr1|}gD87-2<=C4d&CZJzjVXVWqoHwb&CQ#Qkc`X=5n-`;}v3kH#YIeQfs`h zlNezqd8;bJIt(sH_Io4^V>pO0#Gn=dy!K^XH01I?NIn4&(lG>h73eTvSeF#T z5+zY!kSyV)gX+xsFmlqt6Jk^wk77Hlw=xm@M!WeDml zv*k@~?6NCYmc8!iOe9f;28s`Wo)${TPpA)IXb3XEav@OafK%#=Fq62Lq#`f{aygj@ z8O&yI5DdW_3s0v=KVg9+(F^%8ar^;^x76y8LLM?01T*l0bnUQV*+;5YPANT8!^Zr^ zLaqO(D(Nt2)5w>u4(}wNpM5f&|9mHgGhN|U+1U};mEdmi{ zUd-U+iPZ*$K;;pqUes{4orTd%taiQ&P(3U+IbTDQ@No-r53>6Rxq`PkF1kasSqjCK zkztLz;%gCLrsx!>s_N~UKyHR0y39XtYsr%BgjvaT-vGmT8FLwmy_KLDVi|~lO?Mw= zK=xbgLl0(vJ7j^EtAzNy0UCFAHd+*#2LtGBT9c$w>mqV;r(bJ_@l~Jl?@zFotbfrs zHIp$;@<$4Se3n3128QOOV_>{lu#UTE3@(HhqK2DrOd_id6cGT@8r>KLK8ucugl95@ z8gEm87uoI8Q_>(2z*vZU&txsM1=g8yI+Mn?f7iceS%$(q(MYzk`qK25qSm&1>6{v23f^Y;2#^)BqmWwqyy9sa4@kjwTOa( zpQLw`m|bwhRcDa%dbQd*FK2$HN9ytECvVSwwRqXB1)Y!X>N9-=4_~(^b79Y=;|CsS zt@~t^xpz|7po3e6Osdm-e(bx#rOf5+k6H8QS_LP!sO@(3^-hO(Uf(lnme?Ejz)up8 zZ_x`fMT`d3q&?t{3am>4(oQboDhh;?At~sVk_J=F2IPYz&dC1|f&#(8KEdv;u&ThV z#FnnaKq`_R@d-)|U=lCDT$u6=JVxsN;{9QT%&qX|B0f9kTz>n3%|D#DWEp?%i;|Ll zg_bY1DeH=J=7$A;JH5=J_4M*9(d+pe-A4}YQTy@0VSV}rfzdJI&wf_nX%>Ju zD8%I?a3eUsNapIxm!-Q#>%G7JV(+hJ&#mRJQBAhjd9M69o*S!y&Jj(SYhXmv6DW#J zBEc{t!688v>_Q%EoQc)M#>K$dTQ$y*G^bwM6cucjMv!aM7CYR+`*o?pxT8MTs(P z=nV~PkA4F@3;ob8YM5gFArWfJFcu?4>Ubfy12_vbaK{+okUlQXK$29GJNdW@BR~Ej zmQ6QJLw7XdgOdBqw~qY!+3}}mZku`K@MpiCcylWIf%{T{Ek2N)6kh?>SE&u;fR3pc zhl|Ly58pWWGNP!IhQVJ)V~#+q1_ZiB)&RjSWEI4S0@I6*5Sp>V1c#2c)tQS%^jwm)^!@VPANT6l>)Zlx>0j?iQ-<~!*spoh z@$I^GT0UmztRO@CNxf%m&l=ipYFeksxhE9bZ-NU`u5nLIw8iWJ2VoeWNqt6&DC}GU z|Im-z1&kstm_a6jwSw+uNR~*a@=djy3%9&h?dHrbQRW`@L&3G4wTDrY%x}WW9pxdU zLx^|iov^M@XRvd5`-1|%%pp*zo zg&`TFv4pTN#TE?i47W>4mkC34umzQw@JnoPkv4<+Ht2v@3=u?Z_JEW^F~o?y`1>GY zGD$TUc#5~wzCjEzd$PE^`#d)T@B8XOFBf+4b)9>C2=T)O@cNBqKUBV#QoD@iX7T5( zPxin&AtG2V^c zn~CpAh6x*UL}%fOT^~nm3nG(itNHu@_qdLudt}74YZ~Q-%27z+x{?3$^xe|IaZ^og zraiZp9ml(%QEG?dT_Ud3*F}$@3Q~rl!!{=2=y>EPTFaey%j#~;<=;<)oZ?2t2BT&wVQcDN?@iX!HBKQo|ch^&%JB()) z=CcjvQ@n@f&7Lgw(f2TtKmomJ+xYR)YQ9_>T3I_o-dIWJP~@7m%SrCzsbnumbAi2sUwoM_RPrW3A8G{ zBYUDnMqIQdIkT??W#YLu9rb?>9r>edCigQ+tWA2^A4d-TzNUGrudUR3AIzjc=9m5Z1HS+ ztTgdd2n1v7gK&H<6QUf6b8y?puJx&6u6vj(Q4arcdMv((<~-1R7tcjD4EW6w&B9q6 zZu0-1vC?-zdy?{&Do65dL!ficKN5GEt0u}4feTG4A(t+!0=D27tPf2OiF)1&C*J&m#G?T$Z}cz{zm7HA z;-pnWlr1q33I2!PJam2F+-D~6acu5upHE&h1Dxet?*6IE_VvL}&3W6rm9u7Jx|ZPG zsdzWVNShpEq$epc(s)0dqHLqH$4HyJOpRltnSyc>zCkR3u5v{O3CVc-r+?qNFF$?2 z`eAmTW$RXefa3=*UO%O*CU3&qj459@9#et2=mL7Wf*xD0B-pwd#z`yFt?EP#b0vBI z=QwHh)snkRyY$0K{>eirWyAV@MZLCyl=FwKmF@2??_SAU@HR2yMlT*mbQpj~?*knH zkQETigSzt&6%Z+%`Dl!E1BwvKgBZ6!bC4N-8sJSfI7+510AKtQ zOV{qW8)7YEI(H07ZDMvZNv)#e=PjEL0#6)1f9WZCJOA#)y~^QnvrHYPU*E-!@TRZhk9yAlvF>5%it{4kqkZ9o=Z73+F>~kLK_SA~LtGf5id~VRbyYuGW)uyy=Kebunk?Pf# zjx}jEwPVN9e;=;;3zO+cYY5Sb?1q{^>_d2oQ_VSK!6T~_OhENndO&ME-heO#H9{Je zAA5wf4RL^H3Qk5k%vDNw5rlFn`N4NHKHQssJu_#;_L6Eh?!kWctXk1~UFUuoFQ8K1 z2frPDSZV1nW6ZHjt4|zk(sFvc4w&D6Vcn@_vtDyf+#%vK<@#3ZU)%Djt96>u@4Dy* z{YFMDcYFrwj#;09`+p|%3_nf8e~M4W5GmxC7~LcB$svgL05UG(lhIH3!ck}38A^Pz zzmXw#fHA^9g5+T}(%p~w+2WJM#SD;(z){Gb0>uE_rLYj0T{_q2K+(e8eRU;Aj+D68 zoSK@IIb%+*0hpRkdEKGkD~j%}<;mEAT|anO{_5M;KK|tTTerm6CSk0v;%pFa#o3_6 zLm@j{vhZ}5#PX4O1b3V&L^WiwNFG4vH^o<2|AnL@Q|cm--)_>wOZT2*E35g}U$DS` z+?~B}D!VrL))V`otnA!X^V}jWChzvv_`)1JL1$YcF+pfct?^=CSGK3X@Dx|SlEJv+q-@SYS)g&D%we! zyf2YViSGd~>tQ{>0Y8gPLe>9|QtZu45(M3ZRA%E|p{6kV7e< z_^k5^_etkh46N)js6~Fq1&h2{1)rl|H?`BqWSs+7wtXCo*cD-g$prpCof?5(uS7vO5zm=EY=kmCVMcER80f>6NgD9 zfxgQ-hzgqpyR*A{em(5=eXZSA+`vwP5R{g1KH=-7e3r|Y}=77M_J`te6%!( zO&u|UZ?T?WZ}VpKg_fS6qX+1C8Fc*jnBS299`j4H^EWZSJr1)K?@2G+nSX2V=FB~r zTlVd{FZrG2D6Ga?+ey2{TOmwee1SZKMoXIgi1~HXC&4}?Vt$cxkRsw?b-^KF{~_i# zM8y0yZ4mPtjZ0Mx`8C@@e#x*cKy|WB$Z1*pSA_ib`=gToRsC%BfzjFgDBEv6Be&q4 zwj6wM>h-15<|Oy;txp>G!w&;5{PEe0Bjcv~c=sMRymt{Bwtgkwv36%cX>WJe;JC1a z%oyTH1E3qu5$NMMOl#dU5Drwaa9;S!4aYXSydg zuPp#g;+^_Z;7eG2=&^_Q;2UgQb+Er)0$=ik2V%TthQ%cS*@j4Jw8ioUkt{u0=<5{6 zOBw!vA8Byyg5g9Gm0&aILf0nP47vL#y~yPcolvJcEK~$y#fQZQ1yU3&O4WKYPt`X^ zNIhUVVoiJ@SJ=XLC8qdNiF=Es%vjT*W7&*pr5!r0Sr)K!uetZtvn4A}KAT}()IYsd zzy9som{3ad<&~HR3fuje&tX5`diT9sw?4RY6O-H=`qxv~|AAy7 zt;b5S5{=_`vHxA2!I=~t5}w%<#cPL6DdZh@LnGJ}S1my^1f9Ne_`45I)&M#^cjSX7 zrxk`~+;|Ln$_El0ED%(z(^SOp0zbt%k)x^J@HA$T_RMY2ybUya5xz_Q;{q3h0|i{_ zlqf@mmm&PomKuyQ7%G-V3VO5yWP(0NkgQc{MOY69oJ=JMSQ)(+dzJ7w5y$H)-i!I5 z`;^`cokIs3sG?HhO0vD0;40O~Dee{Va^%YpF=RmK5Gn+e?)b1#ty$O^{w!PiG=Hk~ zs9|6XL%;r-Rr6r$EA0O7zjJtGg9bnb+Ofb|gr5Z}ZEAbLs{u}#P9)44(7^yX#L3By zm*Le){H`+=2sZ`>t4JH^LLvbX(oMGOHGZhNx~i(0CBg#$aG{i@0EF5ZC2twOD&bmz z(|O|EU|7&JksSt^xTxv{%o$n<4(*_g5+lQ$gity)F9JGxC>w+v8A)=0GEI?eL%f#qWwy*|t87O8ie56-kw(Y?%$_G;iq#J`G^tX7-9V|5yEs z%9(&sFs92GlY-HZZ(PHe*#81I(pWH(mI4qwSfOHq4r6Ey{)Nh7nn?1T#g)!NAxL$@hkL~*?_6yCVmpF^XZQtX^p`?*#mDdeQ}N=N~MyQaxE{U3G1FaCo7w@%6WEzsPcT^z2_YaR2K0 zw>u?`Z7ucayfQms>xf>1M~eM*5wW|#XfaOM1f!rcM`LtaZv!q93a^7o3Q-&m;~J?5 zvw~TeKxRm?1(~&ou8WI_j4=9oxM`AD62%$dTJgy~H?x;0tU+a9FbVSWoA7X2+$ z>cW|$FhrP3fh}YI61M#KI9*(Swo~E=1~!P(&8`eRz?aJxfHw=A4q$U-?H}~)Igk}P z;MqOd|4pYp(@W|H$E2@^KO2ST;Nm=*t1C^pK|DX)Q~zkQVNR9~_4$UAt+lcn-n z@s>HI92gVopJFcZL9bE^2p*4+qrnnwR}e8n(yx@)z~PZzV~#>Bukd+P?jF|cI;$^+ z8dFpen&DUvl@Asd;)`MrQ7rEu@ji;>#pDQlS4lsp9%cuWw;@ARa9(g=HJ(>Hs4~== z{ZKmW^CMe#Tv>>n#l^M12*j6Zw&7iQc$bNEUPzn`V|gdre8I`F*DQ*idNBXr~m zfpvcs!s3140lpe!)%F4KfDML!pj?BaL2jMoVA1gYT|}t z)ptDuNUnqtPV9$0WukLeGwhN`#{A0)CBv zQ-acvb^uHpX$D9^!3T=WCEc44H-m_alGsbSBlCJsYMs;e$mmyp@F-c;B6O-xK!LB@ z)boq3z9TU{Z%TzL`^ms=pQ}!an%uk#^SCE1&g+n$oEqCTW@7mVOZfLc2RHX-A5_<{ z!JrKCr4jMB7!%~nQD{K?Eew&=uGHLlNz!X>*zh)OnXpp>d$rrWnC3nBdAy4=684|t zZy)dC5vD<$-+zg}MKU-F{P2FSx|9W<=kJ&A+P_kbGi{eKZXRr!yghr7w{t(ZgDsyXm*^Xj#s6=kejD+~D423|aQxFwz4A`=z8e-g$D2nzmxh*R`+ zg-3S7*jsu(GBRkjA+|&UAc-n|c^i?>tUl}(R(5D|b{l(&ulD%r((d^Q2Ceqo(N~et zB5Yr;yO)oD)>Ui=7}}p+WQ~_T7=O!dXvdDg*%E$$be4o4AbdW4!24|N0{bqfIWeep zmg{~6-?<`e+gn6G;Tt5*qPOVxo$$4h$X0=5Y$@1h;hL@%3)QgpgVc+iX46neUGvh%-{|iz^*r=O-X9;l_C4P5 zy4%{)?A6`+W}KZ-fja_d!q-SC(S@OLp8UljW)thY7_yTK*4dU}7pF`o z`FO+6NbxzdD`^nWu+vo85~%kAnOwkViVYTYZY?8bqM&VuJHYe z?Qis**nQiyimHQ8)UciGw{@$y=H`u6FHRjkYYq!-)3QJ+6 zc<(RZC@lF8U@GPOEbGgYpFR%B1nW{yFX36B7jO`@3tO2ON2=2PZ>3$S9gq1?eJE`6 zp|sO{L@WLAFFi-`xhS=_pUGC*g8~khb z#{yQv$Fto$X90I2Zx7lVs0)v?^%3NhC(uB#vlNH;u_i{-Qpt&>M=m&7pBx3>YMD~# zqI!mm5GxrvrX8iEWbOzVQ(;V|Y0)e(-W(nn;Nt~oGKRr^Cc^P;(4@)R)Hp;>IIF}V z7pvU^2YM$PGI?XK{@V&NM@Jp&+PZmWf=+i_s~@y2Z)kaRo5D8-^=>t)d1Q9GL1Rs8 z{(W%Y!}aZ(NqIeH#>GMNQ}9*# zftn4b`WRd70o6vH6hxB3T$nf?rh1lX%r&kW0 zJT`abfhVSKh`E^CzWed*7zt?Ji95InXcjTGYAmol23UoFFcJy{wg~~CmfR1#{Xs8L z>+2XzD=4IxE*O5kVojzjS-FxQVx!*ve9^4e4pfAW;I(XLu}aF!^)dWSp+oM(GnvMf z_6TT+CA^(tX$ixFCBY$<7Bt`&1U(>@)-TpSs$ncGN$dohI0PSyXj%_BCnKV1WRP2g zQ8?;4^@w7GuWiwk72yc(4eQbB?*&v?wvAqP>b9kr{Pa71dd)&QE_JB zXomEm@L*WH@KEo(jtl$sU7NeDWPbiFAGR{%h57IQUJ5^pZf&PFYtsM8bzfd>%Hm77 zF2Tb5nP2s#`?BC$j`!HCUQqmqMJu$t|I z%Bq{;iulL`O2;-l&(FyH{P^WDecMm%u9Z*9+QKWrPal=~R90HA&Yjxh=;mH?+qPcN z_xx8=6nc(87VL((OVli~ct(I2Gfc|Jz(Sg;lKv|Od=SadW(ybtVeRBdU{hK_b{^(3 zLV`^vR_UiH`p+GeY+#9+gqT?Wm?&{znIS4d@qcjiXsHJw9iU0Mv+L{p*Nq$JUWAkO z7dy+|T|8)6hY^crT$wxLZg#((r}`~exp-dVpV)0Tf_aW zuR}3zFD0|d{1=6NBfVwN&dIrGtGITO_) zDG_my5;%;E6f%*Hn^ekE=@&*ol1A#SeySM|y=0Z6!U#x82l7tF1jhIQ(}ky_$e2b9 z1+`vH%AiHyJ9)Yy%&--*{i`mm!sp_L9px@BpL)RDpLx$|XxV_id2y$=dnC?m+aWJG zF*l`IPD-XWrS1;9!kP68_Rfxv=XXrw-bUR^%R3KTlheccy`^XNQ`_$X&y{f>Am2LX z#Ehtk=9)j1AtG0oP6_}752qk3@lB!fYd zrz|R6!+EY`4MG++u;wI8Ufak2iICdOeUv{}9C~11qnu^+dEA06>5u%e8&jQ>FwHJ5 zBCt24&m(^c4j!Hm9~G(5G)anYp3potCNeH64nsBrhKe@FdEkPDi;yDXm|Lm1Qv-jr zxF}i0p;BO!C0O0{-P|$b&(CL}tt}@{$jo|AzkE#A#4cGWC#LOxIbiG!scP6%7gnOT z9!ruZuAMrcJsn?JFm6kZzk)+us!nX0pE%&>YkZ=7s`?BY%BTIhW&Gr4gie9lyx$0& z_X{ECML{QAOEz@mUnG9}20oKr%#W~H4;*3S+=GDW>5fw+Ea&9UQ2=&Feo^%y0>W<| zO1(Wqph37bOmc5j-=1)$=TpnCTshhEOm2O^z`EH}=RL(A&YUv4P%#T4-uegC)|9MK zoGHytSLJ2R?lRF@WH zaT*kR>Jxu(g8ucK;O~r28|q~Zo6RiN7IPi*Ut_K*c8JW5jbg45v-STGb8R1(6v}U$ zIKkSUe4GtUMU)^T{GB0<^!A)CsVEGb`fa{I~CrI8~B zU&r2*5c@V%8wq}5goIgU35f6o0ugHtko8&*6dH!^k>!Pe05vyyv^@+P5PAtV2%-W= z5y5E&SIxKG!to8e35x^uC8erC*IeK}8WCd&{L04qr!e1Qj(Oov}e;OEdm1$@A@_4 z!CCJ@?gEawx&Kh>s^6O)EMj{mS**Wx$bbpHZYm!lFwbBW^F+2+fq5e5k4`4fJDZ%Z zXxB6haUFw1RkH?O0ojNs_pQ%_hQLFzGehnt^b_mT$F8R`a@w!vE!lnw+*ZQ-01k^# z6xu_Jp;F6r-$4&~LC6QOkc|dd`ceEg!n*!GemmsR`0YdlaR2Y(w?R0<0>NiejLl5Z z!T!!cY*XHzmb-VH`{?Mo=5vaJ`1p|4e@^gnHijm$_wo=Kz|NaGOgXoft%LQgT6|I9 z(zo2ChHsyj_?Me5PVbKB&?C6Z5b&)XkMRXyI-^Mg4)l}^PWlGH+jJi}MsLHv3>H|8 zPL1ByXd-EFh)h@XFK=Ujw;SnS2uznqdcy&M-B_)SYKF@R#m^u}z^vC7WJP9m@vAJV zZr3_kvJ|fiwGIhkZ6OA0AAA6}uJ3|JgY2`$zS~y?Jh}7isZsfUY(?!1-k0JZQm+(b_yb3c~yXCTn7Q&Q#h7%K()U%65DF00$XTM ziwU0-z+*~9z7OnHe0~m})5T|Z`phVwNoV>j>Im1@HNl?Ov{CMUZ&C^330BgK!59xVFPUe`qFBnKMV%uPefN@g`{~bw4Ax-VB1t8GdCpsoJ zO78@?pi#CygTRJSWdYJc9CR1$fZ3l{SARZR`S;?snwo99kJW5j$S1H}k0k4R7nK#F z`A7T*{v)d6M1H&hq+u+$*Bj)0eFwCQdKpxS0{4Ckd|nD2Pp@fv9xMe%SD9FM5*n0hd$C8AZroVwz#q3-x#N~xx06#XAldf@_J%gax(r)fsD*B@H&XhBB@yayTX zF=v)WbJ!UjWDSr6#O_0!g~O4nX1qc~1Z;qi29YNWeHcpO(OQ!nETEVKNPw15dl$5W zr;BXxYFBcO3vxoZo<`&3>V)JmS_6FJm7U;BI|1G`@I)40S~;-xw5+#|l7nhj>hkvP zwI1KU-@4=ALCm4Zc_?yGWi+InVUqFtZ$P`fW+>)#6F!GwEN+PBcX6XP;+?jMHA4Ip zuncxB4GOqO0>eOj=#z$xwf|b~n|r|x!SqR%4NCI z+JU_;E#N^LH%Z}A$d*kUk{xL32O5z39P^C@z~^T8yh40lDCQg9Zz8{#QZsb2GT&xs zaWIAJ4H`XC8Br`VY_XGEFyZ2eC6N01_&(U_$RtD^X>$bGs*45(1(=iyqZo*vx0@5p zv#2o&BZ#Oms-Xh8X}(Hr7++YjDe~o1?Ss0Xj{k^I2CHK*%Zc~x%ww?FDM z{NGLo4mdFckN(bF-KzNK^2xhzTymDfYd?3s{QA99Vyw2Dgti)^!5Ay{Jh*RXi?hkY z6$%Ja=P5(>AfvY?UburMP$;hlfvZ%Jhnlel#65^_8;$-tCyJp&9xlS4{Z0566w}WV z^4w^u-=A=1&FPxs6=%NxofWySc)bV?CE6`DUUJ{sb6zL5B4Nm1&vC1fFQbJX37Sy9 z7W`uHg&;lNW+Ny-&u|_{uWKq|;gIzN+g@xDBl#*|P1xA_KD5VunQ)x+_9P%lgY<+3 zCx|B~;o|3G!nR34J_|3@oT5~(2FPM*t$fnFBx&fr!zD}jsGpXXFyH(4nQ!^hfAKL( zR~^-UjcLB~BY)6gMP+B^^(pJmrt&TP^r!rnZj~!KFpnQuGDyUpQuy~u*xm3MqF@Q% zLG5)fU~IuUY6t(McI&UAop5bu(N27m+V_d}ZGuNp|0K2jWzk*(J#v8Pk8>yb-xKZN zJ=n|CpY}362R;;dFIHdaFP&D~?^3(M11UGCcz%7(QgJ3kJ3R;OPWXngdwSN5VG8Nl=%gz7=aiKx;fMn+uS9y~L4K5j^=d>q|(B(@xEVa|zGy2|6d^ z`L3G&%JYM9(!e8_u83e1l{+9pR+pf|jtsK@mYRy|yNFU_pxEvfh2sUOL6PO~U6T3T zo!ZEq{GRqEFP8kQKeEDy>G%W8$x*QubhnnE0rd#^bg>_OAm{YCKq=t`$7%xon$mg* z#9LfrN}B&B#Ii5}WPJzEvUQ-t??J(CCLvMzOBFwEuc_I-=UB~lKAo-bf0iXb|2FG#@hrdg?p1#0s);YtcDCPhKk(0)nbvD7 zAE@nv`7~>gg9L|#BFcr{js-!?m6t@v0iMs`F0pZxMh|`uxtl(Mt|fR`ylrwHT`o5a3<0`O;;{l>os&$&n% zRHTl$CE`THfBW{!gko7vQYvE4{%)Gvy?a04jV zlacsm%64hAb^mtjR`K2xyq9DNSK_lS&Int^0Bw90J@9H-!e$oC%tQIvwoVG4#ZLC| z_Mz9NusEs&C&w{*`zKjPCEv5}oIAoi2k;7Q^)smY*4BChK+@;6#A77H;pU2W%iuW^ zEFO>wm^TCh_SC%cPaoGV4*w zo{MBy#-MGhuwK0}Z%1(#_-U3XSectU^jexbxEu5ewzxZdWW80Oj9e8IkkHPNlwnc< zGa*~Z$|YiBSLAJu?bhIz;2@}M=HnMBIF=HV#K1o=DqC%{BDYZA_;w-uvon0~b=LFU zw)00vwhGXGA?sRpwC=S2AbHFEtXb^->XELl;K3ncEa1)3^B~Y**lvZG1AZgMNW50G zgV!4H>jtv(-{rk-3PUG>{pb!!^f7s_NdRpmNef)Qu#?^UqwznGA>DK0I{-EkH zWpFy=+Uxg%&IWQe=2s(wDf^$i93JVV+R0W+8 zBsEYHQbec%&QO>rp&Q_*><^@qYgxq~RByi3X;`c6bLL&0k$-+k--9_h!=6Kd*lqmK zH~jg7xgBOV=`sC}*KhwmrCIu{F5Sd@s`H2afV-Y~8g^+GT31;XQzbn1v~`t|HX7qm z=|?|!cTm5&!pHVgWRX?qw*&nQhBbU$U28l|Vo%q#rl;XqN}pnTpK?C4u8rEKjnbzM z9wN|5C)xW{SU;<4LVcvC>zb&2XpcMgskDCZNT2?V`mE(XkMtQM`b@FEYaI`Nqz|M| zyla-d&wA@${E|WszogRRF8a)|_u0TTjr#z_qvw#!geAwh+{!b>Iw*5e-@cvCs&Ci- zTX`mAqZhbx4n%*-cTe+-_8as#np<9q=jYkmiy`IKx6``3Ci*vQr?q(9vAr%_Jcs5V znk$~K_OA=Ex4+q-y}!Nvtp@F5L_5vx+YQ>?L_5tVG+~G5yNh<3Lqy~_w1aBMvPAzL zWnWpp75fUi)V8mL-09e--1bt>f&UNs(Absdth3H<+^6o0=tJvH_b2v)RmjSZ$nM)!*?l4UJBxX!Qs-f7 z<1y9^Yt*OQIPCn@G@puYv(*ZpL)?dr zb)N0ked1gk#eO3UNjwwtU#wXv`Y#miwBNoJ>tbskW^ezeV>?e1?KGd?HE3^ZZ~wkQ z`v80U4-MMKiguc-9~-nY(N6R8lViKJPPEgU{Os6Xw@>JDL_dBaK5V_xcn{Pa6Mg9H zD}BnXFA6?PXG-P6{Icjn<5K!mSeHJscIO)PskF{~q)%a^K5MP*9_fRX0!Bt-SKhVm zG3O4eias;W%DMYktb1b}c)Rswu}=hk zC-^X)3BF|G!%92xVWnN=!*x#!otftUh&uo8iurfA2kT~uJ~aPIpK@zu<9m=(tpVT@ z_C6K0zleErxCeQ9qdt|7=`*=epS9McN1jvIsLwiUo5p?W`iMR>ZyD;mEf(`u(x`pA z72FT!ZWqquFu|{?#XOW-&p$E`BrC1Z(L9_``wV?#A7b6nhxVb;r?U3T#`}k|yrK{7 zAEnQ_+Gif=gWD2)Xm2QeHq=%;vNu|YJ~T#fTfvh@i?O&iYTvHl9SWu=_6D9wdqanB zxQi8BgZ75fe#1$$*U`N&K+MT$G2S)SJCB^rx#%-cSFDMZPD5QV7|N~dADI)<^OU`z zJg1`e*T&=JtsC{Jd`zFojr!R3hU1-x^V{HEw!Pulr&FUo>#a>1&n@rRsLuv#i^hHG z`iMT5e-%I4YE}1Gqjq(V0W&GBr%mwiVzFKqMLW)*jKn_n(2L^Y$W=u+k-<$x@p+;O2#l8;GN zyHD_-<+)G2I<#iP)OpjVw3swHGNjK$ZOZyfEYPWFeC53rJ7SLRD6PNlIFuNy3y& zz(67?cJ&HVa$;O%gZ&IKrI5a9<{UK7yn09e` zPFYUI#zmRSZp}SXQvF`j2^SU|f3{|t-=u=g6%)VLeK@FU#-z2`u7>2?t+R@+7fj?K zCf(;Re#%GI9xB+p?!b{1OV=)j(9Sf2F_vN4G{lhUpF-~OmdJ4jhZma9^jJwE4bI2M zB%eOPvycwAfnTv9Ff;kKwZ`5iRd=r)`IRyq2H8j5#U= zk4-~-cr>g|##TL^aq0Op}~wGQ!>*&7vDBy8>?ulayIeoMTk? zHN^;^OiQe^Wb*FrgSsu~KBS=8#35^*%ANVnmaVsE4&InMb@13@sp}W+C@b6Ec8%_% zj%~)J#$xGvh zdz>qszjgJT3z@^S9_(EC`ogm1(`U?nZtl2G`VZ=MAbU~i;we|z7XyY4ozf|FX4`>T znoLKUFXvXo)Ib zl}Hv)A0Ss~7(xIR0c=c@tyt{fPZAR;6aHX=@veR~NSn8#qduB+oIZ29);-{T{>c_g z`HX_ieRuEcK4f-M{=o8Qkp?E|+u*75;`tpoE6jd|fA(-?{`@{M=1!yf&N`W~FgZUf zwrN6WvuQ_5%8|$*{G_X^77`YS*aRP>``DjB@DuR!18D&sI7m_!mY^3UFH{HPEg%%& zU&K08rs56Dq*PM>3&JT;`4PG2oHeL&rBxsmBV5}kdp?j|^6r5@F+dCWV@TI`t*bWm z&1pAvNJw>*&l8?ua<}W+ltY^y&NONhrWSV;DUYNVC!gu;A3QU5a4yZc8Xtmv?*WT> z$m8Qfuu-Tl&#TAiTc%L)(@OYOG;MjKH@WZ)qQ^S>a%Rf_xIU=XV!8K>uv;-tV8(+{5R%8 z^STu0(+_rikyq6PhAY&0pmZnr9hn!C9Dyh#B2vv0>8fX}x{1@+=X{SJ`y;PU;_##< zQR{oIJ0Y)MSNl!h&VJsDeWUiSr#X={r|_;kZ7MKcqsC%!hwB5tZEQw3oY?jv?K-?u zwZ_+P%_Oo{xVpKy8QqLX3@&ydgUh^N_)G>2udJE8aUJi-VmpuS*mlUm_Iz%KNZ;z< z0BveMFW|SjcJe59%dxN;c7M!A!>kLWV+Bu9KCL|5StE7#!24hWfP!TF#0Hm*h+=4}KmiP6;7HW1fTCL4*Z;?T|MFAd1)zOPSF<%$^({C>_|nUP&xvS4%9C!XjrdP>&J znb+56cN#mrNrxHT7rmnUs7?Fv$;k!d#!ueSt1xrQqCN?+J;r1aUuZg^cWP=>bib)j z%*rhq)Xd4$bzi}CNzwk^o2u5LJvTPF=h)MYSc?Nm~DhxtzV-Pk$!OMq49z|C0lE#1a=CF$u zr>9hZ`1-n;L!MY!hRCrm_+MN8+Odec8s(Rmo_Vdl_d5T*_|D4=@C5CXSCQW-9CXEK zVlD77B}0PAAM%^9i{(i~5$z1TAF(*%95zIwP2z@ba3_F=K0Wimph1?oLkDddnzCla zxb=Ou@9i~oR@{_=SifZk^!U|%%VXHURd){7BTFPnIab~f$kF(gYi_w+}p@+UZG}*S?62`V;#yka7h1V_zEK zv#9~YWpWl>SN8o_FEwC20yP0q;JV7*Ys7OYYoJKLF8`Ysj>$?Kk=QhPea8A@!!joh z2=2ZEf!3AQId%>#6}p>B0u)D)H!zREpv9y?-e-{;K%4`cnFQ~paDCrcvZXPyyNZY^ zd(ba#dnr!-CLpBZqXqlkuAZKkQ9jHRq75zPb-ONoH{;|f>0@i^%;_0t_s?L?JFsqD zvDc?zPX%FD^|Clem_*QuO&x&K1et)b_p!IgOT-6DmL&Wal%&jP#BzlMsYiI-jPq~!N$zjlFCMjfIQQ{=>y`JfV@j}w zQ~7XhhPDOvpZ+OlAtU2HATErg197Y=?2XkzYQ{iaQq!8TemMq&?P2!O|}qZd6QfjpwR4$zTtPu!M$CEF;q+I|#OhxPS0͹< z2yZFxj<7*Xh;_oUz;(pn;8G$TTeLVSIOs$`@P|KD?S6iE^@5#9vX$qNU%g;D^V(F* z`JQ|HdsNyovfXa8PK}p#Tjwra?Je)6v8lNlp~)cMkU7E`M~NY)n{4tH(NW!oT}|7KX+)y?AzK))pl_n#B(gl zb2=;iMZ0*8(tZ-n^nB4So};vvs_o(~qUVUa2=u7W5pye^Pl{uGyU>+Id$Q6_#}m&N zx`t?PuC(VV?Lr0??Jbpd2oSV(KmoP>kd56n?Vf^Qs|XR~O93xmN&~9O!7%QU983&x z2>lN7orY!So`-jpEKa(=QRLtf!Y`W>lt}jZ>Tshrt<~A;j+=Or^x}ej-S9^-IL|Y| zKip}*lA6G>oSkrM*;EE31?o#_An{O28kb@=>abCDk@{}oEeB-F3wVcW{$txt@}g32 zw@UWcr$$|C=7i^717AwRbB*u;aPxCR6eXVQZojz6jSyTMceDZ)2sc9|Vke1M1fVYb zDbfVcK_TB{O4cE~t?0t0GKlQjQCDa7+>N9bZRd@2@pj?J?!*1YU&)k*)c)G9tN-$d zEvwpJ{$PA9VP7J@u>25wE0}T>z^z60LRcb1KR5y)NKvuR3O!FThT?D_nv4Qygts)Y zD4pH*NvgZbuWePM-6tOG-KX>Pfg5UeZOAR`lrwby#2p{ZDZFcH(`IUH+?Ct!y&M-e zr9;cIzfK%~P(=Ge&3BCbn-B8H?wMQrvE_HNCa|Y#KPHQw*gxn;bfX_E9m4Lp>Ct|4 zpJE+DF+NX>5T~Ak;ItAXLF^K7t>y_m3OUHVgb6y>JD73np$ z&tHO0vZkVJ&6=_`B4YhR{y`XDYGm6(S>!zgu9w}q{r1gU@4RzU%yk7HD1W703Dijc zl%Ik>5N}slNn@b`B4#U;Sd!Zv8@Ye{g!R2U?JOO$IcNLco}(vqoRgEYZCL-2<$Rp&+LmY#)mfTg|&Xj2V*X1 z?;$1(N+l30(gZTedrQ`kmy)6nNqwWtF?N>+GbxSO;N%cwRGe(~nzK9e^VP=>u{$Q7 zIiepMo;egrvfyYFAD~@vbZO|VKBKGq z&M)WNc__a+`HMIE85gmpr+2R6;nJn)dA0M2t|johNyYwaszJpqnz!qbn(Rc)p6#U1vb&m=g>P9MwRH2^Nxt&bw$_OL-TXh@wySx_ z6u-b8IZsB<8xgg1%i2lAoMjE=`|yDtMY2pP@RZKsPVlRYgR2m^Zy@;~(@lZ{5%5hF z?yHA4NrFjfk(@|UjIXx>J}4@_68dduWlOb*>y&O%ajx`+bRowuRM=4=E-(`5*p#K| zqjwHX3m>&*;P{u;AADi(u0LS2!*b(U3xBqw)Oyy?J0y7Gc&s= ztWeEyg;8J&rD2f7Isjvskl+k%y;LzR1<6?1jx^sz&nLf0gU_6m0dKGsdtzbFo&r;BP~a zr;0LkL}-#GTdqOc>M%TqSh1&;=$O%-qFQCioxjvfK+sUq8i{ z+S@L7DU1)LFi7G=ye3ox2%OTfw?F;q+JU$3PJeDWU%sk%nN+o6#mXYxyS%eI|K{EU z{ATC`6M&xGCMT zDJ{Jz>sc0d9@qR zszbm5xp=9on{LIhyQ}}H%4*sN#h>O%&b%WDXtUobWQA|f-uo(qwmT*eihM8_51sXJ z@H&6ub&osA;-03XyO)mcypc|>@F-_zZzR)wEza@d4{UZ<|DuR3#r9*0`MbpX3;h~- z0g0nWk|e~k5*d&VI>z#Ne3S^Dg*qFX9IbG4BMGg_P0axJfRQKx6<{`E4h855(20#5 zShcF6XvyYTtjZ%{)X0<7AH4P9r>PTn7Ytti|G0Y-_^68PU;Ng+x3}z_&c1iHPJo0! zR
NkT}%P6C7^2!sH#?;s-kz9Y=2ptuaKpor+`sH3>e;%~-L$7OUJ$Ni`CMn^|6 z>6`aGb?@!&Y!IB^d!P6JI}^IQ>Q>#VQ>RXyI%{^u+GYE2q3AsO^EWS^ed>euEinn< zfzij$;(l*o{>uOQVD2*~8b(x}oKQa~IHa}|u+h$SU@7M_kfauz51GfpKU}}l4gnVq zIJRKe+|(-6#!bSY0ip9)3%`hU*7g)+6xc`@uNrJ|)N_ruy|l|cA(tIZIAnS@H4Fb`m=&V{;* zQQ%?T9;3*;WJm}XYx;i@(kS7Ok-)-9X*Ygw>79=z-#q~A+$VRvX*7*d>J2Y+{3scb zLCLr7%#9siOP0A0Hgq)OeYEErfM;QXU4SQOG&33D$b@W}h!7_u;v$6uK*U-K%g%ub z{>^0Kh=2_g5sq|-_6WO;g02TyAg>S4RxyB%KImeIKMpm)O4B1{X*m7%A3pi7>5oCq z!pfDW&zu%3_AQxu>y}+x#M?>0qDx#p-o88Zlzh=GZUHP+{=XW0n#%w03<5}dDc`-* zyS&)_{GLm_m+vK6j=LYv^ZQ5@=q@jH_p|?hd6z%tE1&f}qP6K-r>{?{ ziPooUoxVn?CR(GebvoQrO|IWCR-fM!(tG*duX-=v zx6r$s=Bf6xztp>&a7rzID8aj&)>bV)tCg#FVj91CC*BFe3|jB*<+~sCE~oLU&+qxV z_wv2J>Aid(ogeQ0Y5ZzG`$_%oE~hn8%MWxZr}a_G4;sCnr}?Vo54pU{X})Ut*)HX@ zmTLJ4?{Ya;D~C;3Cjj9dkKPaV1fJJwd#%5@*Loek*c<4!XM0aQ!MSI>4sYxc!23$| z1otb|6a0JD>+6910(keV*VhAkrq_CXU9fMkEP&@7F`gJeg z4I>^}54D`&p+3Lojo!=mKGb{pz6|ejnz!1|{!Z_5ny*@ZK=Cf8d8*|Hzwj<6{8P&x zqGR1XPQn|t{A`zUf`?juqDwi^aJBqV?{ZGV)$)r}4$-^{hp)gRaOs1v*=ZxLHkBD= z2hMB~ii1V3K9>mYob(X530g!mF5|dwActZTRHxu6AR&OOl_}i>v9X=k<54(6we|W8 zQfXR)4D!JA2i6mFLZ$k5;~8 ze;({~4$demQ;y=FjI6SUI93=@Z|Wnl&y{#Ix28NLF)Lzz;{2-o zvCfp--1#+1!QiqoLRPjoqqH;wn5*2`V`+khWDU;DtC*EFq(1j<<)xC0!6hYwGfMPN z`EVs9HwJ4x)+OZjdX5qipr2CC2RAsw~*c~AM?-}hd=uiCquXrS89 z{y6V)qG@V5ky20pMAOvrvsyXV#Tse+#VEn}$EweFFW;^4+p4~Ro_DIxYxC`1uFqF3 zr+KN*>+|hiuFto7xjtXDoaU?cqt90@Cz`L8ABe@U08h1?Xuev0P_^aM$_YMdxeg!o zd4i8xuER$yCz`L8(^2N>pJ={X{-}33r}=6*Ej!VCwI7}4XA5hbzC`nxH?2?TO6z?n zWeO+sT-e0j_T#(>ea{CFTq!1o=zL6eOf~{6Ig%5-DZTGiD1BcFPj=z+3K-3BdJjsj z93^s&{Uq+~rU>nW-YYZv(2xGV1eYsNds`ohV6k(a^6!eh8Z_7|bROcz*FB)p8_Dkz zC|>xY)Bh2|U!8s)`Y&p9e{c`#?M?RseMODvn_ED~)6F{3AJU^)SEAobL+7==3gmv^ zpg}6ZA2ednh?1g${G6e^ll-7-B>CZeEytg7Yepo4@GnSQ0K#8}2r*p=Ke!ZN?e6Z& za}~1x%RZ2o(*ae#N%U|e*3CozyO-` zqK9g^P7l>`qK9g^P7l>`qK9g^P7l>`qK9g^P7l>`qK9g^P7leShVmV%<+w!%n^gEv zIhRY&^SFcIU#+&+`kQ;L*Wrsh7|xrX?LGAb=brUCym1G^d3&XLg8P-~3I09n^>x4< z4CipqdVM``2gCW)vtC~p+`({8^{m(7jXPLZ_^{o|4k@kkJjpuBZ&58J@kL~m0e{arpM2+s;mycF%% z;UHUWXo0^qW%5lyj6&GcS$W(l$mS9F#3-8D$Y6r23k%dVrcG!%OsLHqgp7{x{Eo3F z`&f|uiloZqny(r)L%&0*K%q!Lh6Tu>=?5c=RQ@YZUOn~jBhy`%*=O}$bYK;QRUU+v}Du7_qmB?OJ+#n#0gyuiHMSVSROg->5w-iaq_- z%IlQ!>s`L=veG_p=^(|F(gvvL{>V|75we=#1Qz1sf)Eh|QJA4`;DTo`*LW0GAq|k* zO0aMirSVJ%3HJAewwnxcj9!k(hz*GesK%kHSF)xh5q0O}(M5$Ly1FJ`9$j2K+OT?L z`O^Hs!R=34M`tYacqh9GZ+~}$w7&cu08)ZRM2{5^h`Rx_Qy3uxx&{GR&ydFfY0p}H zEj~~WGSgK4#l&-gnOI#=ke^?WEeKJ~`~v*~gDKRUHOS8xJCITA3+;d8#7=P&#E&RN zr1BYMtYi_&l$BEIN(xOR?sqMNJN_?SOXbS;$LS9+`~}7YpFG&M1`-TmZU|K%vZBD! z3le8Ug@=8(VJclHgeEdZ8{!dAF=sAp4ugkYX^pczgmlbuH$2{!GbG}qLjx(2L;Md zCBR;R@}MEx0`5l`39d(wgfqibGC_tRh=J9GgoK2Lga;=jV(w-mMVNxfI|-4+>_cTn z@p*xX28;?gNt!PCx?Nu!ax$fXz4gGQ_s~a%+T_N ztJ5Hkkj3_ka+UJ8DJv3{*u*s$F2*}uxktVO{^emp0&LSWC~L{U0m(`6aZ!=T3jtX@ z%poyqDB%$eg4B{0`CBBU3Vgz*vK2p5tNk&rsTVC0_slt{~zNy-|oK&7xDERmxl z#?OMhGw-;FPgif86+^BK&OrHj@Dc?7l_|5hEDV0TDzfa9}lW4GCwdwj2ioWeOJS z-~agI_v!D(!%6J!vY#g_jb*Dg9Y)-NgUWr%y~+XPKAy^^uq8W`e<`niyajPpT)+Nw zbH~|w7C^Ymgr@-;U%m4rCKC%NP6>8>GoAv;$G-@Nf&f+qOXR>5#80wY z4UQD#obv7Xm`zkJRk5ER*|lqzocQb-r9BP(!Z1R52Xl`mt#4!mU=9N)0y*KlHgOf! z;xO`-2@CN{y<&ITJCXg!m0orNJ)uRVM1D}Jw7gm@~jIPEW_j;AjEcVM;>ys(6p}HwaI(P zk(-}x9kZ@#{DFowe?G9{qq?OCO*1u?2J@eL?lXlbx z80!wtU;WIY`sNv_~S}8~p;W5D$O^{iUa%Eag78p7rOe7h#0hJK}&?YT@ z+U?52tZtq3P*KOv*bcn!=VXTk9N`&YB+xJ*q?#%WB-tQs!XtDzwBb$0CPef$77$X4 zq?In??~fPagHw`W0Bp8^9wUqo&mySV;{tFh+_;!1$tt}X>H45-Y3Z3iEdS&F4bP98 zxBj7>C(4(ODw$b~;0oK7T6@%oukLjg4VKl0C)|>j<^^`(po5pip(C zxe{75@j-K^Hk~ZnUD-CNuS%2Nnvkf~A zZ`rclusYqbtWbHqV^)3Bvqw(6v*I2$`1c>NagVz;Eu8xKJ5StkFL8uSc)s&1=^Mb? zD8xFWj96Ya7DFb5jmnHs{6x_b0n-D9jTp*)f14QTS|<&*Wyz24y$O$j_G|$xG60Jh zShM7jT~emqim?3Z=H{5qDG!}L@ZYE%I4C1sw!kw0q*=A%!5m3Q$9=jeXINO6fdWP8 zTah?An94#kkHwR|+pv7c?JHhCbmR|DAY`1LQE94E6kcz zdcwVrWZ0P}ljVpuxjZek22RHWam3#~nEZ(H#jhRDFt*dD_~g9h56@lmo1@!SHSQ~s zw(nB@X;`WJL2><2`QW*&xtnqizjtTV^tKmvANftIl6P`{^TdC>+?D2s3rUnq!_2fC zn>cbn^Rb*lyqo4Hu-eE7qWbZ%5lN9rHmhH_f4EBf)u`oNX+MvONt@40lI=MIHXS*# z>88VnHV><=9+qES-Nzw-tQB9g05+5jVFAj>v3dD5HTiiYC=|WUt8lWT^c+%M228{C z9w?>F?0!h;1MM-mehdmjfy_@oJ1eBGyzVXDXKFZXES=7hE?}iFRRB1WZUGxOmb)Qr zkB@LfI=lydB?v=Tpzm~UiPB>to0A+tL5`$kM_{0%ACr2sOoig=C6T6c-y2&i5;*I#vQUDBv31b0SmL#Dt(oYowa! zT3rNg)J4e6nMUqB0IM$I7hvS5TC$7vNB_EU;|E97e*4_v*^z{Pe+Gbj2Ux_=PLGOo^A5x| z5I0Q}oS$w;Z9o>ioO7UHP40-)8s|_dd+`m$n}5}sxZ$m?dDnltjogHLQcxNG%;8)wxIxo%Zu{j|9YFqRuS!==ArECa|hI?VyT0alSR zr85BEK${tEH=OrX83?dU{ANkGCn9PjqGrd(#zeu{$sew011Kv8!d?+-dB|*c&cSQU z>02J2c68C!GfVz>Xx;C|ww3L#KXClS&iNr@mYvwNV(R8m^^=~I*UdaJb>*puz`IVZ zczog1=`9t`n>TJ#_NYU=hfB#{Cpk*#@#5bMy; z%{fp~$!?s(4KKfZ8*`mxNB4nqcsNw7P!4QS-ePI1q=$<-jd8Lnd>2k*un+G=xZclOF#Oz`D_+~X z<>}hil6_6vZau!ZZ2Hh0^4#r8TWrK%U)uJYW%adlhnJl`aq0%J54YeYKY@P$S=9Ik zB=qT(e^8hE|2F?{u`^Wq956Q$PY=!k`~$WPxitV2yd9!3Yq&s%NGAA&hQj#Qo-2K> z`2CFi-L*!ZdsBN>j(m7ezjz6LKyY3Hsc1MaVfvrpCFU=?3NO)mw8)#6$UD4z=8pOS z)wgW9A}IEjI^A7n(`cXE7eWH=Hc+iA^2P((cgyRPHx;WZQT0&e5GpNvATxOetiau0~w&B^IK`BxL-8N73y#CHEE||A$}7-=zVUGZW$h! zph-9qk?$JOi}mc@o^!*VKqrSPx{nMGinqqA=ZBUfg`BavpC7cl=rRLK9im$}UBDKK z^+!ISV?_OT^3fSHj!x$PHsn`T<>ysa%IkJ0?N8D%vWpp?)Q^!nU#zcx;jX(bO;LVb zm76zaOkOVBG`!9}%)yz~=Yi)v;42(>?g95X$g2#F&S(8Ru0ZA=kKFYCD!fg3{egE$ zMn*|-M#fd9beL^?@a(2dXCK^%xoJ5qk(X!5|6g#3C1IcJ5%+_CiWXk1KIa2_2pf() zToO3nq|+vtN|2F7k1DV!s0!+>O3i;p1{Qs^3h%8-Z57#D6}_uxUue+4|9gx=-Zgj5u2IJ)R*ji7VQkezRzB^-j2S1U&A55mw3}zF zXr8-h-qKdAF=LQ{Np^8F=skR8aSgbWIN)qXGb&pQucn^ z?0a1s(FZJ_q;-ICINc|0!B9V#aiow;2b&lvs(E*K4!tEitReawxDbcjJyTUX5X?nt z9e(r%Hpl9E^VEaoCBud^XUtjm(55}?iL#cKvbAk3OKJyY30It>&M2iIv! z0mG7vi#*&nWTZS1B{H0$<&Nov8&hRCxC3bG-uE*YvxWeDKcn_Tn9%n#YCnV$eLti2 zL%7lRGt|!&s?mjtElvbJPfUnKka^N!5QsD$)6HUnVE5!OHH#dvZS5Xs-Q>x493FqF zbjILCg|$_cqZ)Xn1lmhrhG`~%7>r!CEOj!k#u3>Z3SQtF`kg!PtF}UiZ9^!D-N7Y^l zGp_omhj?7|QBF**`jnfvIDL?mI5;smk@B~u=pmc4hvxEZP*B*I?4fzILD$}W!?>fx z(|-RyL`ov(%LaA<^>0_mQ^>#&&LX8 zWWHoEh%BV9P7G)S#hu1-7_oUtSO^IvIDC=v0jfl~?`Lq&(>Lc;`r(_i&u7$r_~z{M z8Ff5-bN2ZR_2cY&xadOLoPp6%32{-$(L~biCZg$}fn5cOs_z)0nkTwNiBe#B{RQ)& zwlSrPM%8VpJ9$Ud(Io{dv)^fM3LZUiR8=|q-u{9kV?aYi<-B3f9jL!~dUTxf^@QxH z!v`0DL_=oX`L(o^)A~ebJna!pe#qH>+7H-`2!{kAqRA~p6w~*yd}X0HG_a6;uo`~3 zxDQ5SU5{~kp9H!2ADtFQbf79j{Qvd=n>JuTnszv;w{VrGRl`>S`61}^mUB8F2J z@(IP0r$2D`=6fdBlsHeW+j?A?Q9V7cpuDQ0oMo+QUUT=j2KL#AGGkPA+4SqLpIQJZ z?Woc*&9kcPDOPKeJ<^e)JU1}inp{4SOy03?Qaits4gkJ3=s0mJ4+l}gv5cripy^du zAv&g^gNIYA*nkg659yXVJ~RZxJ}xvaCB=aCOMyQ)7iemT1iEjqL1|5gEqDdC_`xSq zv#%RD`}m~Y;+WJaoV=cq|FtzmW3qElzht(={EWYgo1#IPBWp+CzTlAS^K zjwEH7w1@d2Ic$I+AYF&$VFA%#H|oCG?0~XSvSWxQIZ&@>-t{@CU&q@A^;+*h|2uiT zwb%L-wBN;WHI;*Vt-RQQP>sqhm&^1x60K(ZTVsqhm&pnCSW_<_4V2leX= zi%<{v^-}K;U&lR=>H)uA>Qm5umths^0l!}A^U;1Se?Q>YOMRNVf51j6JceGcl^@%98i-o8t{1O4yh^#s43^(kn-%Ru-~@atKhkM?W% z`w4zM>(ku*6a08R;O7}%nb;~VM2Mbp$rE+F5Te3hD7J!l@vL*6C!z^%0K7zvuTOQ(|=w1M} z-V+3)z;idSP$m<8H=y2F8owVX=gQ>F67d7=o}e7$z2p4o`~mXeC$#<(g$WNEXODDN z=x>ROg>8gP@uHxb6O6FofZkF-`YI^vR12M{AcayBsGtN%q&+@5k|N6RONt2tdDtk7 zw0Q<&qbLjDUUIu$G!z=*GUX+jcbfC)M(xfqqO@RW^Psuw&fd7E+rX8oUalaR%NL#B z$}eei8OpXgWAu4s!Dec(8T=?xZ5X4YAQCcY9I3+s(hX+V)P_MDWTOER8TIfLpdgiH zsRf)dG>;nl)33*e@yF?&+ONme-iayd*W+sM#2@wRakY11mHPEK_1?cH(XqxrMfv&i z?6RpOM~?{e9qKofV}K7Zz-#qY*ADk#-sgSipDM<_Na*n5+TCTnUVYZ*(beZzH^lhx ziWec`mpUKn{MH~E7I90N5$u99J~|2+G1Ob~aAuL62>oD5WG3^Vfxs7|h9;S43rQVo z^})XuvuuHr2hhe0v;qDTjzCij$NUy>%w@=hq%zCb(rOrQMM!fkt<-lB?p#YN^<9KQ z*V0OT7h%%1w4!%iQvjPvn9|~5`FS~6ndy$05wRmkVBxDOz_XutKK@nS@}7YMUK{^)7=$z)iD)dc(i7Bh@@%yJuaf@(01F2*}At%b^bpkWGwL+XD1YsY%42_V zLUQOnqavA)WhgWHBr$_2kwmQ836+E6$Y3!oqk{_Z%?M>IK4wcR*hZtt$LM`dX?eoR zcw%}Iw&hY=qsg$T|BZ+7#&lf$gvRQd36Fk4WA)91PCucs`ewqapU{}z{8QTDr5cU} z8^`nj_=0EO-#^Wq?FO_bk+mEF;nT)WX zG@=7_do(y9tQs|9xU;OZq`0tv_`aTJJ}h(kr8S}R{z^N@_bNQzE~4Yx`=I0f;sbi_ zD&n7>z`pw4unM}EVAv^t<4mA)KNwmVF$lXU9lI$CyD1X8$*3x6R55O@wxu9q^mnUl zV6z2fcaPoWfvd(D>nDuHW#E)K7r zyG0u3#j7g`oEPm42W24yICzW3k0uL)*=7$%nhake7~PAA3XB~Dwm_GYKJdyY1mcO(ssnJsgFjKaYEOh<-ip;Trn&xQ9>Z*W(_}pkI%3UZ7u3dQShS)D#3K zOHUb`I+#LCBJRKEy5@aV>yP@0#i;D&eMF{al-ECIa;u_F-R@RJSu3E58qtmGRCZiR zGllD&;1B$`6ZjEDIuBulnCeU*ZEjpFlD%?$ZUHm-#Nh6d#C**~AX_F4OG4pJ;u+^0g`F7P39lRpS8@Sx~6^$My3YkIy%XS9qcl4pDQ2}P4E5!gH6P7rL>v3O}+aL~i zU&b{(xq4}(E7g^r@?5^J^pxkaeWj;7m+LD%<+)5>=_!7BzS0Ap%QEyW`MDVb?eU?= z;e=~43H!Z7y?F`c2 zR=V}K-O1USeyVc~ogaa&y{g@5MhJC61M6_c_HwV8SWz@M-C!~6hhG+5Kwx>=CYW%6 zF=-bV5Vo!t7|5udc0~`aZgFTw6POA7naS z7mL1@!m|MSg#uBK>!;L?EqCVSbiGRWs$OH)a+SCSjAp5-WbyRjaks-{3zEgEii*ad zk;Nm(WUFT6&4&rsi*#FsDTM3mwrRLN!pl~nKNk(9zZ(G39Yg20*aN@R&h|W^To}v# z?2PDox{of*OpGzWBVVIuiSVTZ>)-z zbR#e?PTwE9T^*JCJe&KAzJ|{4fV)pZ$40tv^_)&|B9=XbOcsiQuAkJp zJzK1PQj=Jd5{!Y{>~T{UB@!ccJ+5@u{N3y1#Cy7oqn*g z5@bJe`Ovv87~#U~0D^t8l%|N)B+%BK2Q^yL_cL7#hp*TVQI4ziLj> zaJY|ZkKu3tW*a3$!Luqk*hKDK;qKM*)}QC*!z*Yhdw0{)u13OtP!5Uvm;cHzWu&)d z@coO(XT@!M1)mkwcC3qSl|%Jmf$a+F#rHMaq4(XkJs#T}s$Zqmdu(6sW$lthnl2dS z)gsxdol#CmK9YjpZ7}9xY@yOFwHh}P_jn!=9T214#K_xSYHd>%vBPc3;~yqTTh~e3 zk~^-W_EmU(B%ZI3r+E2#h#lv|bZMOUB(}Oq7~pikni*U<{HJKkgG(n9`Z`(0t=1$% znt{h8s}ZhLHuz+dU-ne-^5tgnvY37t|DjDs6?<8FLbBuL3tMTJFH<-ZDE<*(1?L<> zsle+E+@K`3f|QW0pFtR(lw?mzN;aCX(U49fDUm{gP_&&~dnweQJd=Vnd`;}6_4T7$ zCyg6C*jZRr(6LdvuBf`SrnI_k!r08x78no=TUl3JR}!Bdf0)Im#o_BL0Jft+`AV!Z z?m^6w0pu?a`vU`9s!bG~v@dHMSlz=M$Kikv(E!JQ^fZb;8fpthLKz{IrTUP+8zv9S zL+uEltVvWrA?PUvP%ilr*^n-Q0@EnpiRrFCFPhCJwYE)|eLekHa%V%@O*?knlqS}n zmM$GP?%C{|q0HF0!Dd^zO=)f{-;%py1KTvRX~_1hnIqZ04L4=|Fw1mO5M4VvBgIPS zcTI@4frt}`or6h$wMNz;xDu+SL^2H1F?ut?GMRnN6mG&Km=G4h48K%^6~n|I1d4c}P>2*5=+hM7C;IrrApE^3!hTo~xDYSVo0yVu?h(1fm2zaI6w$>fw%xX!fAq`mUbfr>Ie?<^YPl1KHR|(o(9;L z%xl=*Q;Zc`lRh5*)@1WeK0d z%=W+VIY_Q*)Lr0l7}AhurOG*){Fx~V14Q#M?}h#eX1yj%D!#^IFeC4P+(I)-kZT8| z$03&t>{D_EC^;YSHW+<(3Ux|M1Mp zg$t_gt(ZT*a_5|in@iVBeccsTSW{D2P*Wq#DXgt6EEqf1u)2ORX%IH`eAC)YTQ_*Xp0Zk)J6)6Mw`y`U}XjsQMMFbh1IElQ3gubqH%^ z68xFJ#nI*f@G*qy8y1 zj+i-f#E9l**RtWw&BI4FH35%*k8upaI2`h9y%tSz5^F8V5j|A2A>%Fd#5jF2Nj1?gI6PLez0za@eR(1 zm>54l6*A-vMIp&-$%#H7?p6~K=i)w0$3dgd8b;E|$)C?O|#}3yFqq1Q&%L;013x?Iz4eR!aL5l2;*iVS0 zg%Je`gPiHK>`fGvtDuI%N;8>7&;>yac#a3bb&+ARlq9JDQfZ&~u=RLI3sJXWAZjfg zbEI3^kGcsL!9ziABK8&D<&YO@=hH%M&l-Ms1LIiF$Dy7*1BDvr7?q^?g4tv&swTkf zZ}czLIg8|16g-C@3CkE#o95vA2Y_r(Bj-x<2jxbz1{`w-<-lY5T1x&?Y#y|2| zja+|!Qk$8)t;-ay4kkyZOBQ=O6#H$ME>&ulE>#emMveipGTg`w&|+ zT=^3|`J{li%1ZG)>0?gk#;f7}L-}q;A|dQ{Y<3FARYEpoDizSmvHz#))sl!Y+x$QU z)NqP@Pyt(#O`JjpF%bKvsOKZ3k82C^>+13gYL(=Uh0;;lN5XmVPJh$*4n!g9jq9Lf z!lgCf=)rS9Uj}6&F&tf44Nt7IURaGT+ihxZ>_zo8FcpLwlB)&#?uP#Z{1So}fROew ziM}>qdvEiR=4<=U6?qLab8kP~AAXB#OaG@CMGf1!K!9W>tZiys>`ljFD4j1P)s}pB0Q7UfDFd8;7}hdU&sF zrXY799s~T9D4tn@kSt`;%?mh<407P4VPMD%o5*}#GQx)07g-^}P{0M4;7AGm07ih1 zN6t<#*QTb}?Xkf0K?72yHaFn9ux8S@`IAQDOl7}-Y{n!_his=2WGGmZ z-2|($9q@K@*b`EQ@-(iv9vcM_Cobh%v}U5>kWBBuNfiN>Jm=9&mkmfj!F36VKCrxpSy=hjAhw`w0Qg zOY-i{X_%WC{)gwXFl4M@v+ImB&SVxGhFdx(qRwQvMR*XObgs+?Q$HGf5#3{On+-5} zq#n4YSPE(agZw4zwI)9w(QGc_;tmLwu?HdJK&zf&4lC&syu=(yxk#IbKV8IuAeDCM zn3s6to7t0Fx{EVFdxt8YNt2BSA-BmCh6)p%;}BKVJO=7_Rt4b< zKEatA1b*p?;`0FEZ9?WolPLz-nt5D{w6vkfE0UI(mN_6@mD;GX8ml$JO5&Q3V8rcB z6&+yhxXh;OWbApt$cihaNOtVGe3}@$x9RBv_4ogy(Dkt{z3DPNcCp~=v-Jz_n4jx< zw1|CS3+$N3rz<}7N9B({Zk(})B|oJLa=d5#JR7usM)MQD`**%FYT^2&*J&~xg2C6) z4&x5YzgTbztDP$>%o@zbhB3HN`UVPZFdej-gRN~LK}b)7%_5tnR%|uMEas@)BFtCCTmW4+ut}+Tc?>|~@WmMhd?PDrbfC8S72hMTJ1Lrtr!4TQs?C0Ma9OS_$gkNQW0fE=ibshr2P%9nj z-7qU50k;O&^(7=FBqiFhyLuN2h7k*E?E}4Z8Kz{na^za#I@LQoMD`uTgHzgnC=zQs z1P+T&#lqfZEsiDmr@WfxtIk>O04;aj@gfQc&KMXN&?E!|xTT@d&H_-!z~F$u)_!-Q2~r!JsVd+AH5}o65E+uCN>?B< zDRy|X(|*L~#6D>a`R?~Gun@HP)MK698ob%WQ`RgX7&w_ftn;m zX=G2jzAtZTDjzj-rV1DFb$-`YrRt5&X+dU@;@?X)QEIh@1bR~(fRZ)Asf(`Qg3Ts3 z1tTCl(I8pYMe1_A+{^C4q02?;Nw(~(oi_~6h%+v(x!Cb`pUB`+4<0%9uc?1UK=9ql zU@xJ17d)VS)3F2>Q#ymRdsz{p?p6y=Ri6vR&MCoaGa-O&vU=~Jz(5PlllD(=XprYn zjj0J^Hk;TKf&+_G3DM3`y|q_&9=4i)i{S4c-@OmMeRxkH$N~|P5K`OV95mc12o z0@Du2AV1OTe8j~jT7qb;%Z^r4UVx_vBDTu_*-bC(7OBqgmx4Ow6~mUx?@s6Hu`UeO z?GFP3l@^XtZygbzrZceH=G~0n7@zq4X62Z4FZjoyLY^?yISJyO05&!=Lo|&E^b=)& zNJ?=(YsFr~|F5G9dsI!3FC>G;CLaU{Fyh?f`xQQ?*|6W&j;rV>or4_3IY?u0FgQp@ z_!TAuPDn&n?_kbM@*Q=ramQDOt6{b9@0h;kINH2#-Lp$YasB6~>lfbEn*V(;pR@SX zd&=#~-`7rp^r83JyWUuYgx_QT_!065gl*+arLqAKft6P0P`Ltk33rT%a9DwSy;G{+ z{%W!D<8r!hbTr_;@ixD2>~8-j78!S_-C4M8F!i=U>)vq_`VsLA+zHiK6$oq|ZsHM1 znae``T_+n|mg(Yi9hJQ2-@3LuprmYIkBZgwj##|o@7g;aMUO^#_pNkK`y@?z3KSQQ7+uQZCPvi|xkhj;>P|2sZ39B>*jN(7{}+q5 zKIXdLb>9W?R!XBHzuaDc4!Xeu_XXtU$!Dgv?Na?tqqY2UbTN%pdmpQi4?w}+;uM}0 z)H|}FMZY5pPbo7Ib2t_+?lt~v9SN0JJ{O?E+O6feumAT*jSte_jPcy0w>1jNW}VHf2(!)nMeZo|@+t82u{ z^*epX6Ktz=QJTl$o8`>lS{ zc!U1#CbJR9L`jEs(XB(9rfSfl1refKc*FOqIJ2W6S2TG&WiZGMluuUImm!Ccm!8bj zO;2XPxC~z0p+`q1UX+E)Rl1JMtsnd0@ZpCw^%!XVv$z*IaW5j>J>UZs4&cJXRsxWq zio7WdnI!aPLBo)@i2fw!14HXesy zf50)|8MFch5n&DRfe>@Z{UjR1@i?1FF_*-jpeh7vnMzG4ST16O(!=C7Vvy^ioylAa zqIZP{5$6hzA;hKEV>o%Ef{>n35Y$XeUiv7DZg1ed&LlE~c2P;t*4psf_6H?0Eb!Yi zuWi>Jh_%-8^=9me&OzeG(j>tOdgyPU*gcea0qKA!#1{kzqF9wMDt|`F%-r0WB{T9z zC9tx#6FVp6)g3>-ZvFY=U75onH!X$d|M|Mz$t&uRW;l zv;n{me}sYp`bs1m3iJ(-U?m0rcLYN)n!0MNv%DXs5gRGt7~)SD8@e{-8J~?N=&jR| zlM@msqnFCTsR979V)117f*w1l$Nq%%Pi}T-7u_~~^RkQj?o@KmOUur8{HyD4_ns{z z(=_Ffwr%sdp&DR;Tm+XuCTQ={`9biQ?F2m&0}TEWHjRWWW(=|fO7Q+i1PNLfKgh>$ z&u3az;soE?&=8uFnX5y!#K7*1WdZ(XZh+;b6b;stqD-HI_UWC;n(K63-`_87zP~+< z{&gMhqWyGj=kwW0|E_W~_ZXju^}V9pQkD0)<(6*21P-mfL<*c+^AahLHj)1Nlv^qv zv5XSsX14y?WtZysX-)c&yZ+?ygLBqIm0iaVOE}^{j}@``$`)OS)MExL4k;XZSd?|+ zpTR4ta^wHA}~vAIRE~R9ki&kMs72l#$G^la<|SL+k>mAL3E&aCJLp6)r6v% zqDz7q21r#^`R}*0Xk|D1{XW+{;=^prW@wS$J^aOCF?~4N^8PQO|DULn_kNMClkfgQ z<$ZR8lCf88a#efYtwdl97=8ujNALF~D_8KqP{&K8KjAlAFZ)TdXd!C=9@&|Aeb3vZ z+c$T4g)w-z+B^1MdolF=>v;d17Y+3Fk)$z52L_$TRbZ%QI8tFqUiU7)9W2Imi}=*u zj>n}_0OTv%@n+AAE`K=!Z`R(a%_*ERuX(-mYPJsHm|+iF2YR_a+ujGE{8e1wD%~U1 zbj;{J9u*Q|$2K>dxou0JBnkwEtHavl^>#4*;fXYBuNSs|xr2j2hq~)S6%2%1bm#6& z1EBkLjy5aDjvV1QiFTLz-|j7~oiMw~i!r(KJ4Nvo!&i`;K|%=IW1u6#)QDLT;a2c%1^GjQz&%5QE|gi!lF4WsYfs?z zEv726ErWG4q7PH3L1;O&lN;9&vM7oXp%hMq^dvQzj9g3(8&nO*CIS`-a;$I!hV}Xd zlM$XuhZr$PNgYsOAUdr6>wzN|5}gUH7aeP2Z+L9^z=@6#&XvRTpVhm{?%e;-fN_qw zM;vS77TmQocUI=`%57!(&nd@8D)Hh5YiLVlAxySIg1&N;&Z4j53R1+9Sl7|;_%)UJ zMK?{04*gEa*sK*G#|cSk}yOUb>rh zJ^oX01Y=l&!ENh9nLO$2N)i@2+XDPWc?{TTp(@zdY60WzV>aSa3f+B3pv@oo7Da5~ zsrq+OfbBG{4Wdw&kU&}K-L>X6U z2m^lv!2;+q{`Aicu6LEEF|kir$v!1dw1ccD?|-HIYOiZ`M}^<~j*51(`pJ-6_d_@3 zrd`c8=(APl3!7D87w(iNK}(UT%o(pyQJAHYxgTABfp17TOU86O1hLl*>nfJ1uI`qp zT%YR_Zj(iM2XwWURUNhAGS^Z6UARqJXgJTyxsKX!S=CV+F88XVew*v4zr>v*LdbOv zMbtO9mReJ)3n4(4v7rpcz8-cQ2*m0ltQ1HZVnqqr)?k_ip#gKGq~xb)n(N<(+#SMh zpR9~wk4;vZ@k5z4gJQ#d+5^-55MZIz9#vJ zP1j#zzJ2Jgxylpg;-jia%Zg5R*i1B9F?R4wT4MJ=+`P}ka4 zV+fX;7cD(C)fnIsbat%^yCdEaWSn|i$H*e_6I1qvRg2f>o7)#~-mP~hi_G4%fvg%H z7cPS`Dz_?iyL~t`9$YjEEO-gNGM{r_nJb+$GI=lavmigtLC4-@Nz`6XeTYArENy~Kil z!kx2CeBvr+tTy)wIZ;eIbcpWN+E|dw=VjWtfSHOGVau`^h3nYgDG4eJ&?C!~=^{G=zd2C07^jSy5$&<=IPoCuC zeMwv^ea>O>LRXkTE|7q_gS_s=?sa{`lEYxFe2%w!xktN~*xSId1z3X@ddeYO_lX~~ z*Hk$~FFt3lp#D2v4>?5ldcgX!W*ZXhk-4a*9F$5=6|{3xjnkj5U;im2=&;DpZ7{a7 zFgE2QFY!3x^>p-i(RdK*TjTivBgt@bGDY~+1yhP8k!T}`Un|(zt}os^73s{B7hrMsAA`Sb>{?l^E<{!QDUu zK;~3p3$7+2^usWfOSXo?=z?c{?ZfCIPxH%ikeNmAh81b%^c~x`??^YN!H%LYtBDP| zTh>MxN)QavG3FOwYmz9y-z^H98P7Nau^MN@xY$jQVSP$cNeSnoERBz|hFQacxML}0 zyn$&5EJL`Zh>?>$8Y9@Td>aj#rIXP|n(wA39>2+#Mz(|-g#^r5w`!h0HwrQCfH{Y< zdG5mPi{>g@F|4gIqf6P(x2*^cxnU#wdC6$vE7=mwhQma8ZL|}+i{BXb3dNwtHX?U1 zN`dTl7h9rQQfPi+cn1AdYqC$?A6T#6?}=UdgS37uAi`T`wy{zhGc9y^y5(A%@+8@tuefchmxIkv|-{llyNvzTGz!D3m#Z-bRAXO)LP?)N{!k(R&09|AE;k4f&QNfg#3P`40dHZeU;~46 z=CG<@cJ?y+mJL!~SNzx@B%QBwjYHC2k{`0u;>U*HX!66JzMik3{ySbz^1~kW*tf64 zrfUOe!dt?Znr!k*O*T0lbr*Tv+uiH1pWcHV)N-_Yr+Xdn_C4S&)xF!JZb_#B_CD39 z`%90yYScM--Fw~Z^fd&nf1krsTNnKPE3K&T7mXiDr2P%E_I~c~L?IFTJBDY|A)~6E z%!JM_fP0X7VHbiW$Bo-}Y;;H=;0}7{1iH9e(we1WtgnN2s*u0zf(xgVf5<_`?W&a{ zP8}`bOIM;4VT}mloNprBF+*W5Cu4^vi=50RXUjoH73I?6#g~|P^fBexZ+7kahB+TI zbZq`udF_D*mDfJGY0q)y$68)_g*7wZ<0@|6j`v#e-U|62-s6E4fK^#u|U|qwyx-yy|3rl6cpI%ZrEg<~v&Es>ImQI>f8dH#^{DLiposlTKs~na7 z#QTh<9TnxrF`oWGzx;O9gzGEyoD#-;&)n`Zyl=OY4FC20^pdiMz%Y6ddkEjB287YyM2*H2RoqSt%lSHq*)o4U-BFsuxdp*I;$luVvn601&zP9IUY zTUaDpnmSIW^}XeQ8qem?KuR+lDJM*C{Q-*{$xc zG{LpPA}ODbPxG(W#wO*@R_6ZkF_TJDgKvn zuO&#o63wn3=#R$tzwq*vC7jTw(>N>jD0ah;qw$#q0*0FPXJFhyWYaKB9&&qV^ z&xUs(VQF>-5-9-EFmY@M4CvrlMcXohP!rC@m=vtC$p(Nt>WavFHjGM1+KX&MUKMag zVgfm)lc~DdD8#dP&6@xgDkKsMg)}9}N@0c}Da*Cuz6uSc_+i!cWd%bU)2CK3DfAy@ zJFhPu)iCJHnR^WHM8~WTx7O62*xK>fZI3U`T4S%gak;c$>(QIe5X{)!QUlpyAUDGA z)Zfa#C0{{dfu`Td=j5r+YL*i6RP}rpE<#Ubk+b17lY`Y@!aJRxNq5O)2nl}E&ujv_ z69qabl0}Ek_o&_m^fa-@utz6naoLEt1>tI1W(RU*BNu?4Qik z_tJkAsvgYIL5%P$6O&(DT1 z3ZK>_cv@!D*u@u}4~=&MOmD}uyqxsn z0mX4Kp=n`Inn8LP5>DHU+l3lUp(c~v;m9TDBiQz1lZA&mbhad)3b`MeN!^r5i4IdP z{L~B}Ioxvz;SMOsi2mIK#y2!S9K9LBA@}FJPQF7oz6ei2HaC^WNHS^O)DDL@ZN}kA zQE@p_6lKcL_~^-pmGzDUQ^DNgR9jMNOjJ;q^j3IiP*ikEVsL8d+yZlg>(ThZgX5Dj zGMJQ@iRjT;S&sw_^c_9YCtdkl&R%1zJtD>@DtcgiWJp?~_`v?80k)`^^ynDBxCnc! zakrc|Zg+jw!mN48H{YB*4`1qck5kT6rzb2LkQx>il_2NYqOI}q)@WOvoDdZjmYO&> zA-&osBgsBwh#fE0KJO}yD+~xIj4OU?(v<9)#4!KpEPG^VY@oHa)*2Wa8fnjp2ntKA z$(}+ue1~wq94I5i8uo>+Fx*+n&00wMgB{(zD#7$*1>lamF-$OlbAtIm& z&FNDP>^LkfQvc)}Sg~wX9oTSH?e^Y4vh}n`W+KAeJjX|mcHqmfSf;7?FyVF!bga|u z1yujn9)s}H2BFKO_<+7u9+Vc5u+lRa@8R@59(NoOAAxKUOvQ)A+=LL3X0EVNSIJ`_ z(u+Ywpck;925}FNX#jh<>ljs-X`}49hPBRWH_UkK6~Z20P!5hi{0r=-FlHD~^>Y>Z z>S17HYpkP_4mlla6m^9`T8jUAgIj}hiZ|TcaWn!f@HZjc;$MJWuvYp7zv2J@LPy$PGRZ3y!{N+^vxwBdu)lw=2*10 zvT`k8AZjp(Lj<`zp~*QT9UhFhHz16WA;}~P#%Kf{VDKIav%%xpEShBGmNX#BKXP>A zu1R=gB4kv>LBZvZuEKEf1+!>a?4 z_Dl}2eMz}6!Eit);K21WkGN|nM{{jnFcNpBj*L59d3vbd`|tZ@o~%4sR?|Gb2%n+x zMwDdTUU_@mC`VTFx^>OX8#c&sZ_2TucU9hAI$`Fx!dokEAK>?wzxWNfz4BH(G;^Hu z_R70LW92uM*ETgbZ`|12ya`~ytp)cG=>%v|1XM0t9}9+og^7XzQB?1ApaJH&j1>Rd z5n`5-90*1P1tE0}G;sKA=C}262zW%#BDeXbt3TY}#Lb)u32~9hQOO>Eav_36_;@qg zfE9p5cXO552DNfC&f=7Y!&7fv@$9qB&px~Iwkd}ibRgtSI?%B8jP;&zue~~qmX_bXpW5RaHBRFn2|suBceGnz=QfC#0>0i zMA6uQ#4L2*HHtug73D{y4zvcDX^G*e??}yeFn$>)y2&pHbPHz=qr>5i`{FA0Kv=zx zWRaD(k{nYHH#8iYf+=J*q_8Y;(9oA3( zWkUUAR&I6Aj0t-& zursmIsOeTC*6)9H$^2V@1_AL<2Cwga?8@021mluDKk+DY3J2Gdb(y6?E&ppfzUDBK* z^JmRM^Z~r%50LYGfOok4jv!O0XFHWO3R=0|cfBqS;?KduMji*9l~D+H!ej`9Srsp! zH>mL>NC{F{HcMjJ;y||ML8bWD%B8c<$ftiiC1Dnj(*>~S50|dRS!I7fCC@FwjpC#<9}S!YT|T=V);X&vHqY__*%#1KF3E zJtzH=zsp~ES$o4Y%!=L`{IDSD9cynu$D&~Ctd@_!?`9}0;tyf zMr=bpp9=1OphqGpiV-|y4i7I1v=T}UwK>^LC@jb>%PGqkh`4f5p*9>mL)lOt9;z=+ z!a@O`GsXNQ_3X_J;tPhjp2Y41=R&JZER7>C*-diu@~x?2{w;6psEQf9ac$}JVNuL% zD_=P7`fbJk^~C5+lTw|IvK*g;Y4`1(_0gtg#Ey#GIcMbb(c=gD6&hAIo__tG>pPV5 zcNb4@NzQC2TK!*JnV~Z-!}ajOr33C_VfP=n{M7u)B}-P$acz3(*GHakPK-tN5P$>l zSO9t8dc#Uk;4GoSIT8kDB8!FYHY-?!dD&R*okPa!To6hz7kOe;J@^q524^@@!b3qy z{UPj3X31tXT%j)Pq$%4&ldKvba&XLO;tN1PV*vBT%!bnw=VV`BJ-==El2PKI^KtX< zTyg6Qji*oSY@2Z}TlK@d%_T)Q%z1FKVP)a88HEMQ>Py<3TU{SGrj)GtRqHJ;#v2pv zYHT<*-BsIiu%dGFxcRW{i^sgeF|X0W1YsuRyN$Uy*!Chz$J}O4hzka{A&o`QEI%K( zrTTK~d|WnKOaa(<@ii2-!5vhR360YyPaI!YRpBfhmOnHr$sUVHG1`IoC(1t0bsH+;$$d%@?p@_rXct?csH#lox^_qz7GfhT?D{@N=X z-<;PIq8@M$T<7$LF#AjgHTL;rrCx3T%?K1yok>P$D`eQafdX$Q41;P^cY;YqF}|IoWc<&;R!GyWU&0{KH=uS}yyC8E?4!voPs%w&47tB^MW}c=~$h zO6hIv+Zo6^%4j+nk>wCgvi(+3oeC>=U}Mt*r2&0PX5ybN0S2H+Qu z9IvyvRZ;^IPRd!yqS1hyuAp0-N8&d(is@u9(#-<~yAW9RxOvzUtU*l3&CVQ@mYPs# zFN_QiinqpFOhOHm&aZd6V)cCY29ZIdT6J(~cV6IHP>>zyr!B zXU?#w2OeNiXJ+sH$H9YN?%n(4!Gr(UyWinxPKa5*Z|jXI_Lk&f&pol>%oNb%M)tlu zUW3UXV8!(uDzj3U5q`JeoQ#suAR)e$;H%ze{g~{JTds_Ym<6#jEx1+yhrDmInHefz zaH+`2%u!>oBn+|-@&=DrmRk~v5_mY0^aKwv5#dP=u#wjAq)|OrY{P~Y9Uw)yj?ZUx-k1c#)>xqJ84;GabIt_2^{&b)Eh(! zgso;1k#gfy`ZP+=U`a-aEOQXZoa`#Y!mJi!>_AwcN-z>4see*f2q~76ut#A9j4j(1 zw+;qaqH)HS}-m;Q!6td&fm}rT^pi+%m(^hc+E_rO zE20h-R0I(VU>6NyH(*Otuwc+w5o;`|x|+l!CTh&4*G;cxvuT^nwokIjZZ>Tbm6^-; z{hWKJfN0!&KCj;&zZjU|)>EGIoTt8@+AQazVo64AhMFNBao!*vxzYKSn5C`TW~ol{ zhmS1?ej|509ZZ@cmg^OiNpuMVa*R1fqeg>=8oLg+TO4OEPux3w`re7-_s*EHcl_`r zONJLOTBLkDdixEv+eeSyF?Y_k(W|D8mF^PR_3W9a=byh)Kom4GXTWv z;IHJo0||fRZN*fIckefhz4$*yL_-1B^gQBGP9PqoSO${Svx1$DJw|a)uqqeJQ(ec@ z(vBSV2%c?IGC0nsQOR)FX=*ziEG6nl6;86-SnG@?ugckiYi{_C6T`#6|Qr-3* z-~)fFS85zjCq=K}$1+>tv`;dJGWJu=LAl>S*+<^k-gNMw^t~fcX}$E6VHo{52b^;D zGYq@jfN^PRW5 z^JQqX=5II2h!V%(XbAW;Mec@Pe(0d|XxCPDX`$%64edLF_9^gpmDzofFDb%J;BFmC zy}C$2A60+i7%e!gji=WIXcT$p5z`h*38I9b#z#!e^ln9$7GSDDd%2*!GCs$8&N8u{ z>x-48K8NB5q`7~;q0Ctz{;vH&`c3063J(DHvveVHhHSc5yc=UdM zsIW~#R852mOzJO0Twe}02dx+{B~=D^;IgdMWu%q@MpnQ{th>6d_vqJM{Swlu=}|=7 zXa^;fxc=j{Z*-I`z8ZBveytnsuISx)Y^OSHr}_*l+R2XXWTWsbQRt1hfSt-}@N6r2 zeR{-3$g)8Jb~GiTbI}iOGD%zy^#qnO*vUxrp~{E|NR?$y8lEm*M|5?y3#Tf5CnhH4`Yr>2V2&i!LuxZ}>;gLPY9%AQ^D)$^ym z+7cPQ?jPr0TqIWZJ*=!=|9+GDqw}Xe*>zKw9bWb3dgf?)fBo2rNX+6o z$j6tX0n>@gYpD7e=`G&ZPPW@JGLn-51EHp6WdMY)ch980$$bM80}}~S5XMDBOR<1J zH--esGbHmuI+v25$6{#$QxMkqT(h?uITZd9X=~fEtcIGl71_()`1oTVX7n+v`}7Y! zetztM!ajWp2lY;SLXA*XfA`wPS8n|7)s3&zM*;QYHq+E?cUL$^D>dykep~<0C_Xyn z_&vw(!T-nGZ`r<`^nN>gP2MQA!fFrza@Onzk#74zfyaK(Rl9#!_Ne(-hc~GEkq6L< zu|g;hA+1FcTV}$IAPkA8o=PwvLRx|W(ZV_QEmxeGYzM={6LaL-Ek7T>Jei{S|jSK9^e7p0fA(Xs|zWgLS1`Lk?ET9C+X$lJ^ea-;jEhFZ+e4<4s_BRcBU2F z_5)U_(+P9aNs)(6T;?U_GB`;dI>B?khYjh1{v4MLsnTOZ>UuX`&V*F)O?j)5kKIzo z-a(fEJStX;S+#1mnq8~pt3C~^OKlwCt1}(+`@t%IZ@JN&3ph*nazlD|9m=WU@-6tEi zK7>z_%`g0oebPhw+HVn`*sI>VUmA;L-1}q6frWkHBbt+Z%t<33OHcdUwm<2+|DvDI z%PX(?oNxZC=PbPHbH4qro>P0(=luKno&%fI67?-$Hr<7_l?^?_ZXcAJli3#)TTyt$ z1Xdgj4oSvKnlVjBjA928)hW^vg(1p~5#Z@oa9Hu#l{l&lr}LnM{xH z;KOxcz@NGCBCLv>q34^k%@H!gc82w+4j(_iY>Bu?8t;rrLjj?|=l(us%x&w(HoUlY zbLG~y8&=-AuIQm>D(+sh`6gymHw;@=yjA`&aOtGt+3D<7YEd@^w8+S&@_NSixmcl<>e zt9$?nAx=mIm(Lf9;DeqZOcmw`i-ZPYE&ALl+$P*9?6hyo%}Vc`6dy?z@qzGht-f(y zjU4QE$B_ejTeog%TD8EJ1NmF^RsTibSm+A_*A%2I9&QY%3^t5doI3F4g17q5w5Cr=9QLp}?!E2F6Vj7r zNT%M?vcjX`ERlz{FUc!H*0&TLo<048Pzb15Lz~4od&^Lk!z_{N8$(;{VHQ@QUS`qZ zHC6qGI{T?_8G1IgsGC%WQk2(wdNv;Psv*EQd~r&_nr#I)4@_M=Lh&=O7VFIZ^Bx|S zILR3`qj>n4x)sU?tKL=riI#r-?y6Pqvhbs~v#@tpjZ<5t(BLV@M+2|t_>`b9iOm?) zHo%r}vp9 zWZ?Q?qqh%9${qXI+?Jb?4e!r=Y;1H|$dK)CtF7Xgd#0Gf7z;B`xo3>Xa>ln6MlLCh zv<3CZC|we{hVL&dhP@`e!+Cb3+qYovW?yhKR`9?;H>V#`_X%EIduSw7FvGClJ z_zUy-KkoM=UEg~IW5+ukugae*ME9Ui|m662O?@AD+9UB08U7> zCiEaVJUoPO%?X8FOo5CFK}$~s3kyXq7?kmBmk&QYj63i(4}L0l;R6#}bKk>DXfP@% z;6?}Y)ZBelP8Ci*^{rGflVzu*s=d{ZaB!u93zVae2P~UBe!=+qrpEg53o0fr z3wZQV9I3#O#wI#ay(~b@WYhgC?N2;jROzqUXG}VCwsHpkpHZoP^#l&F>FPcHl|>qD zbzojz<@3_hJ;pWHniuGU;~>Axf-h5sDHn+NCW8!*LkUK$Q;J>~JXmlwP*5}fC02^Q z?ca(9wMD!r=c!rFgW^0EiGE+1t3Cj`J6;`LX5-K!k|QJ zMW+##VlbGh5SBu5m?lGcU?5Fqpe4|f11~qUUq7;{G76=JFE$$=*|a?;p|D?WE}?Hi zUaVFhbpEJ5h|i55uyG&Ul%K9XnV!!cR8O;UhV{-<&iBN=?0vPx`G%O|IQ><6et!B_ z6YnNDeU{K)8K(^8c2#)Y>D8Ju+f0RAAjr9h2(c3eSoQ;5PGm&I^Cplj1aTA)Tp>X~ zevto3Prv*j{g&=je^h^L0jL1|V*V}akIY{gnxEeOG@X`*rssdDe$N88sy}&M1_P3) zeyO+1SBMaL*;ApoxPrhJ^L?wFzV8)h6cek$OP_WHR*?OoN-kNf_Tzd?Ks9qN$9pk&L$`3@#Unc_U!;Ae?&a;eqp*}XL_OZw&OR_6!}f(C{a=C)KArarxzBcGxOrZMDwPO z1wyH^QU0F$mNMWIuN3BD7+Qq=!b$t_i6aK(rl-V(!}zdi?eax8Oe;0|`t98=hM1*N zq`6ppA`Ibj7z}a(YLXe@NR1AP3W!vIRpeI}jHC|V7yv~0n&$zJmxWXbAt7v*z(Ux# zEt_vzHLtF^s>q(%PjkAo?%ce8%l`FEt6FYqxp7I|%6Thi&8V7NJr}|8Bkh$%l>_qo z4apqR%a)K~&Cne(oT_8>&c_6_r>$9S#5+C*=wu( z!L}V-SH4&M4jC_xBJQIGZ+hCU$9=HJviV?$x3>sLFYE4ZJ!>=R)sRxW<9(+!kafqCZZqE~Tm?Uri zWOjD0%T>v*3eV2he3Gdkas?S^^ai$+@7!3Lhp#Lbzzj`v1SiD5@?&`$<#hLBKI`r=-UqekJf=Ta zJ^q=RM6b{erBTXn+Vjw0f60M?~HH@cV zve<8k$x>#sLNzy|qiDl|G-eRY44_I{;1%aCBt>NN{Q$Ir;xf5bc?+#NXRGs+IPtCg zbXKgMR-4oowpx5o`4o$CZhF3YOq?iI;qN>Y<{0ds(Bb-~jO;0Ipd*i;`tmJJ~nNDs&CLD3N=9+#-k3XbdrO(D+EIbsh>k6UaVAZx)SjsSAn<2o->$ zI;uvpFXazB@KYPrfE{Wu`*sKW0qfI|1OyHZgV>F4%!MABxcdR3$!2aL>%WMXJUDE2@qWd@cYnf-KBT*X4yV-Z0GhE74? z+&qd__j3Jo3B^HGVXgYtUTJ;Asm^05gX3A;TIc7=lYXCl|3xQ|+?@;5-=+77*&8`9 zLH*qVv8H72UiD2cuw2n`gJ6=Ye|^0&K*Z7NI{HYr^~zMyIT%3x4|ZRz?2KBjjyWra zouc_X>s2=`mNYO=k#tYaFWDLp=^1m40&04Lc&nJRqWg9^Z`a!8bI`>IexZe8fQLC? zVi9vvh8TuA#4r>qd4e8?a)}{sWB`thRB|b<%n6r=j6DkElti^^7>^l-S?-$|>iwkFq~8;L^aZU#Pw%y#u>`8n^2YvKOFO1*&*O zM@7K2<0InzNl@!}zycz35T)3HyCi`iRV4DlNENUKB}YY2y#z%4BR0OX-QE%ot41n}-}5_lpE{-+dwtQv-C6cQUSc23 z3Gk7}o-bXTh`tw~?*@3c?E)+&1q1T3(^8X@+-?ms#cN#KtC5RvyX*C8pyS;Pk?z`X zuD!Nv14aKjt{kZU&c}g~U>{#?@VuNI8c#@>uR0N9rTx%M&B#T9^6iesT&=S%Ew z>~l2%xR|qr$*>$E22NR`Jrgl)5!erp;@C)KsW?yYsG)HsDBvrfH>_G`(%S!?)=E$GZZX@U5v#21;Fm4 z+6Vl8=DuIA2;&Iv{2Whhm@A&xrB2L9_5x~l#IhhD&Rx-O$RsE9e2OY$vCOy6jb}d6 zWM=doS|l1w)j^`40ceWg%SOp}KBCl&hnzw9BCWLZd=o>sOh$U|o+(L5G|~tJp;)THK!c|79xRfhlnl)_gtX-6 z21?qLXo(>Sm3{=e6T&&9N;%R3%}AsQ!O(z^g*hLRA{4yI_)vrzbWaf~&?A^Yk~q*c;}F(F zP1N)*RORYzo@Bf!ZlX>e{l<>39eE|8^c_<^uT&=)NgAw8IeOwf@!FrWp_fh;%<>}4 z^Ay%YtA7_$FaZ0A3D$m?W6?~h)Yl}MN&@_0=s>t9k%H$E`N~VWoFz)}JYX`Yfr{+e zsFofn--1b;@9*mqNKqofWbK>4YRKlx1V)u&=!I!eGi8%FneGCenB;5TV^6=z+Fh7O-qR=H&CnCzS}V;5IWS^|>g-#o{NI(KoJ z9=ilIJ$6h^_85Gdw79%9n}4HY;)ImEoIw8`VV%e})jumYRU_NH;=F<}1KcEB?4ov! z=C$8M*P>%2bnBaye}l*R6Y>tlt;3VZ&7z`!tZL9c<_=ygAct2WR#Sl{tjJ2a$z<|3 z`G>$H8cch*1a*l)Tt&!_HN2N}cl%}afco88wt7E{l?~9j#T9C*Sl@mVhiyP@QAEsr3REMo!DBY(dX% zeDJO=c8)Ee=N#1E?Rw6yiFKgoZ0hnHk6rQlV-((oO~Hx$u#s|ueA=ZOVvM@$?@eyI zWl=Z%?Z$42=Pd8|TDqj%%z~tE|udKUKNb)ym2+p9-hm@V9y=wR-c{XKHKDe7)KAx#Zcl@#EW`U7~-oTYIbtLpzK-5r~8YT{goQi6I*Rqu2YIm?cSA;zH0pg*1@7;L+{o>%W zYtEcmbCy4MDKkk$$c~gKdVv%|#4X^1qbH)c1g^nu%nNlfvMd&#ex(hSuw(GZG=vqTiBeP3w@ppN=N3za(l8|+bV!)NpX)WpKG%G1IKLFAFN4=h0@gCS{Mk>cV>yy{<$wkeX6Bs|(s)$A3f@ljF zIVvQ5a-d4?4iI<|9c&Q>V=Oi)w{hGWCR+E4R_CAgJO3m;DSqt?-|GwzVQt2kiUA$k zvyEgE!Zsrys1p7+C~%!O1zfBLd6U$t>(=5u{ISXj{zyXu_PWB3TnHm4pf8?q>7aCs zw;$P)0xUl;`V)u=0o}0{gAXQ(ychU{#{yYS@L0enDfk5WP{oUIF3ql#3#`&LxVG1{QA;P@rS+Q*NC;a!~JeQl4eo++3VftF1?!j zN{FDe7jrYV{!*VVFLl4L)5{DzhS{s{_g&F`@mr98iV5tLO9HG$v;?iA z+RTtifOaJa{uX~dVg*TI0qzA9NDGMOfc{}9Ac)XE%s9T!k!l=w(9eO(H%fD8U=AF> z484b%OwQK~4ec*dfvCP1As7=q<3sgb;3-D^If)ubqP`3w0ELi5gt-x`kar^?JiZYx9~JZH2g2iE9q@`bo5=je^ad%-3|M<;+(i^~tm7&>Oy_!^DLBVj^lW7}j0d zum^M4@lUo8_J^A}zsseB02DKL@7Q3_;HL0NIKLPzOkj9j_3o8v|tVDuPK)Lpv zBHzpwsx4y@xuy*=A;p^=_9)WMe&RbRS-sT1$`?G!@?J~7G*&=lK<`3b#|yA0;a!MI z6iWQzN*_)1DGF8i5O`L+cu#GP>Y23L~5GTZ;)I$<}OeoXDXsoFbXj7-A zVG=Ax`9-xveZ8?UGA|v0j&Cno^+w~s=hIQJ}NV*zga*{b2nD~4ZhNt ziSGGOu>47%M4D0wxRE%TwzjKyZIOp282k)=$Z7>&F(Uz9 z)n9#mEt@Ib+{QL8P=ABcS7-{`2zz3oPUi}cTcI~1;u5(>tTYUCPy&QHARJst?r|=1 zhZj^Qzs^Jts!-#I{u^0*ugsLopDPI|nZ4A%xac30;$=3{R|0PfUk5(e-;mqti{KrQ zrNxIY2tw={O~w!Cm-v8_k|a53n6Xq?A{u^KZ83ChQ!D$USmnG|O=@JXtGQaUw6)Rx zwH)qRmTn5}rd^Ju`dU#m-8ukcL=6knRw~A*niC!X8Wux$OhXLpvOq8glGptEfkzLw7m>9G1c1zXiIoJSMxuPpb?WbLF!-DO&4B^5 zS}n9rX|q61pr_?t#Njik-K^dTz9AoNp97x4W_hifZ@V^-WFM^I-Pk`yDrY^ik9M5T z$w=jl_i^Dr`dp1v9`Qcz^_=rvkL%BQ)cd&CbIy4m=QK<0DOMiWWLJ{q^>-F4zjYmR zjBp=&%yrD`-lx2e@%I-iPr8nI-TQ=gjK_U6i^G&)f#N<8cg%h6KqM=cPU;s1PBL?)24G97iy%B(4!9ceVk)O(t6H6$27DBVSW@h%g}$2pn2^ zGet=n$@M_VR|JD-&?6$@m!OH+25^x ztK!|ZHP0>G(>!a=_#;cF&x!qgj_TkX4M)Nh?Yc2TjzsScOVDD{}g zvBKTEAB0RH7ox2kCI{TCt+T(p=lV`Fa)2b|mi|fuV`|JNC+MMc)MmA|v+u-k6)%hpY z6W@HR-hVbSZd1SLze8jUd4-L;wDk*hw)(HBzq@~-G~n31>vm_@qu7f9z`u3o3x5}1 zmB~k*EmYelMWA$sXu{4f0WnTNZ$w~?z`hO{6>ew38&D8xG9M03h7+~e^TZlR%dsF> zKn|q$0n$=@baqU3s5#KzAQR|-FOg+e{}F}?S)kn>Wqqm--@2~QRTg#PZQC~ta@9b+ z>KXB_Vb#O5YN*4jhiP?C)!{g%6-&hztspAMPmmFxw-E7pnyy7UGh%4HG01-#kqod(=M*|f>#RsH7{72^phK>*zB2EFgYszTqzT zBu3kdL!n6IAwkg+VGyLA*mMA^2fhajks`xfEwLIfwjO0?>Cv37Jaa@Ht*MvGw^PN=;0!Gj88kxl0V&t;dHr&N+| zrXJ!3x|^MqoX7`ZI2-Q6dAo)i=$ia4eWi`B%NP+6SSXi2HBEG$=)T(>X6M~{xBGV* ztcG_!!E!Bn+nJpvntxCatAAHJ@u?G+ z;Fd_3_NgEbWCFN zM3sCWR`?rbq2<@_#O_AuI~{L`e9%$m{H#k;_U=V%U_;m2LOuUK_A#`tr#{Q2y1$M0 z+iRSI1X#(u`fb2SD*_U~khjBkeb~wM5-3!@On0bwkGgNCQHO~cyF2f&`hug3#$Lis zL2u`y|ED(R_iY$|!xiIC^2ZH&jR$SkO+pL22)xjPkE|dk*awwHs_@}sn#n6Y_?Ui; z4C0y{G6a((!t_tx=nti5f4WI}R`k(6JGIF^4iMNovBxm(g7NT> z&OQh35X;4gm~~F#;wI7yaBxVwy!grgcYYOVm!lqUcMOtV@ZveHKK|eMRg6CRJHUr9 zj;L)-JoA6yS22EmF#&!RkDVYI@d%krvWH|QhWR3#3jiu)BQX8{+eQ!@L&()Jwpd%B zzYxu$JJ|^STiby6{B_y{oU^aj7Qlq})iVaa*ClKJcyXJuvfzDgtixnuuLGi zi5oDl#Dkq|4@k83Otq#Zru9uy0300(^9;mO7sx8N<4~b9n`Xqxw|T?VJ^V;^2P~*$*y$ zc<4}El-*P^x-s+Y_S;%EA3V6Z1p8oHVnJjG zM^+t4%$&b5HiQxIb&`-oFbFbXP;2vobPiZYz<1qRfyE8gWOgNXkbo$jx#{u2b<6kF zKD4Ov{&kyozB}xOAxCF(cqX39@%m)LUFwB^sRI@)4psYG>yE9Sd19Ga{UeJr)el{2 zpX4L#1N)fCCP=k zNhTnh*&I$LAM^|6AqR`t%EF!hWQ)#qY;D@SIlrN5dO=oM#gI`$$K{GulCR^ZTCw2L z8gb&W`yRPx+TrQh>Yv5@(|I?oYQ7QCFiaQ;+&#eGgM-bmPlZ!~$O!p#1|<@EnOq9w zXI~BY5=I#UhNKM-SJhCEIft?57PBEnC^0EWSOFYMJc0SUvWJiZY-6^7PQc+tDPO&KA_%Z8jAc1gtAeUmO8=LS3)p_gvJ?-1B_@^VnTEzZm}T9GsTKu z{-*do`KYdf3M*!7mdsPtQH2Ztl5#`QO0F6uC9OMf-~b!=GmE?R+rvBAEcMO@9=Ltx z{z((}Oh5L`ii4ZPzr6j?Jug-rT=C5}Y&VM5FIbn4HZlRxIdU&Pc95JS8B!u(sZ&t9 zuYqd8k=TJW7X^_PMAg)G1oN4=F~)@YTqeOpS`1Qk!w4uK(HvssPyx9#dXPb&#wO*X zsai!H*p!plQ-9iZyW;$}s6-yx@o9v#NPXs}9{1g;+;xa;dsjUqX6#t8b4B%pjmvX4 ztvK{ZPve_#+ ze`B%g-yT-K1TfE+|4`RG&B}aOnNs5%zw-VS>R(yhiu+fJ4>~uCH`6>?cWT$}$2=69vWa7*Q6DmddYc&w@;i`d{l|{9WvU0Ov ztmxCrmeM0GDlv}=_vYV6)D>UtggDFblU$dSo;a{~p{*h>zkgw`L8;^WtGCdhK7(xI z^4*6j$RI3YOazR{evC;PRjc!iNOBTT0j1JtCXXhb?j8^_{`rqZ+Xmn;z3KqmG1sR4#QF_Uce8!A+5~1&-yO^r ztBd6QiRnqG@~Zb8pZ(hx#I!<{Xp32e-&i%E&}9OmBjmM^^{+Q}kuZT=v5OL0scvnR z*Qy0=>OeUTM6yOWD(A?X;VmStyw)2mz=N_gal$bx{CVVf^ z{Kb-$(KCOPcH=c|@J3Sh4dvid_KjX#H3~!<6&nSP=b0lP4$MJ$MgJX9675O*b=vbH zv^i~Uh(&H?;c}upj*T?Tf`M~}9X?fxgOwgrX1L>EPq22$C`}a&@+$PrwO+NpB?wdO zRo=ZL&g~{j>YWKTRc^rmiWoUA@Zqvy#KloZ;}YT^g>>z%-?h7Y&2`AT3{k2ZvQv}v zfH=hWMze6~5$R1~B)p7X@$;e|?0(06zC`7Sq3ACYlGu&%4(St;1J59E3ityyW~mu0 z_bp^ME<`yYn(sS0=Aa(TE_jea1!M%v47sFOqF(WrqTE6|ZR{s5AOO&Y znt2I3fKXc`gj7p5ttJI_4zb~>7w0~C?DePW9^K!1hEuxuQM9wD%{e4mJV7+y{#Q=- z_oFREye+WXxmx0id+fd$=%?%TMk-Ziw&L~Ju=Llnfh-K~W}DPWc>BM)dpE{lAaLD} zWBvZGV^6gfxVOwC>U^Mhz&W3Ow60&={Z*TKQSUP5C{uV?E|N2eU)p=otYM-E^F|XX zx>zf%fZ(^niwm44f)heNfjdpy4Xca(NKMVW`K?B=)OmJvQ)Q5JljGK)%Er<3cjH?( zi}g*fuWBCNP~2SHFsyl%=2JlR`EY4JXer{l-cJHnm-lO+4f^ZV>37t!eD&2)3qzSx zl|vVdqQ7eIm@d}W9-Y!$Rn%Nm**xVacqXDlrA?T1Nif?3iCE#VLD@2%@yc*r3I%t4*}l#mCep_gY^p`5~tLW<92TrFLFish!7LE_A{j`M<3d z4HxN8qyf=SCp;1GDaeh|Imluog-Fj@rF93IE8=^GA8DsX6v3gGchZWk40`8r=$*pB zy*3a|Wex0YHB(<)d>i3Q3hxI^QOz0f$wG1jx%i}~HXDZiuv#r1GQNYA)YskzHYJGK zphK5-48F+m!^sH1fS1KrTuPEmfQbU?W4sLK*wiEKrD2qg6(hmZ+~gdyO#AX$TUa+yf5 zs&ewA9%B{_$;++=pzPgu7>-BAHb#VuAN|t-(}F#%c!cPueka}K^ChBrlI>QgTs*Ii z-aubaq%WkXM& zFE9VOX>Dleid)(*NzUOjGh6!27_L}&Kb$9}yA7Ms(iD3ljSB%MgK=Y|G`kE^jI@q~ znt4aU0S?ATC{Mb3$*x079y4s-G<@2{5ylg1w&dcf@IpyUhq zMxbEB$*R@9#=~f@H{&OeFjM)43oVhh0k1uuW#?PPK$g3^ZtIWg--oa%>bk9Uo9+dN zVi(>StbPx1_o5gR?d-sI=yXK0uUvbgv+*k$AK~eaInwuNdpbpvBwAx6qaiFbK=Q#n z#>I+;5_AR8#R%m=C0>N`2=FEOKxBingu5vy2p*GNMW5bb+35nnUV8V62v1GoeFz|E zKCR`sXfiYgx5`8@QV=F!f8|-^NnDGN{;1|v*HyfCX^8p?n>oC&D$S)rjhWl4x^7BZ zfrTqqKE}apka}bY8`P#g7R~xXS5PDEg}q3F$c-MIp1pAF|KS zKw7tv4>1u#j{-q3Fi?_sNldh891YJ4D$|4O5WX$h^g8FCK#4;7NlKDd%_X#FurKW_mNyibA%t>6I*^#Cw zxTNTcf);BU9U(!YH=0#8iBAm6NwrC2jABpj8T1p2iBn%@GtN4{5%U{%E}pS>B6@an zoEYnTmi=qZj0Y#8CusGCx@K{8GxcO`bmQqdh(~oB#FKR=dpP%1qgI!j1_ufY?en6e z!pxYH1YZ+Y2kfM;;4=W4(N7xesS0R^~3>_bi<;kR77!3lc z{5;N2F<^ho%}(!=lGG#45*`#_RD^+SpjO?+as?4`3nnK+8+H{b^+=8Q|&S+_sd;?nnR$=SA|MAu)r`e?RHbe}Lc3uc5 z#9tNprl%u%5#5fit7;4#>tpILC4UTLC1+!l&rJC zpx)a`_S6tRLwp|#)FYkYP)vOm@1zC}YGp86+9c3&zSa0<%c)Hl_NfQi`Tfj!RxC|T z{e0u^=Whf#tAA&iEF!R?V9)5r${_XVzB9v^wYk07(QII=iqu2>-`xO$7TK_+R_8;* z8j9Pser2Lx^U<$ZI68XLo?s&`1LKQW3SKE-bs8vn2$A)CKLBE@p9z#M!OVr~2poWu z9Qlf&JUKZ&F1crN&qQlnQhbs%6I~1K>5n}Debd5pvuTw=7v%gVi>nAWRMCaTk`KHQ zOa7ZJZ`U=UD=gyEjpufJvX9-U4&8?}27RTMZ5*jEZ2EpRFt99t4LUca;H`D&5vwm! z-$5_in~ma6&}JO!hN4A@Z_}P}0ei+Dv1i0kh9hQ4-zAL90FMHyP=Ve2(*B@>KJp!o z_P8te2aWf*dewP97^2Q*FHNtj{DTU{---M0{(3i?8m&HqPi=pz5}M`7@^FFMlLP#* zb|h50vV>tS^tFR9R>}+jF(MZgYX=gUR~a8BAdU-y3fYsXZW_??G#cu{9NgUDa8|i> z+T8ipAt^w;;eaPOTiV(lK!)Yn+8uMXNW(_nAG4Fiw)A)y8OcE;?BQw*djs zKBdu5BwCIPtyoY!YRF>Fbn20bdSE^wVFK(A%bdh3t&Vpph$OTpsjpX%2-C?!3IqNh z?MPi{L*3${i@#19!DhV-2HuqQK#fO`N!hR3_;U%7N zNPCw(MCY2MD~3YOfnCH7(Tr8A8-}o_)fn)}v#qV43|7DBRxsm2855d?!*aSj40e*S z8r~|()POo6N0Z4yRO{2QUV;2d>-!5aMS4|y z3f%dcx%j%oF1_lE7oR$KkoX=GE(7f9QR!v0-EaorDp`G)g*?*mxuGP3R5t%V|8e~< zy}Zcv$D)h4R}#JuYJh{=$n8ThWF;d@D1&<(QVV7SbA<^ZQw_Fx7(@|mDJvDI6a+iB zRw31&=O|-~_XEDP=KO)j2bwe7`;b0!y`e6}>i*^B{qe)@b;h%L{SUdZEPp_0>45w) zwP4pS_0h3;1Io(>wgA2vduHa|lO5aCmF)9#)Av89I<~S)ZR&fum)gGC z&H81j#3-3?S{RCAfX5)|8_ri3Z*|1I#o8UA}6`ywwmXE0^r__|S>?}LO=i2)o zeXglcL9X;Lu-U}tr#OA&V*q7S$E;zWoWl719+UjBJQDA?-rOG|F=~fNq|u8KpLN=Y ziyNHlB*hsok5r#JqrR~PL5IPPPbe~v&k^47(SQ98xc1R*K0W(}JJ*RDhCA)z=k$)P z>Oa+;Ea!}~mB;>pMg`DlHmA`W#+*(^1c$ZD>cl3f3@kJ0W^BU%$}~Z=ifaYqG(ray zR0r_7VzNDg=l^#}vN!2T_FyO24*}z6%@yxu3F_x}tDmz3_B>jct$wgWeU;_!V7(nP zb>9#h1l{}r*gE@B6oAM2ZcTu- z_wzU$e6MPO4+ZMFXMc2WjaPLG6}x?8o&9Cf1Kce{;&K0cO=wnI`VY{=ap0pO$Op@fl535$$uN?_y2jYYHEvB8?bhnIk-X_j6+@R;0Aoyv)-%2*=~YlAVp*)_lyO7udlJg>NXs8ADnLM`ogB(CqLK_o z8fkN|bnA^Rg@w%v7w_qxwR5@ty>(#HgnH+{cI_&tD2%ZTm>|aSFa0MD*(I|DXO=8| zWZtHYOAcz^nm3MGIdY5Ip>|X>jmTI!P8!EQNX)>#lC8GOjj%ICK^9M^zJ_2L`ozZp zi;ApEr_!^;OIi76|e&ykp6=@ZT$mpO$6CSj7C20Ooj_UxT9>NMRJ^@zf|maTzZA8jA~)D|pC9T?H0XAI&?Md$hrK$Se6b{(FyM8N_X_Z#;KMaw_aanps^f=v?{F5b z3(s!zET&(#pNiE@Z?0~eu$`<0ZL8mu8&|*CxBf^3J`YOb* z7?+T*r)G4qymvB)dl}(9rtwSvbQ|LhVX%>Vd;_9EWsX0t3=RWD$Pp_H)kb zDA!$sYw!_#G~+h5GB7CQge!~HHnsR^^-LQ!`jdv2S}(mW?ap$1YY5@be^{-SIzZR) zLbknMoDUiw2!jW7i_j3Hc#eil$QWuiYc_ge2nF8Z+W6$i-de;b79wU+V?;J`4j5ro zhdqwAC$sc&O6VQIv2wjeaLjMvNIZ^VW1Z&Y4wrs?ZWWKecRHTK~N21of?O z!Xv;|>p)&=gpfq2G1gGf5zk^n%`NPv#6vKJU?JcyLJe`DibxBf z*^?rZQf&$?!wAaxbZ3fk2AK+T46ivttwSwDHu?vawY%<_P4!PNpL=S4&7Qvg>ZeYY zP_wCJ$?980`$naH*n8>&s^iFy+vopo+oqS6Oc{28#SeG>^d@Cj{FA$i_69Zi!}eaaafq zs)!h>Z1MOFFYcu z*GWhSOuiD{yW-FHR4+QVw7>Y|<$o$+e`A#_V$-x)zkTB;T<`Itzz;vv3{hMe;n#TsV$_gAPC4S&d4u)_${N4U8?-l29{F|N zpuK@I%dhhWdc*Zzi-+sk=Sfu1Dm+Y)^K<(R%pBN@7p#hj3hNQxBfw7%RYLtqRkJ|b z@GjQlPCv(5zy^A)bJBN<9A)#F-;9kTMr@oheN%Dqrs)I6j~i47G%S8 znyauTqQPT&3wen69#8RTvCxv@<0$5gfSoz@1Q`M)+3XuE`Iuy%`6zk~3esY1{SbYo z)KD-FhG=AQ|NOK*2|WUEZft4s_>tp>49XwTe?(TlK6z<*smVQhC-e>r@sAFO*0kD? zE?TXgp-(au1}PQg9c&6pcDa;ZtJile44fDrzZ4-2l!&R1Zq1TCdzRE6I8a}a9+H=r zlNS<_CqCY<{lhF)uypUs^-lZM{09e)XQU>snjaHMY4v>*|X7jLXR`PsbJf>!M*p z=2Pr}`mr=jcoIIxBZAP4Pja#No-|B8tDV2XCn*cZur~xEFq^Q~lCZuht_rC^Sl&=- z*nB^OFN9iH5BL|@4=@KXAfU=XBn^L`Udb6L89m}6lcM0;@wWt6v>_+xNN>Y|_n1P_ z>2|E8%~UF*e_5IHWBp5i{Zrhpf00Lz&FepIT>reWu21K?u5YeSjLA9SR`HU2J7TM} zSmR7cK1K38$X!v_6G?(Nmej71B-*@VmxmiZ6d$fw7KmRE!t^MX!P2{q4Dlp9P|cHD zCr)auMkI?`wSmirOVNgPXv0XuSTFtxyOCSp88zm#32Y@2f`Gv4u^D2^^p*sGSaB5@ zVY1y1OyBJThnRq!0w$)%4vFV?!4`iX#w1AnPUrhUp7(3}g!De&4}!gqvp~KdY({&{ z-pAeh;z&b?_i^m)QTp>kJ0IuInTh9wd5)tW_q$_Eenng&8z>nZmpjEJx?+{Wg+zM~ zj0T2}41b+5`Dk9L7P(i15?{~SymmXMT4Yl7?uiq3({$p=WM`fVdU}KBIUshOT=+oX z0!%tQZ0^MjB+#_;)&9?KzIVEk)nfb?zI4 zQ>|~hL7E5@Y5^kkuh}<*L!&|{{6`w^7^bN6K?ca9o9mR%K zw@#YaI=T6#*BuvKy`!;)c(g^HEBC-olWI?bhTkbbi6mV>Y2iZHso?`6d7bA(Vttgz zv1&o9w4<%f(GECAZLDTOn_r8us`45WN!Zt6l+GCrK?0&!g%S3lZeKA>$uO(x-eMr@ zlAp~F!XoVT_<+5hqLqYlt*R>dZ0Nrbf0xHNn;dPSRvY(4A+9WmM<-g@w$`kJzil}G z;bL`h7<*-OtNj7h%!;gwc09O@SuZ92VH^9$F80n&Ho&HSbNLSWg4%DFiijMNm|MZm z6W#nAG4|XZjKwe?Q3!|$lElgUizopO9e-D7jQmO#u6sD$tomx~T&&iav-%$wyMFo# zc6}wArnWvU-0Y4~FA;9BZ}eE*65TQC0}FtcGO}3o^)r-`FyM}ZCHq@|$MPl^d=RZJ zn4rI*Ksyq`eSQ4!r^&ZQ@HP2XWb>f);Y00%`{!kqWS4XoxE^#3Q$eca8} zBe|~{%6`=o*BRfwDY0)NJmPQ}yG(wZ7F@BTlye;<*zJRSjV8qhDxh==VexlfE1*-x z({`cUa*L{ucTk_7S5P0%xS$=T032vvt7>g!uXuPd1^4j)KM#-pKZqmJIKGSPyW)sE zb^lMoNJ4h4P=WeMziAwHVC%83dvx|MO|ngL~EZOr=29< zQodXuKL9(CZs*ra*fmS%9iyd4$CvU0YV>_-^gUD<4ewit>(g=lNJE{6kKp(k_9r<< zc@?tQC2)wL6bA}i>Y=VS0RQhB#5<3ajD{?1GpX8nF;%zIpmub}EAl8{l}MP)7-6#A zSHcQrrQkCqV@@YRc7T9@v@|l=M3)gHmsA5yI!1y563*>7#0I>NPqrgTO#xU$aEv*| z&m;#aK|F&M60x_G=DqZ#buG?<<`ol`Et^oaY?XM=gyqX8R5h$rZnIxrYF7rW-n@DJ zwzV79JDy*?xn=#Y9upU94rW4kL2vVfi8-3b!&Q?4R<{-Ae9iW%dmC}B_N zoe~GQdN;z=uSGXF<=JVjzPUaHxf3pXAIQ-Y?BhU@FeDN1x*n{L)Of)lB9jElF(!Dj zTSrEmWLlaA#-5gumeDu8XP=&k1pBYS*kObsz_(wPKXchw>*J zk!Q;fa2t7<=dFSU`xqYLXA>tUj8%nV!M6fV70|Z%1pZ}N-_i-fvoN? zOJPUS98a0pq4e1ZW(HK3kSRCIKcHvuW``&i_>1% zE1kz7(;QCY5Q;g zWJ7D6@WIrwqNKto`QPnlGm7Gvt)9x+*Q+1L+xrm)OgH^UN3p-*7tsOrpAHbG6iYZQ zbfN=GGD5X9ei3U=b|fbcj{nzMsuSDvZ>eP6Z;RWY=4aBQ5F5R7Bk7GR>h*lfakK*kah8dt<^`i#POVflU@>+Q%WQ2_# z837ZJgX5cUoN7?Q8$mt@idaH?IHjWEunrzZ-WUpBqAKZ*PjUkI$nUDS3>@7dpc2lB;F*JTA0M6L_s0Q zW`-G_mIWnY1IIcQ>N9@_<$rT~SQ6A1m;Di_`eM<^5mecG#QuHtk1bj9Sp5m-MaiU| zC|*%qynOiKCBLn&|LtP+B)bRuMJC?A3hy6jX!i6U$Ke~1M=OUQt8IonH^U>Vd2;6v zBrnJ-aZQk6vwT{a@hs1-W3KEv_*Xh!#4~2Vze0IS=$jAU60nYly2y|Z%OZ7*1`p`t z(8-9yF*DB|KtG1EDGxX=q4B=zaVDK^e_lPt?!onFUmc#k63<5ayxZMTE3B3OB>xS^ zw|b6)w%!%iDr0aQUQ~PUB&wFmAk0uAIc9+nA8EaL#m@CKNA%)@Y+)?lLiS0DIOA4| zaPL|y;qref)syPc@vqfCyL`p7%a%R6V#S+Bs;iH@xnji|N24U&NfSd{Sp2<|*i5Q{`ENXK|Xf}3d z=eG+xbAH?^KAFy#B7}h&aITEO_x%4?|gtW9j zE*Arv^t2ESY3McnPI#w6LDs~KKyM`oQ|8N`96^ZB1KkmUlOGIzJ#$D3H{B3%)5~*^ zVwMj>mo=P;D4Xs)<(xW<=K-7Dt=eK;OKKmJK;(dmH1GU|$ z&&cF68xRCc(j~SksSb=1eMYqqd{Tx1qYU5B5UMLdz#M`<^K+MSNAIoF3I*yAV@cxW z^n5m+zjHj^S)!i)fHkqE4|oCh1gh*VeU8%Y?T=Gk_bbOC5Op(QL&7)+B|%_>WJn@S z7bdLEd$TzrMPGzuQ{{7EHUU)LjF@lnAPs?Yp7RvD%*)5CPEmeMy%SUOA*SX|lyqw1 zh2xnWk7q)vxKZ9J{}Yx&JqHsc2i}V0wzq28o9xY6aU;7_uP$M2TKvN~@j1C&$$}o_ zi@kiHy*~yG!4FthC{==81&CsxAqxJ(2ZH|$FpbEtzGx*%facEQEqpjTr!iv+j9e-B z@s7E`q#aYINq5t4B@6KvpQtHfz2DZYTX~zkXx5Gh1oVO@=Yc%=680v~&UEQbkGMAGp8C1r8t&49V)6X*-u}9gw?q$j!KfxI-)!pF8l}F|DYdG)6x1n>Jj{5ur4osJrk$ga2b z0AmXSOUXzB9ZQsjz)BEXL`z&`NbbR^ZZQ!8Kq%+W=^1gk0^-4}=Lf@>u zS-sMGrBgI_5-@TC{1G)1&*HV>RC+8o_8?1kUqFw8e0i~oB&e3yud_)+2v=}6H#?j* z^9?JW1E*X$@8MPE_mLcJQNOx{{Ykan!Xm;m6W$M5_3*>x%fdcj^THa&s{6y1mG2$f z5cV3e*5?|g-99HgHn9gQo#H&|JTjG)#t)3X;r40b%c0B0mMsf=e_#2s(BJ<)Y#BMz zF>Y@G1N<~$mm3ax?*}+(mQgR@*Xsq(bLe*#AOHW@dlSH{igW*e=Dch6?aiJ*LN=DL zgd~I|K-gCyEJ{#R&?qRV2&mX1qN1XrqN1frYuc+V?M0hftki2OwX~(Tw52U|X=&wF zT)MX6BjgnQ^6Ug`U@BToAc`V05Q( zLB}E1Zjbudcq9Fw842mxxtMj=sGgie>Ot4a7Kvma(zCARnJX9CZT)+CX@1*R^UBly zaaZ^X{lHzv3d@Q-7xXR6{Lq)szP$a?Ir`qBQF-|zi^6N>S`S+f&kNr(Y;516!DU*u zTXf>hBR%paOZF|W$Ss2=ju=^(qVM^B4MfN@w=%87s6J_(Z?CYg2B<3bJic-zH_MN$ ztcx+5Ke+7$-QN9b4ccXwXGu=~5J%fa!))(3nVG;5uHM_`^z^Juse5HcLoN1*G-ecg zvWx2~iuL<8vwmnTdf{%v#uwUmeQB!4KXq!`ClwB#exN7Mu{k(J2d=s1nohj>a*w=9 zmz@+$B)}@OTL8l;XmF-kgz#?aJhw{N%%bO}ZbT{!o!Cy#G$Ty*-2%3NeQ&3CO87c6 z(sVD7K*Yk=buGu17~0S)92w7G&6oR<^5WP**&BHr71MHlgDu*N*dibrnGcPuIIWU9 zx==XM*pUmNk(*MgFzuuHLt*c{D;aaSG|@?koFY*(v*peM8JCKFh=YPiV-b=H77t3Hxv4y(6@4^vxCyEh%ids|O7h7X4iYmUJ?Vaa{( z`(RK#Oc)mWn(*j`A$ad$v=c^Gwr%iij)WnC(}%H|PBx0TIkXY{^kF=kKPg4&bHfn) z^kKZMCr)4_(G5fJ(}(dL`S@V4CK86=rw?Nr;51<@*5b}EOoRu%BTkqzE!#jarfe1W z6rO^{477%aqE{K(ywH`W3?pT$D>41&T!3iGN!xaBd*4e%m>0t1I)$;CPMeO{Vdxy@ z{nLkezdgNE7|-U@hVg7}+t4YDH}AAzym=?zh=ef)BhI93WAfw8lX`oabV{gu(E>8O z7QOcrWh&((LL>GD`pKd%dE+eM-C)s=v({`LO zEs}Q=LpNI7yci0in*_V$#SM?$YCC<&+-b{J+EqJMp_zU)A`gx=C%OqZz?_f_-C+jv z)?&gq#$dGHY*Z}Hwd}ZTE_k05p7zX#)8*r2qZGTFk724=XyR^1*T%HHM&6f^K!uWZ zkZM(@#<0h9lA5Mwu|0PIBcSsb(_hRi!%B81tX0=xQ|d<64{ld?se9S4`~~%hdQ|-z z*7yEheM@~uJ){0ZJx}pt(of9#bwvj6N*uV~q`0G{%1Yc+&-B{?`;0?#BckbXLEBnQG7Q$Ed3h3iSV!Z2U<5RQ+82iZfv^ zsXwS!)oYwMdPn_LeW*TGCshY3dw~15Q*;K?>iN1a6Au-7kRFP$z&Z{aj@Og)G(Aht z)eH0@eI6HRU96YumHH~ZR$r$-uWz(vFSab*=WIu2 zo%=1@2^Sk);uHT8*7YYuxqe(%{JGDr>uq&pqz4Bf6rtdMk!BGO`9q)Lyp8=7eQSsC z6h%Tgf#r=GGU~^Evj3h+h8YebMfLUUh?@~-pBefBvG+>ON>qsd|PCr)MP43D!V zOAd&YIO`u))9N2RxNzZvKU%$d|APw_Jh`AF30OVPMXY|B{A;s48Ice6QV zqL;A;#8iS87& zEPrA}#KZq%Xe)^DO%@|AGm8;1ro=+}fW zEEOnh;;}fxs)ydhsADrUF-PQ_;bXCuLwq7oSfwh+EI0*YM4*aU;Oov91jRbhn-syd zPV!SAD~m#d41`#S=_=e><>kN`Q=OumiB>0FY#(VD(TxuMu3Hk_<+=Tsk!H6JGnyI6 zs!-SQz1Xnxm?22C?z(mMD=b$F{lXR>dfVQGz=0B3I#a@LvOC2F`bHcAx>zg%&Ng>2gpez!Kc ztW-p0@SxJ+Wy52;S9@!DcX4rv7l{eD`$~)hPo9p@iyqm@}ch60yj<*GG zW1Gj^bRMqAkvFE^O1{Ra`3-Yfnq)ymauz+#$h?w|eY@SVSW|6ha`edRaW&(*XVpFk z^{KK-#eos_8M7Y6u7_-o%`GX(%_%MY6xnYn&9MUsAENb1&Uu!IbxKN=l^CyY6B)-8 z{zlfwrMx?x(Y3c`$T3Cb=uh_HMB?+L#o1F&X=1dXmy$IX=6ctiJYW^v`0EWDetqM{ zU*B-UuQ$%$GN1n!Y?(KYmh9vK@5n}8Mm)5*-xlxy?dW+{f$2LESs7J<}>6!c1AY6nnY!$5_k4!XC4BmQ-TrICm1sCuJsOrl(5s z*m<;*XBUlAacfK5^UcDSMw=&2ma!3d%;l8f&H^brbZ9648K<@@kdmAb=l69|kfPbH z8E4tT=G2##h+b{9v=z2i?da>|)$ABzaZJ|oqnTVL#W4?}u(DGUDmCZh-990bL+?6Q zU2m~P*(!+XWO?P}$_ET- ztY~c_K+7Q7nwJquHCsqT*)V*G^24rHqIe0vZCwQ(xb}PPIn%C|h(-5dD+YTPg#*U2 zuAOt@v2!fhH+h8y=!?W>&x`kJ4_HiNVh_;3L0nMjIS0M+)N5#-bJ~AmHxrS(lfP4kvD+M*5z+c9bJ*rIB05`7d(c>2 zz^c7z+D!no+#gEJ$x| z>{yQ3Wu>>z2^>0E-{NaF@pOFDmSgRT(1YNe%2tn51XdF{ov^E={e4g3KNI~6Tby{T zRo1Q;dffUXCO4$rDJL3ZB3gvQ z4sVA3_L0EI)qt+aVndyi(f6TIVkfuoZJ{KgCBKIMk$dxq*=$q9F&0P=jn0P+wJCw( zT2ls+YdbcH%P9jBm%WxHvmcO&<8DMJTb>>llr$asbvt<*0xM$Bk&0Ud3AWTkbZH60Rum$4ybl;wqmKJX*dxek(&ve7{ zsGWTHB;v+K`Yszcd$E&mGfa-uS-}>prmfLOO*RbQOm0kGJk#M_oEp$jE>a@Qx8p)bn>{L~6=_fn?xf@bMZn$O6DIaOqITg+p zA2zI-yKhy`>!dcz48!a=9dUBY8h%NC$B0dKzRKOL4Dq>5ak*GU=zQCZ-;a5*1i#$h z%g{m0)0=&-hIYFhBpGSJq-fxu|9DGyUzGNakMuz!Taw<$hFZ}n*&J#HhwQ4wBoSp6 z(?0f=oXD>&VxH-3Pg+m42+snC{@F$&ZWv}{!CGa~GV+Dp8d*-4W|9U{mSv>-2*$bzAx1 zi^q#f!p9zt{qw$CW1lH~nlu{H^0}?w5fb@iHZ6#e9n+V{+1da89T=YJdQJEqVvjVj zwq%ixKIrtt(Q-DEcrp#Z#yHa@NXG1=Nxhacx#2(l(z`!JfgIIq+TS-t;`g3BBHe_O zr>G3mf_Su%VYc5XkztR=zA;G-eE2+bdr8bN&hFXh*XFc%=94wg?v{Th2DXRy!9c&6 zf2S>o$yHEes3<()rYv)(libu7 z@STSwPt~Mjgz_BH6(294+c; zM~R(v(@hJvo!qu4udO3dH>KT5!(PI94NXZtb{o^AV7t>FKt-gpQp~X|Pf1Aj1pWTt zBJ&XRb7!JI=%1UOZi_&AcDh&%&PYs4H1~{k7KT(?7!ZTloZBT~1*MgXVS(LA7^yJw z;qbvO8HX26KTcaK^9LSI0#NA~(e%)^_tKRknT}LSt0F3z^^_x$re{JNqk3P0?8(Nm zxg>?`*$OJIThV)JvxwbpaP^v#^>&j>o{YRp{kC~;$}~YN`O%wusNUp*zE`p#du(yo zWXm)TyKGI#CU{LB#l(P2+PGJW*n(s$V&46xhDAi$>0garVsyF$S(%XzLs}Cb$zeX< z=fl7Y`SH0X`9v{EGxuO+O2g~c8MnUJnz5svz>O7-ejMqOXf~wxMnT4j;Zo;5iMU-B zwLR%Hx6;!|pTw2ac3bQANs2y+sCwx#x<+hzj7WbWBO&Q7xbo1%VUNvReM&e_r$^*o z_BnMqQi=cAxFuf05*Z|zo=7-NMGb5hJ~(3z!v~wkCSu#BjF>%?S*>nSIvn;sYxwJo zJM0mlQ*Ow6hG-V?aTa3?nq(O%1^u=piVKgK`Ge&WyI4(j|H&<;+B7$AF-Dp06lX*@ zr9e%gN;5$r@Gmxftml}EQtkemMuDC4QXI1K;IR-RPDODL3wHKOgu}e3(jY7}Y_uIS zP_jFAqobm^Lqp8mN7pqgsRs6%6?^6w+PiZLo##tF^*ljZ%sfHNs6KWcCWAe5rY%0z zI(4j3fF2hC`pGJQEEmbr%MfPucK>e8fB|cMw|n=CR}UC)^^3c&|H9nD!nt3#{`!X+ z3kw?`@{WxCD4tt*dwr}gng*uJ%^a(}&yTZT(y(9(){=(BzAyz}osR59o5{$uiD_PF zu{ktX?7@vrowS6fN4UZf!s%Luafo*3bW;=-&ZXaW=Vv%S&k=JeROLZR)>U+OzMk6 zNKyh8bF8@KiSbsDnww>MJ*x za-EXtcipR^VV^hPg@dMp$aW^avNNk|v$=S=n2jtN*XJPe%w{krYj(!kgH9&NXnQon zE~9tw4`v%@q;Ymo{=QCmms5vnvi5`#2`uC?VX5WfER-f$mk@hYNQ|+yCHbRSl-$W7 zIXef@4o)Nx)8)k_*-S`grd3x6r8?2t8lI`Y`=>vJpMSA^?W?c4@_5aQ`sqKlzhT|| zQh4Uk&NSI4A=5;sOf_djyVAs+P=+XRS%ffDrKAX5DQPKb9N&xcViU|z=89%^W}PQf zBOkgptu|nI`0A_H-7kipei7mvqF#M9Jo80sYkN>X{Zjk7S6_u!Nin?g)59dGRGMwE zSk+hcQ)QgY8zlFN4(eZ4Y8N#nO+nkoTe;58m*?;^$i<|+Ff%!cSh;eQdG+e((M(V~bH}Hh2bO78 zd(u|s3(TCY3G*4|Y%?Qswvk!j*tY#M%`LlqMtHMqJ&>-*%mn|7;$?pSRPp-l{Wfy4 z486l$=8<_Jkz-?;*l82I?qs-*X-j*h*KagmnsALh11wHR*w&AhCm=h_qa0heaF09N=7;rgJQjIsYl(vgOk z@X2#~sy zuKeKDVd{0)w9)KIyK@i7No#5Pa6Wb7G$98$uG1-T=#djVy{9eBE!ky8O)h#ftd<+I zBCI}T#!+@7a?*|6`e2JWsdj39dp&;UMohX<69H$)Zz*5lc9(LtDS_Wn-4L7p&+x*r zzO304WEI%@AaAh+;n2v{5T#whWj3T!L=g)&%z21~8!oF_XD`A@EM4O$>k@~1FS6G4 zwopjWZhp|uxpyQ@OOqz zz0jaz73}DE^3$!ahz=*?oN$p$ayttwlD<6`{i)WcBreZ%J0^F5%87^ZQk?9x6D@;r zqpTn^5T!9R<56k3>|>&_Lp#Qz5~ECIwn(J*qK%2z84E?mcb{fWkJixS*Uqgh>N(@Y zE;P!B?##x_GJunzt#Hwuqx2u|I$ry%>uN3!?D$AWEvNH2gpQ7yHt;~D5Yiqm1 zMW4*}?a@cw_*k2i`FdF^MR4JSS&_l%c8`d=HYw%gE@wpJapuOEuc7Q^9-BHR3mNWU zhcU9f?$@d4%>)PJ$!dZ->2A}<2$Z=9Gp={(lNe~!#FdjQtC%+4l<vK7n#L2DyG|}?S_(y2Fjgs zKJA(@B*=e}HZ4Z5-8RjYn=_GXyBtiLX4dJ1OYXdq8_$`^pG#9z0HG%`k9=Ka;Ss;NK~%(_XM9Dl8RqSVStbN3tC6OkVRUW(D1uYO^QtBsS*RjL>-1 zEb?)QBa3oYjnZN)$3`-0k$GS`EWiQ@oDzFc@-I~^ILk1;6RvycAzk^b!cNK* ztf*2hSV}T=z^x16qTuxQV=eCg(&l{1vWoKC)2XhS70{qBfFVekie2P9Qe$Nrgen^h z$}rJ3ARAM4?GdiD**0{;2i7L}zcZ64GuBEBk9IgbqKr?$A)n0`V=qE<`JOov+|hQl z#r#iHGQVuv_0!j7Swxc^FOIC6bpoX0O!sVAvrU$n51-S;BSNQKwxpbWx9@blE3qI@bVaUf5aUmeq3qLNZ4jA{)fv(&Fwx zv%3&TG1I`Ex+A-JZ+p_t6upMwYh=y+RP(CRM!K6dOq*uX{;YkCv~V^twif8}Inv#_ zx}f2aZEyYz_p`WBOY~z8kvTJu-40y-VNgGA^bOGQ>v7b^FEWz7-Q^l2|9h zV7#K@(=W<)d=yV4Lpf73G$hHq)1JWeBWeAFiHZhqZRlxj*Mk7nl5$%(ia9Q?p z^AK4xic{>Fq7k*sx`hYpZ>n+Y6 zfM`R_*e%*l$3`u6*kr+Ns%_0#T5Eg$KC;djyT3=Mhb1w@GZ>H3-fa(nX@=|#Rc3|D zN#qp#k$w_}aD6tW>14gu%sNKL+?^J7>=LMrj!yI%Y3O7Hwxy*f{)p}-i?Akcvr__J z7rG+UnLjeDsC8?gEt{gh$!eg@duOfL<&W%ZT=I<)*R^8V z+q&#}<;d%ZhHB*1C-N$WhkUFKM0#WuU34VLF5)U>9o3daT3wAQm^TtoD`wucL#qTTMz8{gdVA6V|Sv-FG?bL|RJrw00V}02i60%C@uw zcbtTUpDdNX6q1Y=%wEe=^;agSt9nY=$i7OGx6|rZWW?xKboy0hNg3yQUFfd*mEpUj;&u4` z-`1ZXNmqpuJtMMb^7Oij%$~@+1al{5Qi6$-ZgUc`{iL*S^pmj- zoKa9CO=#DK$8F+jqq85k?Sq$g&*;(>XFd+1hq1|a#z7Q9%>3ei zV~)*4>uL=4h{nWh`oB*(zA|yyQon5Y>fOYa#ojo_{Yds!NO2SsUO6EE;RzBsXEOT=;=Il#qw@OSB>qDMINkSr*b-=!COzh6 zcy7=P_+?v|eai>q>=`n7<0{?ephDzc!i!iow><~X(XesDCk&rEd{XsAEAvWA z^YY8etX3|MFpjCygvG9#%JKT;0f~_P5G%^Gi$fbIahc+!N_5 z^L)jRsrJBE!<2`FT%mytl##l$(m#S*ys*XnswQJzjev-bq}3gT9rSk?}g#p zuU>NDl7;E1P1!@C5t{zs-DSPxpBp=__f%MAPu+68e{NCu`b_cjE6*#|fBA0i;wSgO zy|?&oE)K89k4N|MzV6u;D8Y~1r`2HcS4+8Jia{i8CVRHb0>EPPp!71Mna%5VWmeG1 zL$;>^vjveG^Te*Zd#S9oV9)w%w+O%!inKal3=$ zOP16)Sd8P&EV1X-&&39b9yb?4bs)Q>LJsF!^M;HcH2Tu!KI8qf#ts?T@12(>Rt_0U zz0u>nH+U}e*AsuLsxc`HNKNLX7ipwm>;6pB36#`HI&!&^{*S@q`&_(yOyzjJ_QI>T z`0K|Gshs%Y+a+~F#wLVU_SJVw6QC#el&+*}J#N>C-!&|QIl;4W*+P)&|mY!hJ9oL!9pFO{l?vsNXB)@&f>yeV~ zxN$@3`n^MbhmK8(rkn1)$@7^1yX+t=Rtrt}&R{Po^9fu@&ZYD0yPfS!2VoO~^DESp z%UH1DreNk=ecXG=HNkU@eW>D4aei)Ua$-Ce4`R3^=*n3?=N?7VykYNxdasQznAXl8 z2w1ZdL;=#N}s&pR5?$Fl5-ctkV9z!^5hE4IVLV*|1?vBOVJs zv7v12(6M8O=8T&-)#hE2_ZH8i{=Fs-b4?y5NApmuy}mAafWXU?9OPTfe5K5;`{e$Q zLhMRK$q$@@d=HgIq}gO3VEtzgv}&pS^pVAramtY}d2Ap7d|s2C)iLJXuYbA-Sz zch$Jh)^BTFd0siAZuWRvo<|DX=?nB#(mU|OpIS9+=#UZ9E*e(VRQq`Nr(*|;R1Y5O zy0D-A;c~WPPw$^F;DpPME%yoo0%Y@l!;&A#% z_OIR6z3<0=zAw%5fbUJx*loVQ>V3-kt?yCo4?JN#=-mJJpy_dRgr4YJJ6EOYXY`%5 z;S2nukDmKYXmrz$`k~aVjyd5AYW$j=!SJx=$y~Xg@g{z2=pd&TeTr`z7c|^WH@~ z&k#?Zi6@2A0W7YWEpO>MF5A{fvVA3Gb=jV?XM{hzI{dfUCwenJ9U#5fHO=1@m4(Klkt@d0e9Ul~>dm15HNBS+HhT;=0U5Q)gU| zxi0Yu?*lhqky}4=W_|7zH``_WIqyo(2)`FzJl&ZW@F@*F?ca=^_FYT(HrxA0&!xWe z@%v2IvQfLefAso&=hK!HnfzuNLSt5$(^9Rt96Q&H2EG2auX)b({&ACC9>*z<+gV#N zhO76f216URbf#Hv`93oGN1^`~NB_7bqMzKx_bd56EwIZw&wq;vXYM=oJAUBfYwi23 zpLeW{>L_xuvAL8&33$!IqOhu-E#FBd>q4 z;Ok8b%H-CLXid~wsFyS3>!C2bV_~I5PEdl5no#U z5NSDv)Vr><6Mys-TEF(kGe+)jQZJ0v)RxJB3#qk}j7V*)9(zvRiuQ#)4fZID>FU5@Tzm=)3f)!i=WfhfApOUuPtw~cPscbJ8m>K zX*Bmv%H5MXc~irCtMz$jg`YL)r+eR~uOHO4PIDECx;9#;E6S>A z7xYDqI^}AeH2a_4w->zs{(`5WZKiLLr`h+?8OuX62SW0Ei#AE#dr7Mn|1#Av+~B?a5k*4xESq4+LWs(|788Bbg-bl6 zR1wy`91f!Y>Qr`1q{y*F%qNP1geoZ~sTa#0hh?&{VqRH!TDmtb&u(XH%1i31GqE&g zMu<)!THm;!F@5btiyl~3e`Uppf|^T%CNIKe%SWWnYol+C1&nPkd`*>NLX#Yw#CWUg%q!9^RQO{e#QU z?>r7e-0y#peqlE|h=S<{N2U<$KdJE|4FSmgo<^ z&{tQ7n*;g3Kld^Gti;cxKq$tK=no&y4xc?Z5Zd-7qd%nkUi0h>TuU0QZW?y_girVV z>rWkTxQ>p-0_;E2FieH{7q2X11T4rBech5xpVZ_d4Oz2%p~2S!W1vi?ORO%ry0T z*2zHH*)P3x_E$)EsP8?`E&hL?FEICXT3tbi=&UE4v)1Sd)213d!S~)Rt8&N8oH-_U z)h(1Ze7)ZHV^3}10`mNH=RET**&p(>_@$rzuKUgAIp1dZ>pU0xpW)jx-M%?8j&5k| z>F6gI5Aq|H_gvL{<1OLv0ybiVe_c&JpYVOlL#cJ~bJOPV$pwM3bHYb!@be3Ane~kC z_sl0bZCzZieLjX$m?4U^b*9ZRoxZ5l)~vzfGl~{8*JtEU^!I2-e*Bxy zz3y2oE9uJD>7MU8?zNaE79%H395b2|l3J?1x$>L!JU=E|E~69kS^@86JzV%LV~eSi z$JN(X4;~bIrZ=96zcR;KZnb0hw!%5fTjHGU)vk>|tQ0cX%<_e@Go&5#)l_&GNn$tB-;=XTeK&I}a>tbL(A@p`r85`m(K-7I zN@pxMgKNS4=k8dtWXHKn9y<5jhn6(f>bdckUU~NL@DoX#%IU?h^xH9kN4ZgueXN@WA zmzSyYE?y}f*aF!})+TVX%$kO)rVQe~x%pE^Rx;>WFv{YB%n1Kdg2^6l9FpkuA!Ysq z9iUCSj2p5=-f)mNncG(LCQTgBUux*0vm58koIdfqN#~6nGoohLkp2?}Oo)}d)E*ex zwYGYO3xjPAOYM}gpWO>HL)^=kZtT6iOATH)XvXlGzO{LSM^+E-H?&|x<@DhzZn#~a zhk7)}`o8YNPE<4Ulj99|O1aoHP+MmC{668lF@!2DpjfwYB^UO0liV5>0JNGl6RDq8 zGi~VL!o1M%%;BeyT$g=;48znwwr%pDL>5glh9kN*o9ilUlL#~ca&dS!-flc=MeVeT zIsH?TZ!IaFm{r}kqWR$_;n2VV{d3dH$|@3)?)l!583Qifv52?hFPeLOUY~9(_4+C* zmKGIQ;iR;_%QE^U-B?K;_O9M{n*h7`UA^Zh!Ls9L2BcT)Q+gJrFPud z`Z1$N)l3*MVd&ug)dQ+yB{umCBsL>v)+!*gnPz`xiFCWC>shX>95=2KY}z$sZ1Z=U z$9CgWH_@}+8CN-I+_*uN<3^4h@=6B+^NQ)jeQIGxpSVWf`)Y%}Qg7Ce=_joZCtRv8 z!ACNx_PMxeXb7V1CaN@jpS}ry^Yskw^BJi&ar<{V>lzQ~JE_k}DqTNKX2^dP42yE~_{wVnI~R);HiM z3Cx<+8e#?QUX_@Zo|3pmr6;AOCv8+oS!qdG8&y_9T2{hFm6;Gq$Xuf`Lh+%D_)RK4 zE)*ZPQN>xIIE(wNy;u(4$mDy->)A;C3V8w>S?Z5aiKn6FXmpUvsewDs;B6B zEb5l&5_ODQs=lM1Qje?0xD#z2x8F?TCY(Czf0Y`{taUm6Wg{;+gXAt=Tdk?KwLNr} zs$p5#eJuH3RG4J?IGQ?IIodmxAf*2`?*e=^eNB1A5UZ+gSa}5l2_&<;xFfG-Hx;pV-Vk#l06?Nl0)x)!^BP&dGhATp?a5AeZV6tOk^ZToe zu2WG*j;d>MxT=89fz0f{FcU#l89#-yb=jLs?pb#B&;Ig{Sq^$&&bKFEgEv$h=Fr*W=<_i_24uA9q%NE@@ zbwzE8H*G}U>N)wrgrQjv1@jh_z3ne-9$$Ud{33tR1&gw$e4*Z7ar3PM!@t*W^cm5| z8a**Nn4inJs-P7gLKQ4ePZ=>NKi^+7F%(R0YA#CL?cX&2r_YW}uTBYOj2@gBmp^83 zAecUBQhLze&&pVklQ?v2)*N5|q5VR+3)f5uoqOq7fs$)34;4D^<3P2Rg6 z=r=IY>n#Y49GIS!Q&gV%us8F9YgU&J&x?=iKVbIW>4}~`NmlWeDU0r1IU)XVV8Dfo zODp3q)w%w}%(}j{pUYobHO?A7K68XsGo#qbsEHflA3CLI;u7mXd`e+*{}}^HFZUcQ zxoP&KdzR&w=KHeGTAH7cKd)lY*f9g*M$~5vt4fQXxo8Rp^!#Z^9M_7vx62~%&VR^diZ%6S2p;^=cf&fTkjt@;@XvSlT$L{ zYfAISr)0-vrDTrJ%IMoCJ#R#5@xm(W^7vU*d3{qoL2pX`iu56Q8KWxm;)7-V3sX}D z)#q8+W%0qpev_9j%JAnbm=vE}>sQ6(|a?Q8DUobAtJ2{wHSrSC7f*1MY z;u7mq{ob--tG}F6{HSA9@FU;N>Tg*7uh3PDlBHT@Wz#k|yHcn@GA0cKvZdBg->AWi zNV92v{Sqb!msS}z)-YVk1M8fMxQc?rc?Rdp4$1fX@R3J^O<;Si=wk`t}I&RP*aVdcGx zrfey;lKOZOr|+FTpnqI^-th9(*Ibb4eK@teC?_j@;K)#c*PA%7-vf7fn}TERUV6#> z8HI(F>51AC?@vxnZ@gyI=pB!@OlbCB9+%(OR~|3(0b{BI>60?7+&asjeR0#`<)ixN zPACjreoaZ>tV_=gOH#&IY9rmZE4`a`S`96GvM#H!s)rt;6?)?J~JgNE<0s> zerZj7MoRMBmDi3K=wBZrpdFZ zxhX@dGlMMJC1gmYmdf2{`KY+ec-W zj-Rw3=efkVIR8b|7;mtoGBY^Y8y6Ss=Z_ndQ{UezF7x_RRh;h)y(jof@BQd}8LE)J zyh@E$lNc|Zqp(Cgt}+eFh)f#KowOJX+ ztc-Vtm5U>iJ|de}_G3A9riyC9J2V5Nvb;RIMye{6qrAMXdbsr3RsMc`ynQ{%eb&UM z2jfzLd0CCVKuTY4z15Uh=m`waGXgq0C9cGp6DlfAw;IbbbbiR=o1f?_@|4ASJoAf^ zLm}_hgueb@ZkaxtL)JRJATcAHln{zb_anjyap)BNg50-}m{>YE;0aXuMko7x{)&{e z+M;BZX3IT^X=#bUzPYHPD!Kjl9ew;S#_0D?=)L*{tkL`q)180Of7k8YJM#8~YkSrB zZ$jIjgwD-X9=a6`>^}7W8AkUHp!;X)?-=dh6QliSN3{Q}UbO%J(tpxN^o#nZJ&XgI zS)s{M@%lsk9%BMIeHq8^|L%w&weyG|3DtWidTyo}5y({`Nt;wsc4kucMwOkAnaz+O zf&120tE^CF*6OIPn-hx9*{E{jLOK8H;X!74a%Sh@K{_;k=}c)1ZT#OMQifJvK0T2# z(t?x!J5)A2Dg>q_W-`d?wW$9$DO}6|ry5(a9{oeTPyawar@yYh_AejkUL_^r_%j|rO`G{I7(m^!`Tw&6sHW6D$)3L6O`)v3U`kvtJts1t z+Gy2#`=$hZO&&cU;3*t!mBgiF>%bfms z(rVLED*QfQ5r#!Q<#Cq3DYtJhk-5qT+IRVG3WycMB3Xe>wXNb>nx!>+x0P!tq(Ii+ zJ?75cv_P6JV>&&}SD|&FEDilJAfq}tATnZvp7c#ULZ^=!91MSVoc^Q!6+%K5>P{o`WfQ3DdNfYZw)l^ktC6%a3$COmZn>*&%2P#9~G||f$ z^1@PSTG_-^A>^XVGjy&lPNSLZm#Nd3@+xLcj`Jtg#luIi;%hI%F*2>o%F^Sut{J&z z>yn}K`t&I+?bB!8(Dw8pjrMcnkbh|Zbvf5rQ)^4x2bNY>ms&5D)`kyN^xaWBplR&b zb3E(xFV}3{x+XlFkaLH&ABl#vN{7xR7X8w-*Pa|=Wl7-nxB06d>^~{=%F7v(2aIj> zQ&Q}3Y2W2}3z@Rq{a$Gp;(eE6K9`~LZR@_}7_Cq9|s zXY?j{BQM;-%hnz_KqMwcuux7+sa~$$@fg!3nZ@z8&GVD>{tJoeUV>q(LgJbYZPNmO zilNzxp*ca-H;iJ@jGchKP0rxKn=rD<;2M-R;ftmiR_8HbXWTGJ2Q+N2>H6uqnVu@is2Cy=r z&6G72$6L}o`Ps`Z``(o1ruS~U?LF`3nx`~2PQJM5(n~3mCViRr&%~Lk3LEk;fo%rX zo%TfaiMdbML@5(OCHA7$kO7}7D!t>)yL}^N<3!Wr)2EHxH1+N`?|>7~^et8D{j>LT zDkStK&?FMjVf z*`z@l_FA^}Br^uI(x35e#Xq~dKJF%dlC++5w}9}2S5hXwRHaP%d=43WBU&c%USDQ4 z!M|yN*Q4*r`%qsJ`Iq@exWjCSx<8L6?&Q3LN>)#7cE{!uS znfc=U3H&=bZv+F*3>|NITLtQe;Kp9?btf*@J<0Dg0OuH5)pahSUh!F7dXWY~lHiGegI5WCW<6foFQb7T(_m z=777wm(CE+;qR6nAZb+qasPH2EbH?9d49V%%(Fn^S>Frbhxsmkbj-8-7D#!XAv&a9 z#D@DVzhlu`Jp1tXXb*7XIurML!k6@bt9bYM9`Fm^#X`#PGeZB-+sX|At@=7J7^H)I zzl(J(q6>fyl@JAn_7Tr*?+g%=>QwNh6lOIsBdqy0(Yk=bfMr z80&mL-*KhggFmJXtHl-M0cn4vzPN3R>tEuLZyI>O0wC?qTCfnf?fQMRB_H!!!cB6X zi+K(KX)h&z$cmZ;%E03QIWqo7;tG&0<>pD!6-bzDAo;%?)B&M!8gTP5lxKm^DLj&V zxOox3Lc2g{mH6ddh2uiE@hdb5jllGq!so>fT$=9Yw}cf)K3xbOTwaPRX$vhvU!n87 z3|I0evM6cBgNwj--|-%<F#~n`s=Px-83KMxgFdMs)6us z7GVl(*Tnl0&o=_0X@c`CuH;A3>}&h)n1{O*B!VQq30;n#u5NE(Iw)_UH%xhip(zZV zVal(g6-eHYk&bhDR|`gi1>j_1l7%I_<`49TC?>V?4 zBa;BKYw907HgQNi0{QMj;*jsx07+NU`~#47{&_$?BM`rLfs^1#AaqFm`w|fOUJTN} z=Yh~9Vcj@>#xwjhek85`X-Hm918=xMVF#+v`=|^wH6@Rg~o8RL9w?Jgj<@q;x zeiVod-U0}V?N|fjU&6Tfy#1_8_zo?)#_>xX&~*U0#a@c7&zIx=FZpYiI!?dUaRR&z zsDmA0a1tB=Uj@{&j_=_w1ZW$KKl%zYhW!V$J3Me*d)frQ(w|2lZ==68ZL8h|XixME zbaUG9HgT!%?OSjk;r-Wne$(6f&sRVSpv>B5@t!i#PxJf>-c9kgo|winaqBPOev333 zc>XnQChf+F-w_wQ{dleYPJIVI%Xmk=+m_?^H@qhueF@LJKlvJd$?r*MJNZxEFXjE^ z_#GaDn|XG@`;*_dzkSR*(M2Q=Z8w2J;)0Ggcx2v-OMjw8t|VRZAZfOJ&!)d^AJ52^ z=C_6h`Q|=%ybHg9f+qLbb%nmjGrZtZO@lV%GxDA>P%Qs@eZC)Bkk1Iupz&lo`G7Ac zU*UZQSPaOw`YH4k;ZAVk7MJuRaZ8$luKxJ#`f>9o`O;E%-@zRKRs+h~q8~JM?Eo$~ zWB<%4tA(McOo;br!$ZgRKsPhlc>|h)M=y@Ds$PEYwQMqA%{*;4t8u(aUmiZ?S)~ zjkL8L`r7wWCuUP8c6(d@fqZ?E&ATtcW4;?*K;%&{5D4EJ0BLt1uN@yb@BfY~e64dJ z`E#Egx9v*&Uj&W$d|S{Rrmdq+syFZ_`RV|TfVf0Q48LW&GA8RlwoRVCfeU2Z)v=$r zi}@|-Nm$%A@G-J;5?Wo^ZGWEMeE;}6GPBEj^78Q>@_ZKUhW54|C$A@t@*bX_SZIGg zk;C^L_H!F~I6+yo-Ggam(W5+m!cN@bn4zw+1-;b<^!F zpB)WRdc=?TH?k&m0GiaDpbbz?Mn||2m+~_G{9IhpF>@)R9{{};_w)9%=n2v%nD=>< zhm@np>!aY`fz+`oKtIXle0Et~!Ea>G#4rA6!_*^uqfAZsBepBgE=>8t?@0O*uh1iL zJMYbJ$%DL;_!l|P@=j=xI{qg3#Bs%spw+?eoZsR{;+F98`~W=r5Vsm^cECHMBSN>K ze+2Hofzd$P%BP~VyLm9WPBvG8Z}g!s(7faF8+=jL1T zE|}@K58_fLhJN9Z_!s{2-Q+=da4q;eko+wNA`?=l&ppM}={;>{#1HLdY=71D*@QcdpRQr#w}k5!mbOtBMAMUJ_E;N8 zpVSrd&3za9?7kCMuZa3_--};)@4|iGli%2H5>627ucy5A^iJ|3=;_;Medp39uFw=q zm-{W2Ug>wc`s*4-&9ePSxZeDqE)F;CPA+w#<5?he13Dyrf$K-ob>GE)m$Y0zXX;9r zp2EpHD<;iYKk`2In>=$SU+EM;|Dyi@eg*n~*Brc#J047S+zGhpfc8lAA>%g__j2$X za2=31B+h*BD!3e62QomKgTc6;bDr4;#pzM=o#Vb-KLcI^(gwcv8x54#+`J>Vd~FFUi8zX_}cTR;og3l4#| z9x~rmDht0^gwH0<9OBBse-8e0@SlVK z+$OLZ5MOQ!*b5GUx0K3LAO{Qs)4^u&fl~QFPy|MTM$ih{lqv`TXeyWtmViCrAUFp2 zUdZ=Cz8CVnkne@-qUggr**w+v05}RxC{>gS`hy101U7)}fIRdg?S7 z1pxo0%}SLKwv2enc~_AL_JTv;EyacBAO{e>|8%evtXFElHm)!y{Gc766&wH`C{-B* zd>>3agUQ3-qe=~_0kZ*V4S}{H&^BZ@*ss*kL*OmNFc6UbFyg7gts+bn{;KddJQ3^! z`@k{Krc`wiz;6xd*H(hbN{v_nHUZv^fTp@8a0DDzY9w)tECuyoAy@@AD>aJeQ9O_0 zdGr>g#_)R#{_9C=Eb)&$3Qj0BE*0zt#5a!b6|Hi7$f>*xncfMPMXo1caN^rqtvRs05S260ldPDWo%{9xMdVI%P9B0-$3m zbWG*@)DucggT`t7K?7(4tHBnq8|(*!nSKnkDK&$1W)SBL;+#R8Gg<&?%{T)K!g2t;X+ap09@HHHiRsE#KE3RBGK;uoLW7>Y9466gz@~6G~l|3W$3>?)q(@ zO{wcw0K$E410a8&=XnGCyTJ=W0ROV5a^osM95+6v)TSD+U#XjRf;~W4?l#`tM*6oWf>KZq7J@dVwuC?pm<^f%ac?2s zExVPvV~J9C66Q|6-`T3vUBtO{7}%%O-T1vnuufqf0~}Op8)<{*U{on{V zuGE({DD~x$U?(*g87o(TB%_)(?4wi=vJ>g$gwwTp1O2)B#h-`ELugZ+TGzi~{dZ|+m--zS52 zK^v;scBP&m+>@zbhf?2ywr}C5braYIT9o>B2vjQd6yKhD4iMLOW`jdY?ZMr{yQj(j z)A)ayc)v@SXSOJ{w-M|H$Cdit5^zwdXGem4O8v(|0A0_KpYIod8US72$KUtcl={IY zrJfG~T)eA&#Jz79;Kx4F-S?JKKZM>Nwt@qI-!I_z1>U{DyBB!(qorUyAncEJ0mA<1 zW$=Mg`-9*)rGAW`A0GkG{gXpV{d6-bV=CCA)X(-R^`Fh)xKcmo`$4|_B2lSd7Af^B zzW-`JIIPs6CZ&GOyI&tu>Nl&E`t3TU4&(1IVSYy(FOCG$0sdZG0oH?~-~)vj7C?G0 z?FP{E(m|zOt^t$5LV%xF@be0OULlQF9s_#-Y5%?jv?}!n{2n1cNBDk(w2m|a(mApT z>;U*VasV7>m#+^`~u0y&3{7N*&Ds+m-q+!v47c)PULGxKgiq!A<~;$4Kkg zDuCZ(djY(AU4c?C9q{dS;(dJ&I0%k`Hl^MOfl5I5HVgca?fG71V$x zumM2ZoBP2La9pXkp!2PIuoP?ty8!9D^?_1<2?5gh3u*ji30Mb6<1eJ~m*>D?KpJm* zK>=t0&0q`I4Gsa)c!xCJA&qwyf>mHM*a`N5V@kan1eKr>kjA@P!DHY6cvq?S$isUz zfIPgn8W8_`;SFc0641D z2M3k<8}I%`n7{8;>O*KbPMG6wDfN$SpiQZdwgcRc6Ty0=P7vRTrAqxX2wIf-q#m45 z>LhtQd0eTs=QtS=0y~rn&j!t410bI8Zm=I50r>48&JOa?K^z?m!G5Jx0ocuk>Uz)& zwz9812kcPVlL*!+PFI6{O8a&x?O&yIpg%aFbZ~{zaUswG*ohT~pSVLxGse*ggh?m` z#G9}X5H5iOH3>Vx9&ivG18qtt5=UYsm<*PH^j%!hrnCP9zy5f4g=G{Qm`K2FKrjt2VMprD4iZ;SY4nrV=T>BN@u>SSdIa_ z%c=phK{MC@$bZ&uupb-&$Cb`b1f`%JECj2-X0Q|N0SCb`(57^5Na?&vhW&@ZyNanG zPylMcY|sogfbC#6*bk0?<4PBX0R9RWf^}d!*aHrO<4X4lff}$75Ko_VfV}o0uYI>G zT{Ibx=i((w_e%tCDP2MurKRA2(q%=URq1llFMn6*3c~cqZ~qph2dq|lAp9J7Sm{Aa z0lchi0F4a%y#RlMiEjwuhxP~f8@3Y=Pt|iu4_^TYGklBE)w97-a6;)CzSSI5x^|n= zjCb^i<4V`9QhHPntX6tVqSE!mQ-2JjBhWvNaN`vy0u5j(*aTX@K5$g&1_g>h16T?+ zffle2994RP0!5$!ECriD3)lyaDm_twBG3Sqf=!?W>{ohHDfmF?sUfAOZ&#YJhn`8E zX7A-4rD}6};z~3cHz=ku#R{ZS*2LXJz zv<5)S@@)VfuHfBe&~+K~Ue>Ji8d8M z9~@PB^FbI2 zbNt>wdK>t@5n6AYt@I|s-9&n~@cWkIO5f@Q&~hu^ZrutV0|%Ja3IY7ywgf-}x~#r^ zKY+e13anE4jv}yu={Wq{Nw_=TRr)T{yNmelnhutN9f0(<9#Q)4R;BMD5BClO_}w-e z98~(gO3V;_JR+TM*r35zxv^Ypc#;lN4%g2)Pp8K{ErZS1g$(DQhFzTcCJ?XOQnE(e7OLu z0_5S#A1M835Rl%ZE5H^&+>b)XqwwO<6H2#)z%bAN8bO=VU*X+XwgUY22*Q1JgVO(| zz)nCqk1YYa0pTC71pI#dpweH1zOU5-^6)kC@U?A9e|;fn2AjclrFY?H*IuQ+kqV9~ z{moTM?Dfj=xc?x{=SbuG`27KKKR=Qw$=OQ(kZ?aduJjA|`B4+t3R=J(a0q;$ z^!`?*e@r|-KCJXlh5_RKX|vJ?h~sCZ|DUCRxc`&5fBu-#2Z{IKc5nckQ2G}QfO7c7 zQKf&`AFKv@mHriB4|$dT&2GTA-}3FZeETim4)g8sbU=O%w}N9zzZe3%d$C37mq_C! z-o3nD=~se)^!~74=_928KSz!z{imf$znTadz!vbF;%Y?jn9~2XO6fn7#-E$O38i1d z&uheSjJVK6_3O<_zd?F$^ap%<1AlM4tn{0Ql}1O@f7z_`+x4IkYy-skHu1kr*msEc zog6@zclH3nyth#4_X+b?!u)Ls;N9QeRr>Ed{~bSne@p2P3jpzbh`$dHDt$Zz_YF$zUgV4jch(N}s^riPd1=|JB=<$Jbd@f6p_|%skD~ zG~HWL;Fh#d+BDoHPZlW9EH|aJ&`?@P*qWQ5RX3m*&&YaoKEYCd81-!zX51{)4 zq<`=-bN&VTzmWgG1pxW|8~OjI25<%7b>{qc1>kPxe7FFx4e#WkOds8gv+zZLEaMJ- zGu(NKah_(}McjRZaejb|gMjA%uL0hf2(pcF*~7ROVtfkn z!P<=%F9H0DajegH=~@8NOV0x!ZR&N5W9`JJJ;r!>DWD&)i}C4@nT~vBgaCIlJ`?nr z?=wE@6aey_y%GR>=2ih-Vtn3i#^*!VKFGfUv7+(xsi-8~53AhH31-#7oK}P{VJLow8 z_?Bz{z?Or{0ha<`>%pKe4Fk3TZUF3Jd>LexA&;u_7+*fjc=Za#4-t%q7cm}TfL9rh z!j2gDYUTmJ>w~X$7T`L-%Z%5R0`6tFW&}W9^^XCNS3?^h%lM(N}7pHFT^#3veCaUcgg~A3Y0j9^-4O04;!17++fo zXa@{4e#`>EbBwQB0=S#;Hl(#dciRR4(%WtYz`nLu0q-+@T$XXHS@`kW06GNzKf#76 zjBmJu@r~sG@ScG11cWCd-xD8WyuA$oJKJAlyaV=iAl`xaNeqB;o^%!f`J9CEoO~1j zGN-HnfbUe`r^1d?|HJrckUi}t0Bqcp23*5<=N$m>Czb$q172mk>lDCSjCTvbr2v$# zX9eID#*<+HWRsvJf%i@UAgy;80A0P1?<)l$-@dZ|S-|Uz_s;{Y1)K+X5by@$sRe*G zz(s&vfcF_cy#)aL^rry8H$!Ig6^swu&G;Fh;rx(imH-gPxgj5Wi}5WP#xudhBHAwb2sCk0MA*_b@m3v&q4Y*;5`@Nxv=rv*BL*r1_0a6dzta`LjcI0 ze-Ypf#xH=K7qkKH0KldTR{|~tfcK(#fNg-M7{7QHpa<|E;2p+4xd;HhPa=Lv3*aWk zFI@u20A6SOGU&YQ4#sydKo0Un z1Kw+{1E3B*1)Dy#oAGPGf9*EF%Zz^-`Ft8_*CD*_9mYR{_-D^!{CeoPVG*Dma4+MZ z6M$0y4>Eq^6ae^d1pnvL0OWI1CjfcgbOYcez-x@(%mC1F^9I0K0N8x95dXp}jNc00TcPjPrx?Gj6wm{Bj`1&s0NVif0{+AJ?Ja;S07$z-0HFVl-Hd;! z9*_aN&iI|+xf3#Xg6FPs0C?`Y3Gg!Gch>+e1;EC8)&lNk{L9dTwGjU@_`k9U0R3Ni zpYeMkd+!ne`0l+G@E+s$0l%*Y@F3&&&jVZvc#83_Rsj(I>SOrH4d}~e0LTw#k9_<( zz&niZgr1#{djMs6;5-1*AB4UKhXKzq{!lppat}e@!{B+iAAtDRN&(>c8sfW<=dQB= zkbPtk0D2xlSstwcT*LU+PXVMEe~bZ;jx$RB*z1fxE&#wEzXso`tSwq3b!wJ+}dXGCYT}J&*L~cQgJ1 zWL|*Giwpo8Uc3Tu6995Az5;*^FD(Gn1GWLOjDNcvPz5*&&;!T-ZU8*S_;(foRszmq z{JXa@{xWR)ektQW0R9T{{oxyo{~Y=J>JG+#jkG_70ieIt&iJ1%0^ALFhw-X8QZqc!zxPx!(-Ai!}9{u~bn#X0e>?Mi|b^tz=Vd;|X z<1C`MVj<@UihHcz!LK10*(ogTJVWs!mgZ54m#_o)EfgUaqL_d$#17-LV zkWV1{zw186$PS@4Ql?IH4Q8M;H16OgwS zRim`J{JIsac5P1~Uy@ZpYux0eYj_xZNz}70m(F_-|3j=1-_+K8$0BtzAcK?;v#q-s z8g;%~{=ep>`(t6hDeNKDuspd(&)Sni*QKqMR@grXeIa~7T-S4kZ8i0#`?cjrBkYD& zT}S#W_967GK~rlZi0dA!d5_1pSJon2hZ@nmdQ=3O*G|x&Yjbk%iJ+Hiu&uned;@C| zsO<#w>E7LizdG-&z_lGA(>ASWv*S!WgOL@=)rK8qqbQ3W*Taw)MUJ`$rxEJD(T5SX z4tB5of0E(wYWVljO&Dv~@foDxkM}l^MeKR_vS(rWefUz2fGzj4Yw#l$i}7{W|FVx* zkAn|RvxC^1&J_Hrz!H{pik%Yn9DBhjWiL8YoidF0rR;s&&ML<-^)lSXn#ro1S?nce zHv2aF4t{UoyUrYEt}~Av>I;R@- ziLxJ}rheqaoEpc+x4i1`_4z}cdZz*3r#}o{W(E5boYfwBzePYw<`ggWq=8!nQgmvoYrsd_Vs* zXA?Wd>2wn8Sf|VB#xJ$^I7z41>BBel+wjA1{Z0zs+TYB!Ica#h?alyx-{CBFCcXwc z#BOtj+1YFyef({Bp^dmJH{y)46PyelyZy%*bGER{*onA5w+%Pr&SdTE9Oo14Ja#@i z*E!2M+d0QM*EtVAs&*27TjB!eLgym<&ci9}E&O==qqvE8s&fhZ1HLGGnX|*W+_}QJ z65sv5+PMbbp}iKj@2+z`gYVB?@7&;g&biU~JboA8X6F|CKESQcZTNuZ?f5;5FX2;~ zcj0#f?!mWfzk=ToxX-!Y`6|9-JMQenw`(7C9&#SWFR1Qv9&sLZzK&0CK8_D?egmK3 z{3gDV`=s-f^R)8}zEk_0^Stwd^CG^R`)%ht&Uc-co$oo{cYff!g74`5$oaAJs`C@) zr_RrC5Af&sw(T$RZQWlxzroGG*PY)vzjyxNyn%1+{?Yl9^Oo~x=WXXN>}Gb0^N#Z_ zzQ6l7=RN1|&Oe-gI`2ClIRA3~jT?pkbv|@H;y481`0f?gU#U{~=-Cur#EW?eFU4=s zm+@)5oKNR7_)I>F&*pRZTt1J_=lk#qzJTw`_v8EHBg+f<0lbng;*0r#{2;!BAIz8X zWxR?n=hgfW9_A4q>ej- z^H1_i_@(?ZzJp)Rui#hmtN7LY8vZGME&nvXj(>)KmS4|r;Gg3+^3U^|_|5zl{sn$3 zzm0#9-_GygU*dQ2yZGJw9{y$i6@D+jkKd2q8O`!>zLP(|ALI}5hxym|F8&CAlz*K+ z#vkWT@Ne+l{G0q+{7L>4f0{qTpXJZ-=lKi#Mg9{1HvbO)E`OPSkAI*4fWN|j$bZCt z%wOd{;Xma+zr)|< zf8~GU@A1F$fAD|u_xT6>U;N+vKm5P^L;ew#g$}-W=DNa_u5vwhid*CsyCrU^JJl_7 zr@7_sba#e3)1BqccIUWr-FfbOcOSRHUEuEP?&t3BhTMhj0dA$c$X)Cn=pN)QaSwKv zy35=ucez{b9^!`Gh#PfdZjI}^wQikT?>4xHx`(+d+{4`?+#}tUZll}eHoGlu+--GN zxktIH-J{(#?ppU4_gHtG+vXnUu6K`jH@F+!6WkNscKqVoN$$z+DekH6Y3?Ss(@nTt zZnxXxCf#1Q&+T_p?&+ zx$b%H`R)boh3-Y}#qKBFOWaG{%iJCAkmF`vU)$TR!r`&7ZPrKK-pK(9yUhm%E ze$Kto{k(gVd$W6s`vvz__cr&7?(Oa!?w8y<-Mieo-Fw_GyI*ndb?^{pcbEH!`>6YM_c8Zz_X+nK?r!&+?zh}0-KX5A-Dliq-RIor-51;! z-Iv^NyWerY>%Q!M&;7pp1NRm8hwhKuAG@!*KXHHR{>**N{ki)K_m}Rk++VxDaewQ+ z?*7jGz556E4fjp=kM5t`x77`w#b@?)&Zs?!VlByZB8= z_e1w1!SIX8T(|-sh7(G7Vu~md#iB%%im9SZOcUi|x|ktmidkYdzVtR%%oFqR4nl=k zAodmb3b_c0h2j8FDHe&v;y`hbSRxJAjR zVPb_iTpS^e6e~reXcEn$MZ`s`SS5}UtHse`jaVy=5yy&kqD>qp){EoC2C-3`AWjtR zqC=b{P8O$#Q^jdwljsx)(IvV?55BfG3tteQgI`;j$L8ZgYz12&lA>4iiGGn1r;E)Z zEe6D(7!t$c3^5``MMjK?En=(KCbo++#V5pB;%sq_I9Hq}&KDPm3&lm^V)03FiMUi; zCU%I+#TDX8ah14QTq8aut`(ma*NM-F&x-5C4dQd+M)7%Zlek&jBEBGQ6}O2mirewt z=pF1m@g;F5`#bxGxJ%qE?h#)WUlI3;`^5d?t9YAZT-vo>@@6Yk6F@6+$1|?LUze+*&~y(SN5?={5n8CyA;1Tbvb@_>I!xte)w>cJY8;WjLlz)z z{HJ_hejxuP|1JL`|0_R~AK|y&9mSQagpx|>ud}KmRjf)>shX>~C!^VC9hfT~oB@Y~fFv(Ksn)j?{BI#?}L%T$$G zuBz1`DvX~_h^m;XQNF5Gb*f%9s6*9ZYK1yn9ifg?D^;UvQq8JG#Z{|XrH)do)zNB= zTC0vx$EtOzO&zD!tK-!MwNag*PQ+QsU)Vcr7xp(-vxT@4{~2~2`!u@?JD@wT|G8GR zs}6ONI$52Q#NJU!~OPYO_kK0X3+G z)UY~3ji^zTQDbV0+N!px?dnYR33Zk_Tb-lMRp+Vm)dlK8b&)MQ9qMv* zg}PE*rLI=js86YD)u+{U>ND!I>Uwp9`kcB^eO}$9ZdSLbFQ{A9ZR(5ac6EpPlDbpf zrS4Yus4uIpsC(6Y>VEZAl~vJjy*`nq~dJ+7Wm-%z{NH`TY) zljLvAU^&RzH^|Jb&`o8*sdPV(E{Yd>-y{dkqeyV<^UQ<6; zzfiwazf!+ezfr$cudCmw->W~UH`JTzkLpkAE%j&hw)%^DN4=~5s{W?lQ-4?gQ2$i# zvq#hi>R;;L>Obng>O=JrKKkn5*Y#Xac+yjz=S}g7ykf7!EA^&&W!^Ne+?(#r@Me0m zyxHCy{KnKgZ@#yWSK%%2_VxDj_V+^GLhk^t(p%&$_73z8@|Ji9drQ4#UX{1ptM(4@ z!d}FSdNHrY^SxTH&a3wtyhFXiycOQz-VxrB-b%00Yx0`C7BB9#daJynyw%>(-WqSM zcZ_$ex6W(xj`P-g$9o&Rjot~~iC(+c;hp52?49DB>Ye6o@;beQ*X4D4Jzmo5_4>Si zFXf%?ZT8aMfH&w3dBffr-iSBqWxO$Oi?`L==56=R^giL8<(=)F_*!(Ge zd+%4?v)*&w^WF>Ii{4A#x4rLp-}PShzUO`4`+@h0_e1YT-jBUky`OkL^?v5P=Kb9J zh4)MESKhC^-*~_EUiW_I{oeb7_lEbT_ebwf-do!$J(T03zBMl zH$K>>)|qUkuInEg>`RP{4Wtue8E>7597(~@ji%u0M4RD>`3#Fr>`5g@ zlB20nbz(r>tWOS09(MW^H=uVj_ zV6Tl)r)*MVHrKHUxsFj?j8Uf?Go5nGcFL_bqpkUjiniuDG-AbP z=9)z%s=K#@BP9uwKyhP@iI*qxIt-yK5#&O=Mz4vNQvV{vT5XWxgf^W7jQu9A3@KXD zm&-3&UeJj&Pqftfu9!il<-3!?RZ0i3sVGf4IXY^wl2ooJu@c+I3|Dz-Vo_pcsluWF z4LYJ>FG=M}7AvK0X%b4QZxUCoI5BHY%>-*|3amjqSJ0Zt*3?Y2256x*5Y1UrQ)ms4 zd_99JXHAW@#-Ct~UtkU5IcxmM*7y^x0a|DcM03{oh1LMcTLZ3~HNLf`c7ipv1=b** zv!-^kHMJA10a|DcM03{E7Fq)&Zwn$j5My83|J{M~?NRf6P#A1fO ziCYcz1sV{~X{ayMP@mHPmYjxqtD&Jl1L8Ri4TTySavH#r)6ifvM8bIuIv!|8&MwwWOz(@~KNnZo$Yao4%q_2_mHIlwYEAI#O z+emsENl#;Q(MGH~5~)o0_GuK;tB}pf?(McEn@D35X>1~knn+WV)#L{))kKyxk!8(f zSu^#3W@`CnYLRBD`DSXdW~%vSYM~aAZy`Hc$c`4WqlN5fp%S%F{wMBI*}0s&Ajl6AS18 zPW-W;95ixkDBl{&w}#476O@PeYslUjvbTopsv$dT$iAAuzCb_aUqklP1a<~|0Up#t zkPq4GlYXD_^C=%c&==T8`T1mjEtQ8>?KQQeA3t!em#8(hlz%PhsipjCNlzWg*HJyx zk$fG=*O7b!;SB*!`Wi@I1L1k{!8q0YiicRuFq_K%C zY$8ofq^Zej@`Jgfi7aa(%bJ6bYOa}DpqXmEnQFY5TB?~^w3%wYg-X^!cC?TkEo4Uv z+0jBJYN7mFDBn2A$4Nd;@^O-nlYE@y<0Ky^`Bsu|CHYp8ZzZ`_l4~WoRx9VzobS`x z!4F$GpXNM2Y~_8L^Zc-t_roL~vHAM62Js^#PiqIC)(n1x>OVsA5t665-w)>eda|!R zNT>X14)>!ZA8nn|F_h7}=*_Y*J=~uVP036`u1X9HBuw9qVYL$<9!;f(2E|E8a^pze z(2tl{jbkdgCNVsmz**2hS5JZ;Gsce{-&e~@u|Lngu5Xz=54g9 z+_wId+YD$MP1$T38;VW-nW4d<(NfCMAXAW|i4^G!Z93_<2@sQ7?+o zd={f#6r=epW_wel)y`W8O{*a^qXnU9eS~JzAT+Ir(2O30rnMujcD;a*`0Y9ZIO((N zhe&I^8I1^uZdanfNzTq^z$qU)uK}lg=-92*_RdJFohK2JyqzzBle``Mkyg77iL~1J z5h3Mo=SkosZ|6(kByZ209;Yal+mvO_xQwJSW}q}R@m zkyblDA|!b`4+5w9kCD8c2SF!!+ixPR)=MFzdbZvx(rV{7gk+zc$AFW4HKf0W^xJh% zq}BGHNUL2xMOuB5xAPZp;`hl8yWRpn>9y-H;3Q{P(2-U`>aX0l^~N0QfzXwQB!csLWMQR6cpA($Ya@g zB1S8TSY6$G{aj&WG})6L8%$(yE)7>Zx_zK)C_NX47n!}#_YWjKCf(*lGi{7!+8E8W zF`8*(G}FdtW{%MwB1SWKjArf_?IB{c#*fhq8>6*;jAq&x&9pIE^T%kWjRk$muJz&L zObsA3D-48YrbcL18VJqIiO{S#5Sp1k(rSG@LXx+8EcisTWM8$v;;#?*DF1rOzn=29D=GLw(qm_Q;H1aS{J=?%T>-#eO1CWz-$(Lx#s^O6cIF38 z@^&o*|^w~WdaN_r=UhLij^q@bIPpk`kGnL1# zEs!6T$L^_tQ+e!47QUGBw|f)dl)v4h0H^%zUIjSmw|f?pk9=@lFvn239gpzkq|dHl z;Ik<|yY~Q2>2?nSobt1K5#XfH?nR(47zcKxAFFGe(VZBCZ%qu2ZcnEZU8(NTp*=|n zH5O^{?L-`n*g=ob7#)q+L4wdsXb4TEBBb_N zG+hOu8G;BKY&+?SF*ciswpmBWW*xOQ+nBc5@M)V3pS9UUt<8qf*sNpLX4(>jX~P?) zqq%Ss*=*e>4wK1d>z06%&DL!IrygmSV$q0o3kYqQBi1bdw`Go4w*cIhIbxS~z#EIu z35~t|rKZ!^_-KjlbQCW&MI(foUpIs4&iYvZ>QFyLi_}n}E^jU|jKUd&H?P-~&>FY< zkZ9P>od}Il;jr}qz?+KABk1a0JP9o}`oneAnckwr;7}%+PNoue4N+&tdbG~2I}tX_ z=--ZquY;RxU{2`g5GjENr}UggAMwxVf^O*Q&1}NMQ^Pq+lQt*uysv<`fQTb{lx;%6 zy098b^$i+{8Nwh5%dvP}r7KI6YFw@r4?3Wxh3UmaZUkY zQ=UA_lDja@3WzzhfYUZZP2)6M_MV}wgVo)M(d6`?&^bz}tv7w4DLFDVXi@EP{9t^N zJLujL^{3muo~P9AJ7L&c7hpy^R7C5h(;&1}+Px(fEz9=??F6S8)Cy?4BxgaoyMPsy zJyOW3@iKBv$-$oL(c~G^2^~oF^rVxTQcjf7OkyOX2{hP^F`M#yb0-Y8Jbgw%6H_L$ z3rKm!Sq0QwX4LjJGZLqEoov8t+pdr}Dh_vxKQM=qxrZ=yoJKE!WC6eR{qE1MQ|1gp}vHI1$PU z2&3WJIn;m!lN26i*G+V*pwv%nX;a_&v3LCxI|#N99`aJlkSHvT-j)V!wQ~Y?|mQ_24pV7)<+iH64!l#l5M%v5{mCuIZWa zAGarB(Ri$JYQ8;ty3!@V_=QI>ml2@ExQ7yS3P@9YAQvwpfpqs&ic!1e)l$n?$77Eq zv92*oj9Azni6XSN*4cGjw9an#u^yu`)Wr+Ny!KaxV_xHB`D!r91|@fcI_?UKCKo*V$TOY&NK zasb>e$!lW`MWX{L*pBsNX=-pw4sl1v230SvGNzM4|Bo99YdYD}m+T%I#4~(bgjju@bs+j)CMC>E48WO<;%efiT+EC+qYNE& zCYH5!%-K5BgLQU5L*wPCrG`q2U}OX_3r63(0>Y+3moO_xC?p#3LV__T5{VV*HS7RZ zUbeK6NNq`1GMz3gW{o{#iq_aOr)aFcX)5;cSfvx!Y}`N^NhSJ{>yw$Wk-&^PFv{az*VSTO3%E2+l^|>o-;-x_FNdD-Sfrlv!Q6LIX*3oQ;z~W_ZifW z=&*Zj++#GA4JNm0ZnCRna4gpY%ZAfqqnq>})9YvE&^&Frp%r%Sa#|bZc0F?n2nCy# z86;FdniY@=^(~l0oX}tP(;GwDj?o6UaGyKFBu|)v_nAPNplqs1!U#5{ogQGhz3}Wp z%3i32R+k%I+rzEPC?HJ8wag^sDtS&0oxnBCknP$=do62SVUAXhX0wfD1af~2ZI(d` z)>B&3f=R>)+3r6%agwk#E5GuYYxwQTr_ea;P=k|(3GGurPS}m)+yYXe1}wpH8uC$7 ztON2m)^0hjLTQ`7y^vhk;V@n3J(Avc>(Hp9$%4jS*q<|ku`$v!d61Wl_NRI?tAp`8 z&7kYX0+&>3=KH`+nAP^W-lLK@d>!l~>I|Y5II`)ZV_ldNGI&3QnC9gv6Fu~t(d~oX z{dnRJKe8HoBjUFn#Xe2OI~t8R%f&O|7A*Z!naM?%kEd7}y-wJ}J5AA}02{k-k?ELv z4_7>p8iXS-QQHr7)TU!aJ3{FuYSVRehMo{<#|B@l_iJXtB~roOq<}HEH<_b}1qq~K z{v>i>HGP5#GviMdH|vZ^qQ>a_%DP~u zI(33V@}GHIrcF{bhdTD8YMnocoL4Y!QcA8gn7z!o&%_et*e922?nL&Sn*9QL5Ke6G zg#kGT_h|c2kP?KGTW{YWAqXeb*dF4O4c|Wy3&Ke?I@5TqiB?-byoafF4w!78oo6N( zWf>=1Vm7nH!Xg5M`n@b7G}#t)!US*j z*ulI-$D-8k#=V<#eImVwJ@IVSX~ ziGq_mmPuU@sGZceOrr7edulZ0+p9L?wD+dT6mG8`)?_q0-vPUlSjXcuQTIdhD5bGg z#K%U=g_UU7Ugbha=NC;4c#lSJRj9L<1Q=S|h)3;ZqG;4!rj14$YfFPZiS3;|w~5-P z-q9%SnemQV5$5c^kufYhtQtBvTrikPCG1W*+So7!Z}i{_&0wZDg#%+C>8^Y< zGi*TbD8Wv>L}mb{7-2Puo#B_KYD)a?wU}V+wSuCrXbV zqxNPT==O15)ZP+^M#Ig;_EA^9;PwGLSR+M9(krDTRV6*&j5fARNe&EWwj-f9J=CWc zJ2)OFqUg{F4kzFvMw8uoYbMbb@S?Ep5mCC&9=5k~qcMAb4WaE6F?+8KxGhDDp3}wb zy*8BIwn?~-+J;U>qOsc2k)(d-*jif_Ao}1UnVE`vLS!2XMDQyrFy6O>zQX!woH9Dr-Jj`CWXgh6o24x-HC8mI#5QI$TvLQ=UoaBm#d6vU zbLfOru*Ui#RG7XGmFrLbRCK@;7M$D70asbze(mFk6734BZG3v+Fu^E|_&7ukR`43H zDE~{6WZyj)Qk-p!#SI5nzhNsVN=cm zOPOIPcJ~m-2WD9g4~H;ATgU83i8R#V4NTzXILVk8X{awUB>jYjC=HgP5BP{uYbjkr znf?+|nCG{bVWJUx83wOb)6;HhhlssY6^++Gvsz^z=Jy=(^D z=2vGgGy=EoqRw7E18%#0oxP|FymeX^ULf4OUO#kh)YWV3_35UAPd6R>`dE>!G;_{F z9M%$_?mGDOb+SvJ0^u@Z0hhg074_{!TZHyd#mA|4x|jIj7R}yY__3x#(s0F(xWeJU660z z9Eke%9vVX1^?Z8|4Y+k)zP+yo+;%)J1*jqCRy|pSq}T?@A-vcoEL73v;%& zjG@tXR^Q$ni~9EF80dC}_U+A0;M9req|UcD|Dc&HYK-IY4$k;{aK>Q0fKN|;e3~A8 z`;;Y0FG?Y_f6h92+3r7;Tbp;)n0@KPDQ2LH@6SQ z$6k~MPWIbNb>O#t!>3z2zP$tpIyJhzNDaA0+!51N+cmV!`hmvCzFYfKndI^4^CRYv zdHs-HF7`AJjSOP`A}-QHGoeq@tWVRdPsXU!<>9E(2 zH-#oylNO zQ;^>ro*EEGv={b_Xu5ZR1UlJGm(_fFnoDmPL0@2ZP(JF8wCwWf*#NzD1bb<6v+rwQ z_!Dm5lK@V`+rCc$ej1jv8}aQOXXHzK^zhcV??Zr}hBaNb^XW34Z|}0AJocFcz3&M< zG`wl~K<|ZuPIl2lVV|~2zJ0$0c9NWZuLC&wbh?J>({8}0-GFcJ7K4xSr)MSf-YEJ5 z*=^q|MLCF%*5UMiDEP>o(DOBV3k>{JAN0(H-aAEp)IRhK#;0qqzI|TZ<}eG$!b=hD=&4vDMxmuta*{IYt)~W8u0Y^Bsupp@FU< z^W_2^)4bsry>b_$mxN>V5^#(*Rk3hmk@-4AfrRn$kTl%{p|L+4qnGny^qMd|5)0!| z8BWjj``n{=#+Dhv_9LCxKHZXV7qE{g#++y~cG~)*PSyClfw>t0H4|&x?o;q`hW!YX zL5ofCY?MB6f~WP<3%?m-5~taXf!@y~f ziU&f|9}t>ukI=ZCI9)$QNa=R79}nBz2126S-3D+g2L;-~c%1acNpGC=#;tomx@m8O zX80pCZV#aut_aQWL}=U=!X|g^>Q$bp*Y52?KddYz#MBSNSH7K(KEf9<|8M?1h%ZB4 z#x9cy{650I_!ZA~x%HzrMeL(DKWs+|rG50Kd`;}DX*w10N$HHqpXQ}iF{NM6uE$re zw>a#>bDYu-M}QezywA5t!B5Y!s!*0~Y>#(@LTh%hvg6id)$u2_XKO05OFBAxLpwIM zXZhm9BUA8c!0ztKu8M^Vv#cY_nk!p&qO8rGP0O=RRW{VwyFAOQDi>BRT%L8SLOl<- zvu3lV=IqSoP-kb;IG@$rG``qv&f;f_wuQ2#m54Mada|Nz+fID)8EUc%lM5DV>dvy+ zPSb)AqLocMXF4;HR+(jO?a7Xva~#dNJS(cQ?y~Ic=60Q5c209MF;s+lLfPGIS#jV= zJC`_9o8#T_tctfU%({y^)}P!C=87Hdp=?_lNGm%kLfLv9tMBLtjazP5v;-u=LfNoR z4{OfdZS5h{#*RcNTin*(2}($(73)~7j@5Qnbar%fRG_x9rOn-0w!S^f)@bI1z$?~d z_tUZc)+Bb7vu@3?OR}zxj-EtE)>+okK_%!2^`JbJO&!a#vMLl0WyRtI%H%b-wP(G` zrtFl;CbR%VI+tgasczInsAt^kY6|H@UEB(5zW$q)o$>ChT)Gg6&7mEk9ms1uEEl7O zj%)91t4OTxXs_&8*b&OEJiZ-i6}n!?l;v5kDqGaNY$txA%+&f6pp{LP=q{B_i7fBx z%{tvMBc4(H)7GrI3)y|?a)*NqIx-i#qN~$Jyq*ANJ zSq2lDQS{DGd`D$MH>#;@R-qdt8>)bjft6_5%0!FJV(Q0;XBR;V!nvXq3YF>pU)l7) z)Kccg(L@!M3pF^+wPlroTJ{=y6^679X%BREX zs?Z8Eq7JD-70>Go!TUL#x^pBDW~+7Q4Oe9kS(ZHn12cj#u?k&xuOqWEQD3P&{Qt;- zez`mw4cdCVOvQEA%SM)tOJ{bx9o|v5VyvKgK8~lRD&!l>eQ2|K;(PiFxS_pTtLbdc zgJxcBIij+DyvCWW%UX;2KmqnD(}_`&s9&C~tE!&6VtKaye`7@t?*@AVnuyI=9I6hj z(!K#zyL!itRh6sYi`p>*!u?`us&|~(vrxl_!imkv&IOx*UtMh2#!FdKwxoGkaz}M# zD70b+bRIU5EmUpwWfevPafh;<+PSPeuKfWKlA($R#DQ|3jwbDLi{ZVIKP;$h?aV5) z_QY|Eq2(;I)01fK?7=9+>cXq&0OqNN&G)x4Qt%>@IO61Uraw0IfK;K?6 zfg##AD-2>ZhD3+MR5HmDXiVs7OB>m2&K8E$g0J_RwF65h_Q{hXV2@bq}IX| zusnNYkejJ}YOYSb(wSW3pov$)e^l#QY|YMXZf~o=4%tr{=Jq@#Pk>G}V6U3%5tI@te9FV^*~{n(1i`ig}G{bgYX z*|`c1_|RoRtsDjHuw@H%o9TL?(&VaOHL75?9rjqJV1&=C&emd-AN?`(HBjfwnwhNw z&)TYN1A=38EyhvZp;pYsL2Vsdr8{)?7*zARs-29rBGQJ4qa(*v?Q{%fJtBs3yyjVj zqz#%!M>c969XUbs=*Wpx55OBXBi4=>CV9j@>}1Vw-fHajCrqolGHpp(yMuNq|eyZf>^(?OUF{i zE*(4F*rj8eja@pHHg@UQfU!%*28~@hHiSA}k!!$VgJoAD;S3vXM07+~x@OBZ0UO0! zi>T(w*r?_iGdwy$^K60e;W@2a4Q3>^*{GJ-Zljw0Oz1h>s{DkFYKF6HR5P3n-Xn7Q z&M}zLcdm_U_Va91v!4&iBdop)Y*aH`Xrr3pBJduW(|57KjJ{9WsAj*!Mm77TkUY}r zyUa#4!wwtO43}5!EHO*mtg6^4xEsgr33FLT)3WT8WY%5Owk?>@m+Lteo5APSV`=GJ zh3U?2|Axnz*Yq%Bjgf_tE!D`%(qr*68MRXmoHEao#Y9?<-^@7zU%2028d7P~6Ek;D z-#vAA(QdU8KY36Bw&_hzu$8(0n)V2OZjZH$FLEwD4m*cS+cn$w0nc$M1uaeva}7q*;9`%?e8OH+0tFkM;B+sm6*$09*{kXwY2Dh JVW8mO{{dG|E=m9Z 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 428/702] 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 429/702] 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 430/702] 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 431/702] 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 432/702] 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 433/702] 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 434/702] 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 435/702] `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 436/702] 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 437/702] 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 438/702] 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 439/702] 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 440/702] 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 441/702] 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 442/702] 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 443/702] 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 444/702] 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 445/702] 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 446/702] 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 447/702] 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 448/702] 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 449/702] 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 450/702] 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 451/702] 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 452/702] 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 453/702] 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 454/702] 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 455/702] 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 456/702] 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 457/702] 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 458/702] 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 459/702] 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 460/702] 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 461/702] 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 462/702] 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 463/702] 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 464/702] 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 465/702] 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 466/702] 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 467/702] 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 468/702] 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 469/702] 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 470/702] 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 471/702] 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 472/702] 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 473/702] 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 474/702] 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 475/702] 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 476/702] 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 477/702] 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 478/702] 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 479/702] 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 480/702] 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 481/702] 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 482/702] 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 483/702] 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 484/702] 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 485/702] 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 486/702] 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 487/702] 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 488/702] 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 489/702] 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 490/702] 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 491/702] 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 492/702] 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 493/702] 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 494/702] 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 495/702] 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 496/702] 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 497/702] 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 498/702] 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 499/702] 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 500/702] 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 501/702] 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 502/702] 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 503/702] 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 504/702] 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 505/702] 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 506/702] 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 507/702] 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 508/702] 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 509/702] 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 510/702] 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 511/702] 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 512/702] 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 513/702] 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 514/702] 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 515/702] 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 51bda77e3a8f584862299e21dbfc5ab338ea4236 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 30 Nov 2025 10:10:50 -0500 Subject: [PATCH 516/702] macos: teach agents about `zig build run` --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index a3e752816..dc2b47a70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,4 +30,5 @@ A file for [guiding coding agents](https://agents.md/). - Do not use `xcodebuild` - Use `zig build` to build the macOS app and any shared Zig code +- Use `zig build run` to build and run the macOS app - Run Xcode tests using `zig build test` From d7087627d728dda8a3baa45ad104977533b0d0d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 30 Nov 2025 07:15:23 -0800 Subject: [PATCH 517/702] 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 518/702] 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 519/702] 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 520/702] 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 521/702] 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 522/702] 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 523/702] 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 524/702] 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 525/702] 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 526/702] 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 527/702] 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 528/702] 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 529/702] 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 530/702] 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 531/702] 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 532/702] 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 533/702] 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 534/702] 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 535/702] 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 536/702] 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 537/702] 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 538/702] 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 539/702] 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 540/702] 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 541/702] 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 542/702] 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 543/702] 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 From 5bc78d59fb27ddab4c39ab8617ac8120d4289c5f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Dec 2025 11:19:26 -0800 Subject: [PATCH 544/702] terminal/tmux: add more control mode parsing keys --- src/terminal/dcs.zig | 22 ++---- src/terminal/tmux.zig | 172 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 18 deletions(-) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 52f696131..447905d24 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -26,7 +26,7 @@ pub const Handler = struct { assert(self.state == .inactive); // Initialize our state to ignore in case of error - self.state = .{ .ignore = {} }; + self.state = .ignore; // Try to parse the hook. const hk_ = self.tryHook(alloc, dcs) catch |err| { @@ -70,7 +70,7 @@ pub const Handler = struct { ), }, }, - .command = .{ .tmux = .{ .enter = {} } }, + .command = .{ .tmux = .enter }, }; }, @@ -116,7 +116,7 @@ pub const Handler = struct { // On error we just discard our state and ignore the rest log.info("error putting byte into DCS handler err={}", .{err}); self.discard(); - self.state = .{ .ignore = {} }; + self.state = .ignore; return null; }; } @@ -158,7 +158,7 @@ pub const Handler = struct { // Note: we do NOT call deinit here on purpose because some commands // transfer memory ownership. If state needs cleanup, the switch // prong below should handle it. - defer self.state = .{ .inactive = {} }; + defer self.state = .inactive; return switch (self.state) { .inactive, @@ -167,7 +167,7 @@ pub const Handler = struct { .tmux => if (comptime build_options.tmux_control_mode) tmux: { self.state.deinit(); - break :tmux .{ .tmux = .{ .exit = {} } }; + break :tmux .{ .tmux = .exit }; } else unreachable, .xtgettcap => |*list| xtgettcap: { @@ -200,7 +200,7 @@ pub const Handler = struct { fn discard(self: *Handler) void { self.state.deinit(); - self.state = .{ .inactive = {} }; + self.state = .inactive; } }; @@ -255,21 +255,15 @@ pub const Command = union(enum) { decstbm, decslrm, }; - - /// Tmux control mode - pub const Tmux = union(enum) { - enter: void, - exit: void, - }; }; const State = union(enum) { /// We're not in a DCS state at the moment. - inactive: void, + inactive, /// We're hooked, but its an unknown DCS command or one that went /// invalid due to some bad input, so we're ignoring the rest. - ignore: void, + ignore, /// XTGETTCAP xtgettcap: std.Io.Writer.Allocating, diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 56d4c5fe2..f1eb178fe 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -271,6 +271,90 @@ pub const Client = struct { // Important: do not clear buffer here since name points to it self.state = .idle; return .{ .window_renamed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { + var re = try oni.Regex.init( + "^%window-pane-changed @([0-9]+) %([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const window_id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const pane_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; + } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { + var re = try oni.Regex.init( + "^%client-detached (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + + // Important: do not clear buffer here since client points to it + self.state = .idle; + return .{ .client_detached = .{ .client = client } }; + } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { + var re = try oni.Regex.init( + "^%client-session-changed (.+) \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + const session_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[3])..@intCast(ends[3])]; + + // Important: do not clear buffer here since client/name point to it + self.state = .idle; + return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; } else { // Unknown notification, log it and return to idle state. log.warn("unknown tmux control mode notification={s}", .{cmd}); @@ -291,34 +375,75 @@ pub const Client = struct { }; /// Possible notification types from tmux control mode. These are documented -/// in tmux(1). +/// in tmux(1). A lot of the simple documentation was copied from that man +/// page here. pub const Notification = union(enum) { - enter: void, - exit: void, + /// Entering tmux control mode. This isn't an actual event sent by + /// tmux but is one sent by us to indicate that we have detected that + /// tmux control mode is starting. + enter, + /// Exit. + /// + /// NOTE: The tmux protocol contains a "reason" string (human friendly) + /// associated with this. We currently drop it because we don't need it + /// but this may be something we want to add later. If we do add it, + /// we have to consider buffer limits and how we handle those (dropping + /// vs truncating, etc.). + exit, + + /// Dispatched at the end of a begin/end block with the raw data. + /// The control mode parser can't parse the data because it is unaware + /// of the command that was sent to trigger this output. block_end: []const u8, block_err: []const u8, + /// Raw output from a pane. output: struct { pane_id: usize, data: []const u8, // unescaped }, + /// The client is now attached to the session with ID session-id, which is + /// named name. session_changed: struct { id: usize, name: []const u8, }, - sessions_changed: void, + /// A session was created or destroyed. + sessions_changed, + /// The window with ID window-id was linked to the current session. window_add: struct { id: usize, }, + /// The window with ID window-id was renamed to name. window_renamed: struct { id: usize, name: []const u8, }, + + /// The active pane in the window with ID window-id changed to the pane + /// with ID pane-id. + window_pane_changed: struct { + window_id: usize, + pane_id: usize, + }, + + /// The client has detached. + client_detached: struct { + client: []const u8, + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + client_session_changed: struct { + client: []const u8, + session_id: usize, + name: []const u8, + }, }; test "tmux begin/end empty" { @@ -433,3 +558,42 @@ test "tmux window-renamed" { try testing.expectEqual(42, n.window_renamed.id); try testing.expectEqualStrings("bar", n.window_renamed.name); } + +test "tmux window-pane-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_pane_changed); + try testing.expectEqual(42, n.window_pane_changed.window_id); + try testing.expectEqual(2, n.window_pane_changed.pane_id); +} + +test "tmux client-detached" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_detached); + try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client); +} + +test "tmux client-session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_session_changed); + try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client); + try testing.expectEqual(2, n.client_session_changed.session_id); + try testing.expectEqualStrings("mysession", n.client_session_changed.name); +} From 6e016ea81e5fee07cce9a329581c7d47e6fd2aa1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Dec 2025 11:38:15 -0800 Subject: [PATCH 545/702] terminal: move tmux into folder --- src/terminal/dcs.zig | 4 +- src/terminal/tmux.zig | 602 +--------------------------------- src/terminal/tmux/control.zig | 602 ++++++++++++++++++++++++++++++++++ 3 files changed, 610 insertions(+), 598 deletions(-) create mode 100644 src/terminal/tmux/control.zig diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 447905d24..425325d4a 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -213,7 +213,7 @@ pub const Command = union(enum) { /// Tmux control mode tmux: if (build_options.tmux_control_mode) - terminal.tmux.Notification + terminal.tmux.ControlNotification else void, @@ -276,7 +276,7 @@ const State = union(enum) { /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode tmux: if (build_options.tmux_control_mode) - terminal.tmux.Client + terminal.tmux.ControlParser else void, diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index f1eb178fe..a6538ea50 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -1,599 +1,9 @@ -//! This file contains the implementation for tmux control mode. See -//! tmux(1) for more information on control mode. Some basics are documented -//! here but this is not meant to be a comprehensive source of protocol -//! documentation. +//! Types and functions related to tmux protocols. -const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; -const oni = @import("oniguruma"); +const control = @import("tmux/control.zig"); +pub const ControlParser = control.Parser; +pub const ControlNotification = control.Notification; -const log = std.log.scoped(.terminal_tmux); - -/// A tmux control mode client. It is expected that the caller establishes -/// the connection in some way (i.e. detects the opening DCS sequence). This -/// just works on a byte stream. -pub const Client = struct { - /// Current state of the client. - state: State = .idle, - - /// The buffer used to store in-progress notifications, output, etc. - buffer: std.Io.Writer.Allocating, - - /// The maximum size in bytes of the buffer. This is used to limit - /// memory usage. If the buffer exceeds this size, the client will - /// enter a broken state (the control mode session will be forcibly - /// exited and future data dropped). - max_bytes: usize = 1024 * 1024, - - const State = enum { - /// Outside of any active notifications. This should drop any output - /// unless it is '%' on the first byte of a line. The buffer will be - /// cleared when it sees '%', this is so that the previous notification - /// data is valid until we receive/process new data. - idle, - - /// We experienced unexpected input and are in a broken state - /// 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 '%'). - notification, - - /// Inside a begin/end block. - block, - }; - - 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; - } - - switch (self.state) { - // Drop because we're in a broken state. - .broken => return null, - - // Waiting for a notification so if the byte is not '%' then - // we're in a broken state. Control mode output should always - // be wrapped in '%begin/%end' orelse we expect a notification. - // Return an exit notification. - .idle => if (byte != '%') { - self.broken(); - return .{ .exit = {} }; - } else { - self.buffer.clearRetainingCapacity(); - self.state = .notification; - }, - - // If we're in a notification and its not a newline then - // we accumulate. If it is a newline then we have a - // complete notification we need to parse. - .notification => if (byte == '\n') { - // We have a complete notification, parse it. - return try self.parseNotification(); - }, - - // If we're in a block then we accumulate until we see a newline - // and then we check to see if that line ended the block. - .block => if (byte == '\n') { - const written = self.buffer.written(); - const idx = if (std.mem.lastIndexOfScalar( - u8, - written, - '\n', - )) |v| v + 1 else 0; - const line = written[idx..]; - - if (std.mem.startsWith(u8, line, "%end") or - std.mem.startsWith(u8, line, "%error")) - { - const err = std.mem.startsWith(u8, line, "%error"); - const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); - - // If it is an error then log it. - if (err) log.warn("tmux control mode error={s}", .{output}); - - // Important: do not clear buffer since the notification - // contains it. - self.state = .idle; - return if (err) .{ .block_err = output } else .{ .block_end = output }; - } - - // Didn't end the block, continue accumulating. - }, - } - - try self.buffer.writer.writeByte(byte); - - return null; - } - - fn parseNotification(self: *Client) !?Notification { - assert(self.state == .notification); - - const line = line: { - var line = self.buffer.written(); - if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; - break :line line; - }; - const cmd = cmd: { - const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; - break :cmd line[0..idx]; - }; - - // The notification MUST exist because we guard entering the notification - // state on seeing at least a '%'. - if (std.mem.eql(u8, cmd, "%begin")) { - // We don't use the rest of the tokens for now because tmux - // claims to guarantee that begin/end are always in order and - // never intermixed. In the future, we should probably validate - // this. - // TODO(tmuxcc): do this before merge? - - // Move to block state because we expect a corresponding end/error - // and want to accumulate the data. - self.state = .block; - self.buffer.clearRetainingCapacity(); - return null; - } else if (std.mem.eql(u8, cmd, "%output")) cmd: { - var re = try oni.Regex.init( - "^%output %([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const data = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .output = .{ .pane_id = id, .data = data } }; - } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { - var re = try oni.Regex.init( - "^%session-changed \\$([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .session_changed = .{ .id = id, .name = name } }; - } else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { - if (!std.mem.eql(u8, line, "%sessions-changed")) { - log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); - break :cmd; - } - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .sessions_changed = {} }; - } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { - var re = try oni.Regex.init( - "^%window-add @([0-9]+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .window_add = .{ .id = id } }; - } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { - var re = try oni.Regex.init( - "^%window-renamed @([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[2])..@intCast(ends[2])]; - - // Important: do not clear buffer here since name points to it - self.state = .idle; - return .{ .window_renamed = .{ .id = id, .name = name } }; - } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { - var re = try oni.Regex.init( - "^%window-pane-changed @([0-9]+) %([0-9]+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const window_id = std.fmt.parseInt( - usize, - line[@intCast(starts[1])..@intCast(ends[1])], - 10, - ) catch unreachable; - const pane_id = std.fmt.parseInt( - usize, - line[@intCast(starts[2])..@intCast(ends[2])], - 10, - ) catch unreachable; - - self.buffer.clearRetainingCapacity(); - self.state = .idle; - return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; - } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { - var re = try oni.Regex.init( - "^%client-detached (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const client = line[@intCast(starts[1])..@intCast(ends[1])]; - - // Important: do not clear buffer here since client points to it - self.state = .idle; - return .{ .client_detached = .{ .client = client } }; - } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { - var re = try oni.Regex.init( - "^%client-session-changed (.+) \\$([0-9]+) (.+)$", - .{ .capture_group = true }, - oni.Encoding.utf8, - oni.Syntax.default, - null, - ); - defer re.deinit(); - - var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); - break :cmd; - }; - defer region.deinit(); - const starts = region.starts(); - const ends = region.ends(); - - const client = line[@intCast(starts[1])..@intCast(ends[1])]; - const session_id = std.fmt.parseInt( - usize, - line[@intCast(starts[2])..@intCast(ends[2])], - 10, - ) catch unreachable; - const name = line[@intCast(starts[3])..@intCast(ends[3])]; - - // Important: do not clear buffer here since client/name point to it - self.state = .idle; - return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; - } else { - // Unknown notification, log it and return to idle state. - log.warn("unknown tmux control mode notification={s}", .{cmd}); - } - - // Unknown command. Clear the buffer and return to idle state. - self.buffer.clearRetainingCapacity(); - self.state = .idle; - - return null; - } - - // Mark the tmux state as broken. - fn broken(self: *Client) void { - self.state = .broken; - self.buffer.deinit(); - } -}; - -/// Possible notification types from tmux control mode. These are documented -/// in tmux(1). A lot of the simple documentation was copied from that man -/// page here. -pub const Notification = union(enum) { - /// Entering tmux control mode. This isn't an actual event sent by - /// tmux but is one sent by us to indicate that we have detected that - /// tmux control mode is starting. - enter, - - /// Exit. - /// - /// NOTE: The tmux protocol contains a "reason" string (human friendly) - /// associated with this. We currently drop it because we don't need it - /// but this may be something we want to add later. If we do add it, - /// we have to consider buffer limits and how we handle those (dropping - /// vs truncating, etc.). - exit, - - /// Dispatched at the end of a begin/end block with the raw data. - /// The control mode parser can't parse the data because it is unaware - /// of the command that was sent to trigger this output. - block_end: []const u8, - block_err: []const u8, - - /// Raw output from a pane. - output: struct { - pane_id: usize, - data: []const u8, // unescaped - }, - - /// The client is now attached to the session with ID session-id, which is - /// named name. - session_changed: struct { - id: usize, - name: []const u8, - }, - - /// A session was created or destroyed. - sessions_changed, - - /// The window with ID window-id was linked to the current session. - window_add: struct { - id: usize, - }, - - /// The window with ID window-id was renamed to name. - window_renamed: struct { - id: usize, - name: []const u8, - }, - - /// The active pane in the window with ID window-id changed to the pane - /// with ID pane-id. - window_pane_changed: struct { - window_id: usize, - pane_id: usize, - }, - - /// The client has detached. - client_detached: struct { - client: []const u8, - }, - - /// The client is now attached to the session with ID session-id, which is - /// named name. - client_session_changed: struct { - client: []const u8, - session_id: usize, - name: []const u8, - }, -}; - -test "tmux begin/end empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_end); - try testing.expectEqualStrings("", n.block_end); -} - -test "tmux begin/error empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_err); - try testing.expectEqualStrings("", n.block_err); -} - -test "tmux begin/end data" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); - for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); - for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .block_end); - try testing.expectEqualStrings("hello\nworld", n.block_end); -} - -test "tmux output" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .output); - try testing.expectEqual(42, n.output.pane_id); - try testing.expectEqualStrings("foo bar baz", n.output.data); -} - -test "tmux session-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .session_changed); - try testing.expectEqual(42, n.session_changed.id); - try testing.expectEqualStrings("foo", n.session_changed.name); -} - -test "tmux sessions-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .sessions_changed); -} - -test "tmux sessions-changed carriage return" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .sessions_changed); -} - -test "tmux window-add" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_add); - try testing.expectEqual(14, n.window_add.id); -} - -test "tmux window-renamed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_renamed); - try testing.expectEqual(42, n.window_renamed.id); - try testing.expectEqualStrings("bar", n.window_renamed.name); -} - -test "tmux window-pane-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .window_pane_changed); - try testing.expectEqual(42, n.window_pane_changed.window_id); - try testing.expectEqual(2, n.window_pane_changed.pane_id); -} - -test "tmux client-detached" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .client_detached); - try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client); -} - -test "tmux client-session-changed" { - const testing = std.testing; - const alloc = testing.allocator; - - var c: Client = .{ .buffer = .init(alloc) }; - defer c.deinit(); - for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null); - const n = (try c.put('\n')).?; - try testing.expect(n == .client_session_changed); - try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client); - try testing.expectEqual(2, n.client_session_changed.session_id); - try testing.expectEqualStrings("mysession", n.client_session_changed.name); +test { + @import("std").testing.refAllDecls(@This()); } diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig new file mode 100644 index 000000000..8304b2f1f --- /dev/null +++ b/src/terminal/tmux/control.zig @@ -0,0 +1,602 @@ +//! This file contains the implementation for tmux control mode. See +//! tmux(1) for more information on control mode. Some basics are documented +//! here but this is not meant to be a comprehensive source of protocol +//! documentation. + +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const oni = @import("oniguruma"); + +const log = std.log.scoped(.terminal_tmux); + +/// A tmux control mode parser. This takes in output from tmux control +/// mode and parses it into a structured notifications. +/// +/// It is up to the caller to establish the connection to the tmux +/// control mode session in some way (e.g. via exec, a network socket, +/// whatever). This is fully agnostic to how the data is received and sent. +pub const Parser = struct { + /// Current state of the client. + state: State = .idle, + + /// The buffer used to store in-progress notifications, output, etc. + buffer: std.Io.Writer.Allocating, + + /// The maximum size in bytes of the buffer. This is used to limit + /// memory usage. If the buffer exceeds this size, the client will + /// enter a broken state (the control mode session will be forcibly + /// exited and future data dropped). + max_bytes: usize = 1024 * 1024, + + const State = enum { + /// Outside of any active notifications. This should drop any output + /// unless it is '%' on the first byte of a line. The buffer will be + /// cleared when it sees '%', this is so that the previous notification + /// data is valid until we receive/process new data. + idle, + + /// We experienced unexpected input and are in a broken state + /// 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 '%'). + notification, + + /// Inside a begin/end block. + block, + }; + + pub fn deinit(self: *Parser) 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: *Parser, 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; + } + + switch (self.state) { + // Drop because we're in a broken state. + .broken => return null, + + // Waiting for a notification so if the byte is not '%' then + // we're in a broken state. Control mode output should always + // be wrapped in '%begin/%end' orelse we expect a notification. + // Return an exit notification. + .idle => if (byte != '%') { + self.broken(); + return .{ .exit = {} }; + } else { + self.buffer.clearRetainingCapacity(); + self.state = .notification; + }, + + // If we're in a notification and its not a newline then + // we accumulate. If it is a newline then we have a + // complete notification we need to parse. + .notification => if (byte == '\n') { + // We have a complete notification, parse it. + return try self.parseNotification(); + }, + + // If we're in a block then we accumulate until we see a newline + // and then we check to see if that line ended the block. + .block => if (byte == '\n') { + const written = self.buffer.written(); + const idx = if (std.mem.lastIndexOfScalar( + u8, + written, + '\n', + )) |v| v + 1 else 0; + const line = written[idx..]; + + if (std.mem.startsWith(u8, line, "%end") or + std.mem.startsWith(u8, line, "%error")) + { + const err = std.mem.startsWith(u8, line, "%error"); + const output = std.mem.trimRight(u8, written[0..idx], "\r\n"); + + // If it is an error then log it. + if (err) log.warn("tmux control mode error={s}", .{output}); + + // Important: do not clear buffer since the notification + // contains it. + self.state = .idle; + return if (err) .{ .block_err = output } else .{ .block_end = output }; + } + + // Didn't end the block, continue accumulating. + }, + } + + try self.buffer.writer.writeByte(byte); + + return null; + } + + fn parseNotification(self: *Parser) !?Notification { + assert(self.state == .notification); + + const line = line: { + var line = self.buffer.written(); + if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; + break :line line; + }; + const cmd = cmd: { + const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; + break :cmd line[0..idx]; + }; + + // The notification MUST exist because we guard entering the notification + // state on seeing at least a '%'. + if (std.mem.eql(u8, cmd, "%begin")) { + // We don't use the rest of the tokens for now because tmux + // claims to guarantee that begin/end are always in order and + // never intermixed. In the future, we should probably validate + // this. + // TODO(tmuxcc): do this before merge? + + // Move to block state because we expect a corresponding end/error + // and want to accumulate the data. + self.state = .block; + self.buffer.clearRetainingCapacity(); + return null; + } else if (std.mem.eql(u8, cmd, "%output")) cmd: { + var re = try oni.Regex.init( + "^%output %([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const data = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .output = .{ .pane_id = id, .data = data } }; + } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { + var re = try oni.Regex.init( + "^%session-changed \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .session_changed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { + if (!std.mem.eql(u8, line, "%sessions-changed")) { + log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); + break :cmd; + } + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .sessions_changed = {} }; + } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { + var re = try oni.Regex.init( + "^%window-add @([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_add = .{ .id = id } }; + } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { + var re = try oni.Regex.init( + "^%window-renamed @([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .window_renamed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { + var re = try oni.Regex.init( + "^%window-pane-changed @([0-9]+) %([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const window_id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const pane_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; + } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { + var re = try oni.Regex.init( + "^%client-detached (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + + // Important: do not clear buffer here since client points to it + self.state = .idle; + return .{ .client_detached = .{ .client = client } }; + } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { + var re = try oni.Regex.init( + "^%client-session-changed (.+) \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const client = line[@intCast(starts[1])..@intCast(ends[1])]; + const session_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[3])..@intCast(ends[3])]; + + // Important: do not clear buffer here since client/name point to it + self.state = .idle; + return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; + } else { + // Unknown notification, log it and return to idle state. + log.warn("unknown tmux control mode notification={s}", .{cmd}); + } + + // Unknown command. Clear the buffer and return to idle state. + self.buffer.clearRetainingCapacity(); + self.state = .idle; + + return null; + } + + // Mark the tmux state as broken. + fn broken(self: *Parser) void { + self.state = .broken; + self.buffer.deinit(); + } +}; + +/// Possible notification types from tmux control mode. These are documented +/// in tmux(1). A lot of the simple documentation was copied from that man +/// page here. +pub const Notification = union(enum) { + /// Entering tmux control mode. This isn't an actual event sent by + /// tmux but is one sent by us to indicate that we have detected that + /// tmux control mode is starting. + enter, + + /// Exit. + /// + /// NOTE: The tmux protocol contains a "reason" string (human friendly) + /// associated with this. We currently drop it because we don't need it + /// but this may be something we want to add later. If we do add it, + /// we have to consider buffer limits and how we handle those (dropping + /// vs truncating, etc.). + exit, + + /// Dispatched at the end of a begin/end block with the raw data. + /// The control mode parser can't parse the data because it is unaware + /// of the command that was sent to trigger this output. + block_end: []const u8, + block_err: []const u8, + + /// Raw output from a pane. + output: struct { + pane_id: usize, + data: []const u8, // unescaped + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + session_changed: struct { + id: usize, + name: []const u8, + }, + + /// A session was created or destroyed. + sessions_changed, + + /// The window with ID window-id was linked to the current session. + window_add: struct { + id: usize, + }, + + /// The window with ID window-id was renamed to name. + window_renamed: struct { + id: usize, + name: []const u8, + }, + + /// The active pane in the window with ID window-id changed to the pane + /// with ID pane-id. + window_pane_changed: struct { + window_id: usize, + pane_id: usize, + }, + + /// The client has detached. + client_detached: struct { + client: []const u8, + }, + + /// The client is now attached to the session with ID session-id, which is + /// named name. + client_session_changed: struct { + client: []const u8, + session_id: usize, + name: []const u8, + }, +}; + +test "tmux begin/end empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("", n.block_end); +} + +test "tmux begin/error empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_err); + try testing.expectEqualStrings("", n.block_err); +} + +test "tmux begin/end data" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("hello\nworld", n.block_end); +} + +test "tmux output" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(42, n.output.pane_id); + try testing.expectEqualStrings("foo bar baz", n.output.data); +} + +test "tmux session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .session_changed); + try testing.expectEqual(42, n.session_changed.id); + try testing.expectEqualStrings("foo", n.session_changed.name); +} + +test "tmux sessions-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux sessions-changed carriage return" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux window-add" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_add); + try testing.expectEqual(14, n.window_add.id); +} + +test "tmux window-renamed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_renamed); + try testing.expectEqual(42, n.window_renamed.id); + try testing.expectEqualStrings("bar", n.window_renamed.name); +} + +test "tmux window-pane-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_pane_changed); + try testing.expectEqual(42, n.window_pane_changed.window_id); + try testing.expectEqual(2, n.window_pane_changed.pane_id); +} + +test "tmux client-detached" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_detached); + try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client); +} + +test "tmux client-session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .client_session_changed); + try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client); + try testing.expectEqual(2, n.client_session_changed.session_id); + try testing.expectEqualStrings("mysession", n.client_session_changed.name); +} From 7a9dc77a94d05610ec576add0a6096985f83a17b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Dec 2025 12:51:09 -0800 Subject: [PATCH 546/702] terminal/tmux: clean up error handling, explicit error sets --- src/terminal/tmux/control.zig | 74 ++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index 8304b2f1f..b41671aab 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -4,6 +4,7 @@ //! documentation. const std = @import("std"); +const Allocator = std.mem.Allocator; const assert = @import("../../quirks.zig").inlineAssert; const oni = @import("oniguruma"); @@ -56,7 +57,11 @@ pub const Parser = struct { } // Handle a byte of input. - pub fn put(self: *Parser, byte: u8) !?Notification { + // + // If we reach our byte limit this will return OutOfMemory. It only + // does this on the first time we exceed the limit; subsequent calls + // will return null as we drop all input in a broken state. + pub fn put(self: *Parser, byte: u8) Allocator.Error!?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 @@ -89,7 +94,15 @@ pub const Parser = struct { // complete notification we need to parse. .notification => if (byte == '\n') { // We have a complete notification, parse it. - return try self.parseNotification(); + return self.parseNotification() catch { + // If parsing failed, then we do not mark the state + // as broken because we may be able to continue parsing + // other types of notifications. + // + // In the future we may want to emit a notification + // here about unknown or unsupported notifications. + return null; + }; }, // If we're in a block then we accumulate until we see a newline @@ -122,12 +135,16 @@ pub const Parser = struct { }, } - try self.buffer.writer.writeByte(byte); + self.buffer.writer.writeByte(byte) catch |err| switch (err) { + error.WriteFailed => return error.OutOfMemory, + }; return null; } - fn parseNotification(self: *Parser) !?Notification { + const ParseError = error{RegexError}; + + fn parseNotification(self: *Parser) ParseError!?Notification { assert(self.state == .notification); const line = line: { @@ -155,13 +172,16 @@ pub const Parser = struct { self.buffer.clearRetainingCapacity(); return null; } else if (std.mem.eql(u8, cmd, "%output")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%output %([0-9]+) (.+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -183,13 +203,16 @@ pub const Parser = struct { self.state = .idle; return .{ .output = .{ .pane_id = id, .data = data } }; } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%session-changed \\$([0-9]+) (.+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -220,13 +243,16 @@ pub const Parser = struct { self.state = .idle; return .{ .sessions_changed = {} }; } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%window-add @([0-9]+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -247,13 +273,16 @@ pub const Parser = struct { self.state = .idle; return .{ .window_add = .{ .id = id } }; } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%window-renamed @([0-9]+) (.+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -275,13 +304,16 @@ pub const Parser = struct { self.state = .idle; return .{ .window_renamed = .{ .id = id, .name = name } }; } else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%window-pane-changed @([0-9]+) %([0-9]+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -307,13 +339,16 @@ pub const Parser = struct { self.state = .idle; return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } }; } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%client-detached (.+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { @@ -330,13 +365,16 @@ pub const Parser = struct { self.state = .idle; return .{ .client_detached = .{ .client = client } }; } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: { - var re = try oni.Regex.init( + var re = oni.Regex.init( "^%client-session-changed (.+) \\$([0-9]+) (.+)$", .{ .capture_group = true }, oni.Encoding.utf8, oni.Syntax.default, null, - ); + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; defer re.deinit(); var region = re.search(line, .{}) catch |err| { From dfa22379b23ad612d34853aceacc6a7353ec31ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Dec 2025 08:46:33 -0800 Subject: [PATCH 547/702] terminal/tmux: layout string parser --- src/terminal/tmux.zig | 2 + src/terminal/tmux/layout.zig | 459 +++++++++++++++++++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 src/terminal/tmux/layout.zig diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index a6538ea50..0e8c41262 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -1,8 +1,10 @@ //! Types and functions related to tmux protocols. const control = @import("tmux/control.zig"); +const layout = @import("tmux/layout.zig"); pub const ControlParser = control.Parser; pub const ControlNotification = control.Notification; +pub const Layout = layout.Layout; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/tmux/layout.zig b/src/terminal/tmux/layout.zig new file mode 100644 index 000000000..595738251 --- /dev/null +++ b/src/terminal/tmux/layout.zig @@ -0,0 +1,459 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; + +/// A tmux layout. +/// +/// This is a tree structure so by definition it pretty much needs to be +/// allocated. We leave allocation up to the user of this struct, but +/// a general recommendation is to use an arena allocator for simplicity +/// in freeing the entire layout at once. +pub const Layout = struct { + /// Width, height of the node + width: usize, + height: usize, + + /// X and Y offset from the top-left corner of the window. + x: usize, + y: usize, + + /// The content of this node, either a pane (leaf) or more nodes + /// (split) horizontally or vertically. + content: Content, + + pub const Content = union(enum) { + pane: usize, + horizontal: []const Layout, + vertical: []const Layout, + }; + + pub const ParseError = Allocator.Error || error{SyntaxError}; + + /// Parse a layout string into a Layout structure. The given allocator + /// will be used for all allocations within the layout. Note that + /// individual nodes can't be freed so this allocator must be some + /// kind of arena allocator. + /// + /// The layout string must be fully provided as a single string. + /// Layouts are generally small so this should not be a problem. + /// + /// Tmux layout strings have the following format: + /// + /// - WxH,X,Y,ID Leaf pane: width×height, x-offset, y-offset, pane ID + /// - WxH,X,Y{...} Horizontal split (left-right), children comma-separated + /// - WxH,X,Y[...] Vertical split (top-bottom), children comma-separated + pub fn parse(alloc: Allocator, str: []const u8) ParseError!Layout { + var offset: usize = 0; + const root = try parseNext( + alloc, + str, + &offset, + ); + if (offset != str.len) return error.SyntaxError; + return root; + } + + fn parseNext( + alloc: Allocator, + str: []const u8, + offset: *usize, + ) ParseError!Layout { + // Find the first `x` to grab the width. + const width: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + 'x', + )) |idx| width: { + defer offset.* += idx + 1; // Consume `x` + break :width std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find the height, up to a comma. + const height: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + ',', + )) |idx| height: { + defer offset.* += idx + 1; // Consume `,` + break :height std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find X + const x: usize = if (std.mem.indexOfScalar( + u8, + str[offset.*..], + ',', + )) |idx| x: { + defer offset.* += idx + 1; // Consume `,` + break :x std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Find Y, which can end in any of `,{,[` + const y: usize = if (std.mem.indexOfAny( + u8, + str[offset.*..], + ",{[", + )) |idx| y: { + defer offset.* += idx; // Don't consume the delimiter! + break :y std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + } else return error.SyntaxError; + + // Determine our child node. + const content: Layout.Content = switch (str[offset.*]) { + ',' => content: { + // Consume the delimiter + offset.* += 1; + + // Leaf pane. Read up to `,}]` because we may be in + // a set of nodes. If none exist, end of string is fine. + const idx = std.mem.indexOfAny( + u8, + str[offset.*..], + ",}]", + ) orelse str.len - offset.*; + + defer offset.* += idx; // Consume the pane ID, not the delimiter + const pane_id = std.fmt.parseInt( + usize, + str[offset.* .. offset.* + idx], + 10, + ) catch return error.SyntaxError; + + break :content .{ .pane = pane_id }; + }, + + '{', '[' => |opening| content: { + var nodes: std.ArrayList(Layout) = .empty; + defer nodes.deinit(alloc); + + // Move beyond our opening + offset.* += 1; + + while (true) { + try nodes.append(alloc, try parseNext( + alloc, + str, + offset, + )); + + // We should not reach the end of string here because + // we expect a closing bracket. + if (offset.* >= str.len) return error.SyntaxError; + + // If it is a comma, we expect another node. + if (str[offset.*] == ',') { + offset.* += 1; // Consume + continue; + } + + // We expect a closing bracket now. + switch (opening) { + '{' => if (str[offset.*] != '}') return error.SyntaxError, + '[' => if (str[offset.*] != ']') return error.SyntaxError, + else => return error.SyntaxError, + } + + // Successfully parsed all children. + offset.* += 1; // Consume closing bracket + break :content switch (opening) { + '{' => .{ .horizontal = try nodes.toOwnedSlice(alloc) }, + '[' => .{ .vertical = try nodes.toOwnedSlice(alloc) }, + else => unreachable, + }; + } + }, + + // indexOfAny above guarantees we have only the above + else => unreachable, + }; + + return .{ + .width = width, + .height = height, + .x = x, + .y = y, + .content = content, + }; + } +}; + +test "simple single pane" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0,42"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + try testing.expectEqual(42, layout.content.pane); +} + +test "single pane with offset" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "40x12,10,5,7"); + try testing.expectEqual(40, layout.width); + try testing.expectEqual(12, layout.height); + try testing.expectEqual(10, layout.x); + try testing.expectEqual(5, layout.y); + try testing.expectEqual(7, layout.content.pane); +} + +test "single pane large values" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "1920x1080,100,200,999"); + try testing.expectEqual(1920, layout.width); + try testing.expectEqual(1080, layout.height); + try testing.expectEqual(100, layout.x); + try testing.expectEqual(200, layout.y); + try testing.expectEqual(999, layout.content.pane); +} + +test "horizontal split two panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + + const children = layout.content.horizontal; + try testing.expectEqual(2, children.len); + + try testing.expectEqual(40, children[0].width); + try testing.expectEqual(24, children[0].height); + try testing.expectEqual(0, children[0].x); + try testing.expectEqual(0, children[0].y); + try testing.expectEqual(1, children[0].content.pane); + + try testing.expectEqual(40, children[1].width); + try testing.expectEqual(24, children[1].height); + try testing.expectEqual(40, children[1].x); + try testing.expectEqual(0, children[1].y); + try testing.expectEqual(2, children[1].content.pane); +} + +test "vertical split two panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12,2]"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + try testing.expectEqual(0, layout.x); + try testing.expectEqual(0, layout.y); + + const children = layout.content.vertical; + try testing.expectEqual(2, children.len); + + try testing.expectEqual(80, children[0].width); + try testing.expectEqual(12, children[0].height); + try testing.expectEqual(0, children[0].x); + try testing.expectEqual(0, children[0].y); + try testing.expectEqual(1, children[0].content.pane); + + try testing.expectEqual(80, children[1].width); + try testing.expectEqual(12, children[1].height); + try testing.expectEqual(0, children[1].x); + try testing.expectEqual(12, children[1].y); + try testing.expectEqual(2, children[1].content.pane); +} + +test "horizontal split three panes" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parse(arena.allocator(), "120x24,0,0{40x24,0,0,1,40x24,40,0,2,40x24,80,0,3}"); + try testing.expectEqual(120, layout.width); + try testing.expectEqual(24, layout.height); + + const children = layout.content.horizontal; + try testing.expectEqual(3, children.len); + try testing.expectEqual(1, children[0].content.pane); + try testing.expectEqual(2, children[1].content.pane); + try testing.expectEqual(3, children[2].content.pane); +} + +test "nested horizontal in vertical" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Vertical split with top pane and bottom horizontal split + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12{40x12,0,12,2,40x12,40,12,3}]"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + + const vert_children = layout.content.vertical; + try testing.expectEqual(2, vert_children.len); + + // First child is a simple pane + try testing.expectEqual(1, vert_children[0].content.pane); + + // Second child is a horizontal split + const horiz_children = vert_children[1].content.horizontal; + try testing.expectEqual(2, horiz_children.len); + try testing.expectEqual(2, horiz_children[0].content.pane); + try testing.expectEqual(3, horiz_children[1].content.pane); +} + +test "nested vertical in horizontal" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Horizontal split with left pane and right vertical split + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0[40x12,40,0,2,40x12,40,12,3]}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); + + const horiz_children = layout.content.horizontal; + try testing.expectEqual(2, horiz_children.len); + + // First child is a simple pane + try testing.expectEqual(1, horiz_children[0].content.pane); + + // Second child is a vertical split + const vert_children = horiz_children[1].content.vertical; + try testing.expectEqual(2, vert_children.len); + try testing.expectEqual(2, vert_children[0].content.pane); + try testing.expectEqual(3, vert_children[1].content.pane); +} + +test "deeply nested layout" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Three levels deep + const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0[40x12,0,0,1,40x12,0,12,2],40x24,40,0,3}"); + + const horiz = layout.content.horizontal; + try testing.expectEqual(2, horiz.len); + + const vert = horiz[0].content.vertical; + try testing.expectEqual(2, vert.len); + try testing.expectEqual(1, vert[0].content.pane); + try testing.expectEqual(2, vert[1].content.pane); + + try testing.expectEqual(3, horiz[1].content.pane); +} + +test "syntax error empty string" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "")); +} + +test "syntax error missing width" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "x24,0,0,1")); +} + +test "syntax error missing height" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x,0,0,1")); +} + +test "syntax error missing x" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,,0,1")); +} + +test "syntax error missing y" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,,1")); +} + +test "syntax error missing pane id" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,")); +} + +test "syntax error non-numeric width" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "abcx24,0,0,1")); +} + +test "syntax error non-numeric pane id" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,abc")); +} + +test "syntax error unclosed horizontal bracket" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1")); +} + +test "syntax error unclosed vertical bracket" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1")); +} + +test "syntax error mismatched brackets" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1]")); + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1}")); +} + +test "syntax error trailing data" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,1extra")); +} + +test "syntax error no x separator" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "8024,0,0,1")); +} + +test "syntax error no content delimiter" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0")); +} From 92ea8d0eb521032376cad618620ae634cffc6963 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Dec 2025 08:57:41 -0800 Subject: [PATCH 548/702] terminal/tmux: layout checksums --- src/terminal/tmux/layout.zig | 179 +++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/src/terminal/tmux/layout.zig b/src/terminal/tmux/layout.zig index 595738251..df1a53917 100644 --- a/src/terminal/tmux/layout.zig +++ b/src/terminal/tmux/layout.zig @@ -30,6 +30,36 @@ pub const Layout = struct { pub const ParseError = Allocator.Error || error{SyntaxError}; + /// Parse a layout string that includes a 4-character checksum prefix. + /// + /// The expected format is: `XXXX,layout_string` where XXXX is the + /// 4-character hexadecimal checksum and the layout string follows + /// after the comma. For example: `f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}`. + /// + /// Returns `ChecksumMismatch` if the checksum doesn't match the layout. + /// Returns `SyntaxError` if the format is invalid. + pub fn parseWithChecksum( + alloc: Allocator, + str: []const u8, + ) (ParseError || error{ChecksumMismatch})!Layout { + // If the string is less than 5 characters, it can't possibly + // be correct. 4-char checksum + comma. In practice it should + // be even longer, but that'll fail parse later. + if (str.len < 5) return error.SyntaxError; + if (str[4] != ',') return error.SyntaxError; + + // The layout string should start with a 4-character checksum. + const checksum: Checksum = .calculate(str[5..]); + if (!std.mem.startsWith( + u8, + str, + &checksum.asString(), + )) return error.ChecksumMismatch; + + // Checksum matches, parse the rest. + return try parse(alloc, str[5..]); + } + /// Parse a layout string into a Layout structure. The given allocator /// will be used for all allocations within the layout. Note that /// individual nodes can't be freed so this allocator must be some @@ -194,6 +224,38 @@ pub const Layout = struct { } }; +pub const Checksum = enum(u16) { + _, + + /// Calculate the checksum of a tmux layout string. + /// The algorithm rotates the checksum right by 1 bit (with wraparound) + /// and adds the ASCII value of each character. + pub fn calculate(str: []const u8) Checksum { + var result: u16 = 0; + for (str) |c| { + // Rotate right by 1: (result >> 1) + ((result & 1) << 15) + result = (result >> 1) | ((result & 1) << 15); + result +%= c; + } + + return @enumFromInt(result); + } + + /// Convert the checksum to a 4-character hexadecimal string. This + /// is always zero-padded to match the tmux implementation + /// (in layout-custom.c). + pub fn asString(self: Checksum) [4]u8 { + const value = @intFromEnum(self); + const charset = "0123456789abcdef"; + return .{ + charset[(value >> 12) & 0xf], + charset[(value >> 8) & 0xf], + charset[(value >> 4) & 0xf], + charset[value & 0xf], + }; + } +}; + test "simple single pane" { var arena: ArenaAllocator = .init(testing.allocator); defer arena.deinit(); @@ -457,3 +519,120 @@ test "syntax error no content delimiter" { try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0")); } + +// parseWithChecksum tests + +test "parseWithChecksum valid" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + const layout: Layout = try .parseWithChecksum(arena.allocator(), "f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqual(80, layout.width); + try testing.expectEqual(24, layout.height); +} + +test "parseWithChecksum mismatch" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.ChecksumMismatch, Layout.parseWithChecksum(arena.allocator(), "0000,80x24,0,0{40x24,0,0,1,40x24,40,0,2}")); +} + +test "parseWithChecksum too short" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62")); + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "")); +} + +test "parseWithChecksum missing comma" { + var arena: ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62x159x48,0,0")); +} + +// Checksum tests + +test "checksum empty string" { + const checksum = Checksum.calculate(""); + try testing.expectEqual(@as(u16, 0), @intFromEnum(checksum)); + try testing.expectEqualStrings("0000", &checksum.asString()); +} + +test "checksum single character" { + // 'A' = 65, first iteration: csum = 0 >> 1 | 0 = 0, then 0 + 65 = 65 + const checksum = Checksum.calculate("A"); + try testing.expectEqual(@as(u16, 65), @intFromEnum(checksum)); + try testing.expectEqualStrings("0041", &checksum.asString()); +} + +test "checksum two characters" { + // 'A' (65): csum = 0, rotate = 0, add 65 => 65 + // 'B' (66): csum = 65, rotate => (65 >> 1) | ((65 & 1) << 15) = 32 | 32768 = 32800 + // add 66 => 32800 + 66 = 32866 + const checksum = Checksum.calculate("AB"); + try testing.expectEqual(@as(u16, 32866), @intFromEnum(checksum)); + try testing.expectEqualStrings("8062", &checksum.asString()); +} + +test "checksum simple layout" { + const checksum = Checksum.calculate("80x24,0,0,42"); + try testing.expectEqualStrings("d962", &checksum.asString()); +} + +test "checksum horizontal split layout" { + const checksum = Checksum.calculate("80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + try testing.expectEqualStrings("f8f9", &checksum.asString()); +} + +test "checksum asString zero padding" { + // Value 0x000f should produce "000f" + const checksum: Checksum = @enumFromInt(0x000f); + try testing.expectEqualStrings("000f", &checksum.asString()); +} + +test "checksum asString all digits" { + // Value 0x1234 should produce "1234" + const checksum: Checksum = @enumFromInt(0x1234); + try testing.expectEqualStrings("1234", &checksum.asString()); +} + +test "checksum asString with letters" { + // Value 0xabcd should produce "abcd" + const checksum: Checksum = @enumFromInt(0xabcd); + try testing.expectEqualStrings("abcd", &checksum.asString()); +} + +test "checksum asString max value" { + // Value 0xffff should produce "ffff" + const checksum: Checksum = @enumFromInt(0xffff); + try testing.expectEqualStrings("ffff", &checksum.asString()); +} + +test "checksum wraparound" { + const checksum = Checksum.calculate("\xff\xff\xff\xff\xff\xff\xff\xff"); + try testing.expectEqualStrings("03fc", &checksum.asString()); +} + +test "checksum deterministic" { + // Same input should always produce same output + const str = "159x48,0,0{79x48,0,0,79x48,80,0}"; + const checksum1 = Checksum.calculate(str); + const checksum2 = Checksum.calculate(str); + try testing.expectEqual(checksum1, checksum2); +} + +test "checksum different inputs different outputs" { + const checksum1 = Checksum.calculate("80x24,0,0,1"); + const checksum2 = Checksum.calculate("80x24,0,0,2"); + try testing.expect(@intFromEnum(checksum1) != @intFromEnum(checksum2)); +} + +test "checksum known tmux layout bb62" { + // From tmux documentation: "bb62,159x48,0,0{79x48,0,0,79x48,80,0}" + // The checksum "bb62" corresponds to the layout "159x48,0,0{79x48,0,0,79x48,80,0}" + const checksum = Checksum.calculate("159x48,0,0{79x48,0,0,79x48,80,0}"); + try testing.expectEqualStrings("bb62", &checksum.asString()); +} From b95965cb5ae83db763a93b8852a4905d6a154619 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Dec 2025 09:27:09 -0800 Subject: [PATCH 549/702] terminal/tmux: add layout-change to control mode parsing --- src/terminal/tmux/control.zig | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index b41671aab..3624173dd 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -242,6 +242,44 @@ pub const Parser = struct { self.buffer.clearRetainingCapacity(); self.state = .idle; return .{ .sessions_changed = {} }; + } else if (std.mem.eql(u8, cmd, "%layout-change")) cmd: { + var re = oni.Regex.init( + "^%layout-change @([0-9]+) (.+) (.+) (.*)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const layout = line[@intCast(starts[2])..@intCast(ends[2])]; + const visible_layout = line[@intCast(starts[3])..@intCast(ends[3])]; + const raw_flags = line[@intCast(starts[4])..@intCast(ends[4])]; + + // Important: do not clear buffer here since layout strings point to it + self.state = .idle; + return .{ .layout_change = .{ + .window_id = id, + .layout = layout, + .visible_layout = visible_layout, + .raw_flags = raw_flags, + } }; } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { var re = oni.Regex.init( "^%window-add @([0-9]+)$", @@ -455,6 +493,14 @@ pub const Notification = union(enum) { /// A session was created or destroyed. sessions_changed, + /// The layout of the window with ID window-id changed. + layout_change: struct { + window_id: usize, + layout: []const u8, + visible_layout: []const u8, + raw_flags: []const u8, + }, + /// The window with ID window-id was linked to the current session. window_add: struct { id: usize, @@ -575,6 +621,21 @@ test "tmux sessions-changed carriage return" { try testing.expect(n == .sessions_changed); } +test "tmux layout-change" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%layout-change @2 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} *-") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .layout_change); + try testing.expectEqual(2, n.layout_change.window_id); + try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.layout); + try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.visible_layout); + try testing.expectEqualStrings("*-", n.layout_change.raw_flags); +} + test "tmux window-add" { const testing = std.testing; const alloc = testing.allocator; From 6b21b9147c09028ebb58a5a7f09b44aacb49d1ba Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Dec 2025 19:57:21 -0800 Subject: [PATCH 550/702] terminal/tmux: add output format parsing (minimal) --- src/terminal/tmux.zig | 1 + src/terminal/tmux/output.zig | 205 +++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/terminal/tmux/output.zig diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 0e8c41262..82ef5036b 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -2,6 +2,7 @@ const control = @import("tmux/control.zig"); const layout = @import("tmux/layout.zig"); +pub const output = @import("tmux/output.zig"); pub const ControlParser = control.Parser; pub const ControlNotification = control.Notification; pub const Layout = layout.Layout; diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig new file mode 100644 index 000000000..dcfa89ac3 --- /dev/null +++ b/src/terminal/tmux/output.zig @@ -0,0 +1,205 @@ +const std = @import("std"); +const testing = std.testing; + +pub const ParseError = error{ + MissingEntry, + ExtraEntry, + FormatError, +}; + +/// Parse the output from a command with the given format struct +/// (returned usually by FormatStruct). The format struct is expected +/// to be in the order of the variables used in the format string and +/// the variables are expected to be plain variables (no conditionals, +/// extra formatting, etc.). Each variable is expected to be separated +/// by a single `delimiter` character. +pub fn parseFormatStruct( + comptime T: type, + str: []const u8, + delimiter: u8, +) ParseError!T { + // Parse all our fields + const fields = @typeInfo(T).@"struct".fields; + var it = std.mem.splitScalar(u8, str, delimiter); + var result: T = undefined; + inline for (fields) |field| { + const part = it.next() orelse return error.MissingEntry; + @field(result, field.name) = Variable.parse( + @field(Variable, field.name), + part, + ) catch return error.FormatError; + } + + // We should have consumed all parts now. + if (it.next() != null) return error.ExtraEntry; + + return result; +} + +/// Returns a struct type that contains fields for each of the given +/// format variables. This can be used with `parseFormatStruct` to +/// parse an output string into a format struct. +pub fn FormatStruct(comptime vars: []const Variable) type { + var fields: [vars.len]std.builtin.Type.StructField = undefined; + for (vars, &fields) |variable, *field| { + field.* = .{ + .name = @tagName(variable), + .type = variable.Type(), + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(variable.Type()), + }; + } + + return @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +} + +/// Possible variables in a tmux format string that we support. +/// +/// Tmux supports a large number of variables, but we only implement +/// a subset of them here that are relevant to the use case of implementing +/// control mode for terminal emulators. +pub const Variable = enum { + session_id, + window_id, + window_width, + window_height, + window_layout, + + /// Parse the given string value into the appropriate resulting + /// type for this variable. + pub fn parse(comptime self: Variable, value: []const u8) !Type(self) { + return switch (self) { + .session_id => if (value.len >= 2 and value[0] == '$') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, + .window_id => if (value.len >= 2 and value[0] == '@') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, + .window_width => try std.fmt.parseInt(usize, value, 10), + .window_height => try std.fmt.parseInt(usize, value, 10), + .window_layout => value, + }; + } + + /// The type of the parsed value for this variable type. + pub fn Type(comptime self: Variable) type { + return switch (self) { + .session_id => usize, + .window_id => usize, + .window_width => usize, + .window_height => usize, + .window_layout => []const u8, + }; + } +}; + +test "parse session id" { + try testing.expectEqual(42, try Variable.parse(.session_id, "$42")); + try testing.expectEqual(0, try Variable.parse(.session_id, "$0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "@0")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "$")); + try testing.expectError(error.FormatError, Variable.parse(.session_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.session_id, "$abc")); +} + +test "parse window id" { + try testing.expectEqual(42, try Variable.parse(.window_id, "@42")); + try testing.expectEqual(0, try Variable.parse(.window_id, "@0")); + try testing.expectEqual(12345, try Variable.parse(.window_id, "@12345")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "$0")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "@")); + try testing.expectError(error.FormatError, Variable.parse(.window_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_id, "@abc")); +} + +test "parse window width" { + try testing.expectEqual(80, try Variable.parse(.window_width, "80")); + try testing.expectEqual(0, try Variable.parse(.window_width, "0")); + try testing.expectEqual(12345, try Variable.parse(.window_width, "12345")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "abc")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "80px")); + try testing.expectError(error.Overflow, Variable.parse(.window_width, "-1")); +} + +test "parse window height" { + try testing.expectEqual(24, try Variable.parse(.window_height, "24")); + try testing.expectEqual(0, try Variable.parse(.window_height, "0")); + try testing.expectEqual(12345, try Variable.parse(.window_height, "12345")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "abc")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "24px")); + try testing.expectError(error.Overflow, Variable.parse(.window_height, "-1")); +} + +test "parse window layout" { + try testing.expectEqualStrings("abc123", try Variable.parse(.window_layout, "abc123")); + try testing.expectEqualStrings("", try Variable.parse(.window_layout, "")); + try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)")); +} + +test "parseFormatStruct single field" { + const T = FormatStruct(&.{.session_id}); + const result = try parseFormatStruct(T, "$42", ' '); + try testing.expectEqual(42, result.session_id); +} + +test "parseFormatStruct multiple fields" { + const T = FormatStruct(&.{ .session_id, .window_id, .window_width, .window_height }); + const result = try parseFormatStruct(T, "$1 @2 80 24", ' '); + try testing.expectEqual(1, result.session_id); + try testing.expectEqual(2, result.window_id); + try testing.expectEqual(80, result.window_width); + try testing.expectEqual(24, result.window_height); +} + +test "parseFormatStruct with string field" { + const T = FormatStruct(&.{ .window_id, .window_layout }); + const result = try parseFormatStruct(T, "@5,abc123", ','); + try testing.expectEqual(5, result.window_id); + try testing.expectEqualStrings("abc123", result.window_layout); +} + +test "parseFormatStruct different delimiter" { + const T = FormatStruct(&.{ .window_width, .window_height }); + const result = try parseFormatStruct(T, "120\t40", '\t'); + try testing.expectEqual(120, result.window_width); + try testing.expectEqual(40, result.window_height); +} + +test "parseFormatStruct missing entry" { + const T = FormatStruct(&.{ .session_id, .window_id }); + try testing.expectError(error.MissingEntry, parseFormatStruct(T, "$1", ' ')); +} + +test "parseFormatStruct extra entry" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.ExtraEntry, parseFormatStruct(T, "$1 @2", ' ')); +} + +test "parseFormatStruct format error" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.FormatError, parseFormatStruct(T, "42", ' ')); + try testing.expectError(error.FormatError, parseFormatStruct(T, "@42", ' ')); + try testing.expectError(error.FormatError, parseFormatStruct(T, "$abc", ' ')); +} + +test "parseFormatStruct empty string" { + const T = FormatStruct(&.{.session_id}); + try testing.expectError(error.FormatError, parseFormatStruct(T, "", ' ')); +} + +test "parseFormatStruct with empty layout field" { + const T = FormatStruct(&.{ .session_id, .window_layout }); + const result = try parseFormatStruct(T, "$1,", ','); + try testing.expectEqual(1, result.session_id); + try testing.expectEqualStrings("", result.window_layout); +} From 0a03434656d064163428cbb180bf4a5880e70064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Bulteel?= Date: Sun, 2 Nov 2025 14:09:54 +0100 Subject: [PATCH 551/702] gtk: fix xkb mapping not working on linux Signed-off-by: Cedric BULTEEL --- src/apprt/gtk/class/surface.zig | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index fcbfbe6ab..09d82fe5c 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1137,13 +1137,14 @@ pub const Surface = extern struct { if (entry.native == keycode) break :w3c entry.key; } else .unidentified; - // If the key should be remappable, then consult the pre-remapped - // XKB keyval/keysym to get the (possibly) remapped key. + // Consult the pre-remapped XKB keyval/keysym to get the (possibly) + // remapped key. If the W3C key or the remapped key + // is eligible for remapping, we use it. // // See the docs for `shouldBeRemappable` for why we even have to // do this in the first place. - if (w3c_key.shouldBeRemappable()) { - if (gtk_key.keyFromKeyval(keyval)) |remapped| + if (gtk_key.keyFromKeyval(keyval)) |remapped| { + if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable()) break :keycode remapped; } From 68426dc21a93ed97b52511c3bdf69c5fec1fd6ae Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 4 Dec 2025 12:30:51 -0600 Subject: [PATCH 552/702] core: rate limit BEL character processing If the BEL character is received too frequently, the GUI thread can be starved and Ghostty will lock up and eventually crash. This PR limits BEL handling to 1 per 100ms. Fixes #9800. --- src/Surface.zig | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 40929e168..18eac39ca 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -155,6 +155,9 @@ command_timer: ?std.time.Instant = null, /// Search state search: ?Search = null, +/// Used to rate limit BEL handling. +last_bell_time: ?std.time.Instant = 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 @@ -1026,7 +1029,12 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .password_input => |v| try self.passwordInput(v), - .ring_bell => { + .ring_bell => bell: { + const now = std.time.Instant.now() catch unreachable; + if (self.last_bell_time) |last| { + if (now.since(last) < 100 * std.time.ns_per_ms) break :bell; + } + self.last_bell_time = now; _ = self.rt_app.performAction( .{ .surface = self }, .ring_bell, From 6b2097e8720a3bd8b5ca104bb70a10ed24178522 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Dec 2025 19:44:39 -0800 Subject: [PATCH 553/702] core: hold lock during keyCallback when mouseRefreshLinks is called From #9812 I'm not sure if this is the root cause of the crash in #9812 but the LLM-discovered issue that we are not holding a lock here appears to be a real issue. I manually traced the code paths and thought about this and looked where we call `mouseRefreshLinks` in other places and this appears to be a real bug. --- src/Surface.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 18eac39ca..1926c4394 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2592,6 +2592,8 @@ pub fn keyCallback( { // Refresh our link state const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); self.mouseRefreshLinks( pos, self.posToViewport(pos.x, pos.y), From f98b12579e7b02108bb03fc9d75d8fecef82ca7e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 08:30:33 -0800 Subject: [PATCH 554/702] core: selection and copy bindings need to hold the big lock This was found by LLM hunting! We were not holding the lock properly during these operations. There aren't any known cases where we can directly attribute these races to issues but we did find at least one consistent crash for a user when `linkAtPos` wasn't properly locked (in another PR). This continues those fixes. --- src/Surface.zig | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1926c4394..653178bdc 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5032,8 +5032,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .copy_to_clipboard => |format| { - // We can read from the renderer state without holding - // the lock because only we will write to this field. + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + if (self.io.terminal.screens.active.selection) |sel| { try self.copySelectionToClipboards( sel, @@ -5061,8 +5062,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .copy_url_to_clipboard => { // If the mouse isn't over a link, nothing we can do. if (!self.mouse.over_link) return false; - const pos = try self.rt_surface.getCursorPos(); + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); if (try self.linkAtPos(pos)) |link_info| { const url_text = switch (link_info[0]) { .open => url_text: { @@ -5438,6 +5441,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ), .select_all => { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const sel = self.io.terminal.screens.active.selectAll(); if (sel) |s| { try self.setSelection(s); From d09621fa11b1470f9859a2e3201741acd2ab5ec9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 08:41:16 -0800 Subject: [PATCH 555/702] ci: cancel prior test runs for the same git ref This should save on CI quite a bit. This will cancel our GHA runs when you push to the same ref, except for `main`, where I want to make sure every commit is tested. --- .github/workflows/nix.yml | 5 +++++ .github/workflows/test.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 825cf52f5..f928ed5a5 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -1,5 +1,10 @@ on: [push, pull_request] name: Nix + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name != 'main' && github.ref || github.run_id }} + cancel-in-progress: true + jobs: required: name: "Required Checks: Nix" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 916745f58..20f674bab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,11 @@ on: name: Test +# We only want the latest commit to test for any non-main ref. +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name != 'main' && github.ref || github.run_id }} + cancel-in-progress: true + jobs: required: name: "Required Checks: Test" From aa0afa2d0225bfe3566a7508057569f3bc410aac Mon Sep 17 00:00:00 2001 From: voideanvalue Date: Sat, 6 Dec 2025 22:17:33 +0000 Subject: [PATCH 556/702] fix C ABI compat for ghostty_quick_terminal_size_tag_e --- src/config/Config.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 82e81a01f..20256e951 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -7987,7 +7987,8 @@ pub const QuickTerminalSize = struct { tag: Tag, value: Value, - pub const Tag = enum(u8) { none, percentage, pixels }; + /// c_int because it needs to be extern compatible + pub const Tag = enum(c_int) { none, percentage, pixels }; pub const Value = extern union { percentage: f32, From 6e081b2c81ca807eb3ea0b87491d98e4900254d9 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:15:51 +0000 Subject: [PATCH 557/702] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 993904aec..20cf44141 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", - .hash = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 9ca70c410..cb827e238 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN": { + "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz", - "hash": "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 2563f5411..0ec137c70 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN"; + name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz"; - hash = "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; + hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 4362c5d36..6b19df24e 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-20251124-150533-2b326a8/ghostty-themes.tgz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/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 672fd7a5f..9563f9622 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-20251124-150533-2b326a8/ghostty-themes.tgz", - "dest": "vendor/p/N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN", - "sha256": "e669975bb77d4a47ac1f22305019569b21ad3967fac2ed12eb39077bddc554b3" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, { "type": "archive", From aa504b27842f8a740a3a4f6c32dcd7b0908d2eaf Mon Sep 17 00:00:00 2001 From: voideanvalue Date: Sun, 7 Dec 2025 00:51:37 +0000 Subject: [PATCH 558/702] add assertionFailure for unexpected QuickTerminalSize tag --- macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift index 9f86a7c2b..08bbcb8d9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift @@ -33,6 +33,7 @@ struct QuickTerminalSize { case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS: self = .pixels(cStruct.value.pixels) default: + assertionFailure() return nil } } From 90ab79445744688c90120563105efddf3880825c Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sun, 7 Dec 2025 12:04:50 +0800 Subject: [PATCH 559/702] CONTRIBUTING: tighten AI assistance disclosure requirements --- CONTRIBUTING.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7df4b71..0bc74af5d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,10 +23,20 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -If PR responses are being generated by an AI, disclose that as well. As a small exception, trivial tab-completion doesn't need to be disclosed, so long as it is limited to single keywords or short phrases. +The submitter must have also tested the pull request on all impacted +platforms, and it's **highly discouraged** to code for an unfamiliar platform +with AI assistance alone: if you only have a macOS machine, do **not** ask AI +to write the equivalent GTK code, and vice versa — someone else with more +expertise will eventually get to it and do it for you. + +Even though using AI to generate responses on a PR is allowed when properly +disclosed, **we do not encourage you to do so**. Often, the positive impact +of genuine, responsive human interaction more than makes up for any language +barrier. ❤️ + An example disclosure: > This PR was written primarily by Claude Code. @@ -36,6 +46,11 @@ Or a more detailed disclosure: > I consulted ChatGPT to understand the codebase but the solution > was fully authored manually by myself. +An example of a **problematic** disclosure (not having tested all platforms): + +> I used Amp to code both macOS and GTK UIs, but I have not yet tested +> the GTK UI as I don't have a Linux setup. + Failure to disclose this is first and foremost rude to the human operators on the other end of the pull request, but it also makes it difficult to determine how much scrutiny to apply to the contribution. @@ -45,10 +60,12 @@ work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! -When using AI assistance, we expect contributors to understand the code +When using AI assistance, we expect a fairly high level of accountability +and responsibility from contributors, and expect them to understand the code that is produced and be able to answer critical questions about it. It isn't a maintainers job to review a PR so broken that it requires -significant rework to be acceptable. +significant rework to be acceptable, and we **reserve the right to close +these PRs without hesitation**. Please be respectful to maintainers and disclose AI assistance. From c9655eefe527b74b86da7516825792df6ab1ed38 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sun, 7 Dec 2025 12:36:22 +0800 Subject: [PATCH 560/702] CONTRIBUTING: clarify discussion categories & discord channels --- CONTRIBUTING.md | 67 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0bc74af5d..b4285f42f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,22 +91,47 @@ submission. ### I have a bug! / Something isn't working -1. Search the issue tracker and discussions for similar issues. Tip: also - search for [closed issues] and [discussions] — your issue might have already - been fixed! -2. If your issue hasn't been reported already, open an ["Issue Triage" discussion] - and make sure to fill in the template **completely**. They are vital for - maintainers to figure out important details about your setup. Because of - this, please make sure that you _only_ use the "Issue Triage" category for - reporting bugs — thank you! +First, search the issue tracker and discussions for similar issues. Tip: also +search for [closed issues] and [discussions] — your issue might have already +been fixed! + +> [!NOTE] +> +> If there is an _open_ issue or discussion that matches your problem, +> **please do not comment on it unless you have valuable insight to add**. +> +> GitHub has a very _noisy_ set of default notification settings which +> sends an email to _every participant_ in an issue/discussion every time +> someone adds a comment. Instead, use the handy upvote button for discussions, +> and/or emoji reactions on both discussions and issues, which are a visible +> yet non-disruptive way to show your support. + +If your issue hasn't been reported already, open an ["Issue Triage"] discussion +and make sure to fill in the template **completely**. They are vital for +maintainers to figure out important details about your setup. + +> [!WARNING] +> +> A _very_ common mistake is to file a bug report either as a Q&A or a Feature +> Request. **Please don't do this.** Otherwise, maintainers would have to ask +> for your system information again manually, and sometimes they will even ask +> you to create a new discussion because of how few detailed information is +> required for other discussion types compared to Issue Triage. +> +> Because of this, please make sure that you _only_ use the "Issue Triage" +> category for reporting bugs — thank you! [closed issues]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20state%3Aclosed [discussions]: https://github.com/ghostty-org/ghostty/discussions?discussions_q=is%3Aclosed -["Issue Triage" discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage +["Issue Triage"]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage ### I have an idea for a feature -Open a discussion in the ["Feature Requests, Ideas" category](https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas). +Like bug reports, first search through both issues and discussions and try to +find if your feature has already been requested. Otherwise, open a discussion +in the ["Feature Requests, Ideas"] category. + +["Feature Requests, Ideas"]: https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas ### I've implemented a feature @@ -115,10 +140,28 @@ Open a discussion in the ["Feature Requests, Ideas" category](https://github.com 3. If you want to live dangerously, open a pull request and [hope for the best](#pull-requests-implement-an-issue). -### I have a question +### I have a question which is neither a bug report nor a feature request Open an [Q&A discussion], or join our [Discord Server] and ask away in the -`#help` channel. +`#help` forum channel. + +Do not use the `#terminals` or `#development` channels to ask for help — +those are for general discussion about terminals and Ghostty development +respectively. If you do ask a question there, you will be redirected to +`#help` instead. + +> [!NOTE] +> If your question is about a missing feature, please open a discussion under +> the ["Feature Requests, Ideas"] category. If Ghostty is behaving +> unexpectedly, use the ["Issue Triage"] category. +> +> The "Q&A" category is strictly for other kinds of discussions and do not +> require detailed information unlike the two other categories, meaning that +> maintainers would have to spend the extra effort to ask for basic information +> if you submit a bug report under this category. +> +> Therefore, please **pay attention to the category** before opening +> discussions to save us all some time and energy. Thank you! [Q&A discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=q-a [Discord Server]: https://discord.gg/ghostty From 0c9082eb7235fc46e8851a412568bbf2f61ef3fa Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Sun, 26 Oct 2025 17:24:47 +0100 Subject: [PATCH 561/702] macOS: fix theme reloading ### Background After #9344, the Ghostty theme won't change after switching systems', and reverting #9344 will bring back the issue it fixed. The reason these two issues are related is because the scheme change is based on changes of `effectiveAppearance`, which is also affected by setting the window's `appearance` or changing `NSAppearance.currentDrawing()`. ### Changes Instead of observing `effectiveAppearance`, we now explicitly update the color scheme of surfaces, so that we can control when it happens to avoid callback loops and redundant updates. ### Regression Tests - [x] #8282 - [x] Reloading with `window-theme = light` should update Ghostty with the default dark theme with a dark window theme (break before [#83104ff](https://github.com/ghostty-org/ghostty/commit/83104ff27a42fbcd5a7dec7677d9ed4f9b9c59c8)) - [x] `window-theme = light \n macos-titlebar-style = native` should update Ghostty with the default dark theme with a light window theme - [x] Reloading from the default config to `theme=light:3024 Day,dark:3024 Night \n window-theme = light`, should update Ghostty with the theme `3024 Day` with a light window theme (break on [#d39cc6d](https://github.com/ghostty-org/ghostty/commit/d39cc6d478edd6e1f412fa680e5166ea4f24c898)) - [x] Using `theme=light:3024 Day,dark:3024 Night`; Switching the system's appearance should change Ghostty's appearance (break on [#d39cc6d](https://github.com/ghostty-org/ghostty/commit/d39cc6d478edd6e1f412fa680e5166ea4f24c898)) - [x] Reloading from `theme=light:3024 Day,dark:3024 Night` with a light window theme to the default config, should update Ghostty with the default dark theme with a dark window theme - [x] Reloading from the default config to `theme=light:3024 Day,dark:3024 Night \n window-theme=dark`, should update Ghostty with the theme `3024 Night` with a dark window theme - [x] Reloading from `theme=light:3024 Day,dark:3024 Night \n window-theme=dark` to `theme=light:3024 Day,dark:3024 Night` with light system appearance, should update Ghostty from dark to light - [x] Reload with quick terminal open # Conflicts: # macos/Sources/Features/Terminal/BaseTerminalController.swift --- .../QuickTerminalController.swift | 1 + .../Terminal/BaseTerminalController.swift | 34 +++++++++++++++++++ .../Terminal/TerminalController.swift | 12 ++----- .../Window Styles/TerminalWindow.swift | 5 +++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 20 ----------- 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 4c2052f23..201289736 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -566,6 +566,7 @@ class QuickTerminalController: BaseTerminalController { private func syncAppearance() { guard let window else { return } + defer { updateColorSchemeForSurfaceTree() } // Change the collection behavior of the window depending on the configuration. window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9104e61ff..1c8e258f7 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -72,6 +72,9 @@ class BaseTerminalController: NSWindowController, /// The previous frame information from the window private var savedFrame: SavedFrame? = nil + /// Cache previously applied appearance to avoid unnecessary updates + private var appliedColorScheme: ghostty_color_scheme_e? + /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig @@ -1163,4 +1166,35 @@ extension BaseTerminalController: NSMenuItemValidation { return true } } + + // MARK: - Surface Color Scheme + + /// Update the surface tree's color scheme only when it actually changes. + /// + /// Calling ``ghostty_surface_set_color_scheme`` triggers + /// ``syncAppearance(_:)`` via notification, + /// so we avoid redundant calls. + func updateColorSchemeForSurfaceTree() { + /// Derive the target scheme from `window-theme` or system appearance. + /// We set the scheme on surfaces so they pick the correct theme + /// and let ``syncAppearance(_:)`` update the window accordingly. + /// + /// Using App's effectiveAppearance here to prevent incorrect updates. + let themeAppearance = NSApplication.shared.effectiveAppearance + let scheme: ghostty_color_scheme_e + if themeAppearance.isDark { + scheme = GHOSTTY_COLOR_SCHEME_DARK + } else { + scheme = GHOSTTY_COLOR_SCHEME_LIGHT + } + guard scheme != appliedColorScheme else { + return + } + for surfaceView in surfaceTree { + if let surface = surfaceView.surface { + ghostty_surface_set_color_scheme(surface, scheme) + } + } + appliedColorScheme = scheme + } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 93a05b6b9..5cc2c67f1 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -425,15 +425,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return } - - // This is a surface-level config update. If we have the surface, we - // update our appearance based on it. - guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard surfaceTree.contains(surfaceView) else { return } - - // We can't use surfaceView.derivedConfig because it may not be updated - // yet since it also responds to notifications. - syncAppearance(.init(config)) + /// Surface-level config will be updated in + /// ``Ghostty/Ghostty/SurfaceView/derivedConfig`` then + /// ``TerminalController/focusedSurfaceDidChange(to:)`` } /// Update the accessory view of each tab according to the keyboard diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index a829ec519..2208d99cf 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -419,6 +419,7 @@ class TerminalWindow: NSWindow { // have no effect if the window is not visible. Ultimately, we'll have this called // at some point when a surface becomes focused. guard isVisible else { return } + defer { updateColorSchemeForSurfaceTree() } // Basic properties appearance = surfaceConfig.windowAppearance @@ -481,6 +482,10 @@ class TerminalWindow: NSWindow { return derivedConfig.backgroundColor.withAlphaComponent(alpha) } + func updateColorSchemeForSurfaceTree() { + terminalController?.updateColorSchemeForSurfaceTree() + } + private func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. guard let x, let y else { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 03ef293af..e86df4454 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -369,26 +369,6 @@ extension Ghostty { // Setup our tracking area so we get mouse moved events updateTrackingAreas() - // Observe our appearance so we can report the correct value to libghostty. - // This is the best way I know of to get appearance change notifications. - self.appearanceObserver = observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in - guard let appearance = change.newValue else { return } - guard let surface = view.surface else { return } - let scheme: ghostty_color_scheme_e - switch (appearance.name) { - case .aqua, .vibrantLight: - scheme = GHOSTTY_COLOR_SCHEME_LIGHT - - case .darkAqua, .vibrantDark: - scheme = GHOSTTY_COLOR_SCHEME_DARK - - default: - return - } - - ghostty_surface_set_color_scheme(surface, scheme) - } - // The UTTypes that can be dragged onto this view. registerForDraggedTypes(Array(Self.dropTypes)) } From 9d4f96381a8211f79594b9f448954a3d98de8aa4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 14:14:03 -0800 Subject: [PATCH 562/702] make our quirks assert use `std.debug.assert` in debug builds This fixes an issue I have on both macOS and Linux (ARM and x86_64) where stack traces are broken for inlined functions. They don't point to the proper location in the source code, making debugging difficult. Release builds use the same previous function. --- src/quirks.zig | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/quirks.zig b/src/quirks.zig index 5129923d2..db2760141 100644 --- a/src/quirks.zig +++ b/src/quirks.zig @@ -7,6 +7,7 @@ //! [1]: https://github.com/WebKit/WebKit/blob/main/Source/WebCore/page/Quirks.cpp const std = @import("std"); +const builtin = @import("builtin"); const font = @import("font/main.zig"); @@ -41,6 +42,16 @@ pub fn disableDefaultFontFeatures(face: *const font.Face) bool { /// 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; -} +pub const inlineAssert: fn (bool) void = switch (builtin.mode) { + // In debug builds we just use std.debug.assert because this + // fixes up stack traces. `inline` causes broken stack traces. This + // is probably a Zig compiler bug but until it is fixed we have to + // do this for development sanity. + .Debug => std.debug.assert, + + .ReleaseSmall, .ReleaseSafe, .ReleaseFast => (struct { + inline fn assert(ok: bool) void { + if (!ok) unreachable; + } + }).assert, +}; From 5131998eda852206c1cf936c28499562928bea4d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 00:06:17 +0000 Subject: [PATCH 563/702] build(deps): bump peter-evans/create-pull-request from 7.0.9 to 7.0.11 Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.9 to 7.0.11. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/84ae59a2cdc2258d6fa0732dd66352dddae2a412...22a9089034f40e5a961c8808d113e2c98fb63676) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-version: 7.0.11 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 b9ff89c35..ca65c2a21 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@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 with: title: Update iTerm2 colorschemes base: main From ed1d77d518f58ca97895ff789c69b54d3a3bf1b4 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sun, 7 Dec 2025 13:35:52 +0800 Subject: [PATCH 564/702] os: fix off-by-one error in ShellEscapeWriter I am truly not sure why the tests never caught this, but I just fell for the oldest trick in the book --- src/os/shell.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/os/shell.zig b/src/os/shell.zig index a6f23e843..7f3254d87 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -5,8 +5,6 @@ const Writer = std.Io.Writer; /// Writer that escapes characters that shells treat specially to reduce the /// risk of injection attacks or other such weirdness. Specifically excludes /// linefeeds so that they can be used to delineate lists of file paths. -/// -/// T should be a Zig type that follows the `std.Io.Writer` interface. pub const ShellEscapeWriter = struct { writer: Writer, child: *Writer, @@ -33,7 +31,7 @@ pub const ShellEscapeWriter = struct { var count: usize = 0; for (data[0 .. data.len - 1]) |chunk| try self.writeEscaped(chunk, &count); - for (0..splat) |_| try self.writeEscaped(data[data.len], &count); + for (0..splat) |_| try self.writeEscaped(data[data.len - 1], &count); return count; } From 6da2f0e3e76336ab0c761fccae267efc20301ac7 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 8 Dec 2025 12:50:04 +0800 Subject: [PATCH 565/702] os/shell: actually run tests --- src/os/main.zig | 1 + src/os/shell.zig | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/os/main.zig b/src/os/main.zig index 2d269e412..c105f6143 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -69,6 +69,7 @@ test { _ = i18n; _ = path; _ = uri; + _ = shell; if (comptime builtin.os.tag == .linux) { _ = kernel_info; diff --git a/src/os/shell.zig b/src/os/shell.zig index 7f3254d87..9fce3e385 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -65,7 +65,7 @@ pub const ShellEscapeWriter = struct { test "shell escape 1" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("abc"); try testing.expectEqualStrings("abc", writer.buffered()); } @@ -73,7 +73,7 @@ test "shell escape 1" { test "shell escape 2" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a c"); try testing.expectEqualStrings("a\\ c", writer.buffered()); } @@ -81,7 +81,7 @@ test "shell escape 2" { test "shell escape 3" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a?c"); try testing.expectEqualStrings("a\\?c", writer.buffered()); } @@ -89,7 +89,7 @@ test "shell escape 3" { test "shell escape 4" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a\\c"); try testing.expectEqualStrings("a\\\\c", writer.buffered()); } @@ -97,7 +97,7 @@ test "shell escape 4" { test "shell escape 5" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a|c"); try testing.expectEqualStrings("a\\|c", writer.buffered()); } @@ -105,7 +105,7 @@ test "shell escape 5" { test "shell escape 6" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a\"c"); try testing.expectEqualStrings("a\\\"c", writer.buffered()); } @@ -113,7 +113,7 @@ test "shell escape 6" { test "shell escape 7" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - var shell: ShellEscapeWriter = .{ .child_writer = &writer }; + var shell: ShellEscapeWriter = .init(&writer); try shell.writer.writeAll("a(1)"); try testing.expectEqualStrings("a\\(1\\)", writer.buffered()); } From 2ac9e03c52b713d4f8aecc6fab46f213c580dfc3 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 8 Dec 2025 13:10:05 +0800 Subject: [PATCH 566/702] quirks: remove type signature for inlineAssert Functions with different calling conventions are not compatible with each other Fixes all release builds + CI --- src/quirks.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quirks.zig b/src/quirks.zig index db2760141..ecef74600 100644 --- a/src/quirks.zig +++ b/src/quirks.zig @@ -42,7 +42,7 @@ pub fn disableDefaultFontFeatures(face: *const font.Face) bool { /// 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 const inlineAssert: fn (bool) void = switch (builtin.mode) { +pub const inlineAssert = switch (builtin.mode) { // In debug builds we just use std.debug.assert because this // fixes up stack traces. `inline` causes broken stack traces. This // is probably a Zig compiler bug but until it is fixed we have to From af3a11b54673a0bb8f3c1e6f2d076f69810cbf4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 11:09:52 -0800 Subject: [PATCH 567/702] terminal/tmux: output has format/comptimeFormat --- src/terminal/tmux/output.zig | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index dcfa89ac3..cff1a982d 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -36,6 +36,36 @@ pub fn parseFormatStruct( return result; } +pub fn comptimeFormat( + comptime vars: []const Variable, + comptime delimiter: u8, +) []const u8 { + comptime { + @setEvalBranchQuota(50000); + var counter: std.Io.Writer.Discarding = .init(&.{}); + try format(&counter.writer, vars, delimiter); + + var buf: [counter.count]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try format(&writer, vars, delimiter); + const final = buf; + return final[0..writer.end]; + } +} + +/// Format a set of variables into the proper format string for tmux +/// that we can handle with `parseFormatStruct`. +pub fn format( + writer: *std.Io.Writer, + vars: []const Variable, + delimiter: u8, +) std.Io.Writer.Error!void { + for (vars, 0..) |variable, i| { + if (i != 0) try writer.writeByte(delimiter); + try writer.print("#{{{t}}}", .{variable}); + } +} + /// Returns a struct type that contains fields for each of the given /// format variables. This can be used with `parseFormatStruct` to /// parse an output string into a format struct. @@ -203,3 +233,41 @@ test "parseFormatStruct with empty layout field" { try testing.expectEqual(1, result.session_id); try testing.expectEqualStrings("", result.window_layout); } + +fn testFormat( + comptime vars: []const Variable, + comptime delimiter: u8, + comptime expected: []const u8, +) !void { + const comptime_result = comptime comptimeFormat(vars, delimiter); + try testing.expectEqualStrings(expected, comptime_result); + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try format(&writer, vars, delimiter); + try testing.expectEqualStrings(expected, buf[0..writer.end]); +} + +test "format single variable" { + try testFormat(&.{.session_id}, ' ', "#{session_id}"); +} + +test "format multiple variables" { + try testFormat(&.{ .session_id, .window_id, .window_width, .window_height }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height}"); +} + +test "format with comma delimiter" { + try testFormat(&.{ .window_id, .window_layout }, ',', "#{window_id},#{window_layout}"); +} + +test "format with tab delimiter" { + try testFormat(&.{ .window_width, .window_height }, '\t', "#{window_width}\t#{window_height}"); +} + +test "format empty variables" { + try testFormat(&.{}, ' ', ""); +} + +test "format all variables" { + try testFormat(&.{ .session_id, .window_id, .window_width, .window_height, .window_layout }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height} #{window_layout}"); +} From 0d75a787471a2b1a26dc31d05c5f607d7cab1543 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 11:01:04 -0800 Subject: [PATCH 568/702] terminal/tmux: start viewer state machine --- src/terminal/tmux.zig | 1 + src/terminal/tmux/viewer.zig | 235 ++++++++++++++++++++++++++++++++++ src/termio/stream_handler.zig | 6 +- 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 src/terminal/tmux/viewer.zig diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 82ef5036b..c7cda1442 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -6,6 +6,7 @@ pub const output = @import("tmux/output.zig"); pub const ControlParser = control.Parser; pub const ControlNotification = control.Notification; pub const Layout = layout.Layout; +pub const Viewer = @import("tmux/viewer.zig").Viewer; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig new file mode 100644 index 000000000..7a84f9243 --- /dev/null +++ b/src/terminal/tmux/viewer.zig @@ -0,0 +1,235 @@ +const std = @import("std"); +const testing = std.testing; +const assert = @import("../../quirks.zig").inlineAssert; +const control = @import("control.zig"); +const output = @import("output.zig"); + +const log = std.log.scoped(.terminal_tmux_viewer); + +// NOTE: There is some fragility here that can possibly break if tmux +// changes their implementation. In particular, the order of notifications +// and assurances about what is sent when are based on reading the tmux +// source code as of Dec, 2025. These aren't documented as fixed. +// +// I've tried not to depend on anything that seems like it'd change +// in the future. For example, it seems reasonable that command output +// always comes before session attachment. But, I am noting this here +// in case something breaks in the future we can consider it. We should +// be able to easily unit test all variations seen in the real world. + +/// A viewer is a tmux control mode client that attempts to create +/// a remote view of a tmux session, including providing the ability to send +/// new input to the session. +/// +/// This is the primary use case for tmux control mode, but technically +/// tmux control mode clients can do anything a normal tmux client can do, +/// so the `control.zig` and other files in this folder are more general +/// purpose. +/// +/// This struct helps move through a state machine of connecting to a tmux +/// session, negotiating capabilities, listing window state, etc. +pub const Viewer = struct { + state: State = .startup_block, + + /// The current session ID we're attached to. The default value + /// is meaningless, because this has to be sent down during + /// the startup process. + session_id: usize = 0, + + pub const Action = union(enum) { + /// Tmux has closed the control mode connection, we should end + /// our viewer session in some way. + exit, + + /// Send a command to tmux, e.g. `list-windows`. The caller + /// should not worry about parsing this or reading what command + /// it is; just send it to tmux as-is. This will include the + /// trailing newline so you can send it directly. + command: []const u8, + }; + + /// Initial state + pub const init: Viewer = .{}; + + /// Send in the next tmux notification we got from the control mode + /// protocol. The return value is any action that needs to be taken + /// in reaction to this notification (could be none). + pub fn next(self: *Viewer, n: control.Notification) ?Action { + return switch (self.state) { + .startup_block => self.nextStartupBlock(n), + .startup_session => self.nextStartupSession(n), + .defunct => defunct: { + log.info("received notification in defunct state, ignoring", .{}); + break :defunct null; + }, + + // Once we're in the main states, there's a bunch of shared + // logic so we centralize it. + .list_windows => self.nextCommand(n), + }; + } + + fn nextStartupBlock(self: *Viewer, n: control.Notification) ?Action { + assert(self.state == .startup_block); + + switch (n) { + // This is only sent by the DCS parser when we first get + // DCS 1000p, it should never reach us here. + .enter => unreachable, + + // I don't think this is technically possible (reading the + // tmux source code), but if we see an exit we can semantically + // handle this without issue. + .exit => { + self.state = .defunct; + return .exit; + }, + + // Any begin and end (even error) is fine! Now we wait for + // session-changed to get the initial session ID. session-changed + // is guaranteed to come after the initial command output + // since if the initial command is `attach` tmux will run that, + // queue the notification, then do notificatins. + .block_end, .block_err => { + self.state = .startup_session; + return null; + }, + + // I don't like catch-all else branches but startup is such + // a special case of looking for very specific things that + // are unlikely to expand. + else => return null, + } + } + + fn nextStartupSession(self: *Viewer, n: control.Notification) ?Action { + assert(self.state == .startup_session); + + switch (n) { + .enter => unreachable, + + .exit => { + self.state = .defunct; + return .exit; + }, + + .session_changed => |info| { + self.session_id = info.id; + self.state = .list_windows; + return .{ .command = std.fmt.comptimePrint( + "list-windows -F '{s}'", + .{comptime Format.list_windows.comptimeFormat()}, + ) }; + }, + + else => return null, + } + } + + fn nextCommand(self: *Viewer, n: control.Notification) ?Action { + assert(self.state != .startup_block); + assert(self.state != .startup_session); + assert(self.state != .defunct); + + switch (n) { + .enter => unreachable, + + .exit => { + self.state = .defunct; + return .exit; + }, + + .block_end, + .block_err, + => |content| switch (self.state) { + .startup_block, .startup_session, .defunct => unreachable, + .list_windows => { + // TODO: parse the content + _ = content; + return null; + }, + }, + + // TODO: Use exhaustive matching here, determine if we need + // to handle the other cases. + else => return null, + } + } +}; + +const State = enum { + /// We start in this state just after receiving the initial + /// DCS 1000p opening sequence. We wait for an initial + /// begin/end block that is guaranteed to be sent by tmux for + /// the initial control mode command. (See tmux server-client.c + /// where control mode starts). + startup_block, + + /// After receiving the initial block, we wait for a session-changed + /// notification to record the initial session ID. + startup_session, + + /// Tmux has closed the control mode connection + defunct, + + /// We're waiting on a list-windows response from tmux. + list_windows, +}; + +/// Format strings used for commands in our viewer. +const Format = struct { + /// The variables included in this format, in order. + vars: []const output.Variable, + + /// The delimiter to use between variables. This must be a character + /// guaranteed to not appear in any of the variable outputs. + delim: u8, + + const list_windows: Format = .{ + .delim = ' ', + .vars = &.{ + .session_id, + .window_id, + .window_width, + .window_height, + .window_layout, + }, + }; + + /// The format string, available at comptime. + pub fn comptimeFormat(comptime self: Format) []const u8 { + return output.comptimeFormat(self.vars, self.delim); + } + + /// The struct that can contain the parsed output. + pub fn Struct(comptime self: Format) type { + return output.FormatStruct(self.vars); + } +}; + +test "immediate exit" { + var viewer: Viewer = .init; + try testing.expectEqual(.exit, viewer.next(.exit).?); + try testing.expect(viewer.next(.exit) == null); +} + +test "initial flow" { + var viewer: Viewer = .init; + + // First we receive the initial block end + try testing.expect(viewer.next(.{ .block_end = "" }) == null); + + // Then we receive session-changed with the initial session + { + const action = viewer.next(.{ .session_changed = .{ + .id = 42, + .name = "main", + } }).?; + try testing.expect(action == .command); + try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); + try testing.expectEqual(42, viewer.session_id); + } + + try testing.expectEqual(.exit, viewer.next(.exit).?); + try testing.expect(viewer.next(.exit) == null); +} diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 6e125e100..e25d635c9 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -368,7 +368,11 @@ pub const StreamHandler = struct { fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void { // log.warn("DCS command: {}", .{cmd}); switch (cmd.*) { - .tmux => |tmux| { + .tmux => |tmux| tmux: { + // If tmux control mode is disabled at the build level, + // then this whole block shouldn't be analyzed. + if (comptime !terminal.options.tmux_control_mode) break :tmux; + // TODO: process it log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); }, From 4c3ef8fa13d12d6b5bba8f9f3e78214187ca8e84 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 15:21:26 -0800 Subject: [PATCH 569/702] terminal/tmux: viewer list windows state --- src/terminal/tmux/viewer.zig | 134 ++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 26 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7a84f9243..60666b2aa 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const control = @import("control.zig"); @@ -29,12 +30,17 @@ const log = std.log.scoped(.terminal_tmux_viewer); /// This struct helps move through a state machine of connecting to a tmux /// session, negotiating capabilities, listing window state, etc. pub const Viewer = struct { - state: State = .startup_block, + /// Allocator used for all internal state. + alloc: Allocator, - /// The current session ID we're attached to. The default value - /// is meaningless, because this has to be sent down during - /// the startup process. - session_id: usize = 0, + /// Current state of the state machine. + state: State, + + /// The current session ID we're attached to. + session_id: usize, + + /// The windows in the current session. + windows: std.ArrayList(Window), pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end @@ -48,8 +54,32 @@ pub const Viewer = struct { command: []const u8, }; - /// Initial state - pub const init: Viewer = .{}; + pub const Window = struct { + id: usize, + width: usize, + height: usize, + // TODO: more fields, obviously! + }; + + /// Initialize a new viewer. + /// + /// The given allocator is used for all internal state. You must + /// call deinit when you're done with the viewer to free it. + pub fn init(alloc: Allocator) Viewer { + return .{ + .alloc = alloc, + .state = .startup_block, + // The default value here is meaningless. We don't get started + // until we receive a session-changed notification which will + // set this to a real value. + .session_id = 0, + .windows = .empty, + }; + } + + pub fn deinit(self: *Viewer) void { + self.windows.deinit(self.alloc); + } /// Send in the next tmux notification we got from the control mode /// protocol. The return value is any action that needs to be taken @@ -80,10 +110,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -108,10 +135,7 @@ pub const Viewer = struct { switch (n) { .enter => unreachable, - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), .session_changed => |info| { self.session_id = info.id; @@ -134,19 +158,17 @@ pub const Viewer = struct { switch (n) { .enter => unreachable, - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), - .block_end, + inline .block_end, .block_err, - => |content| switch (self.state) { + => |content, tag| switch (self.state) { .startup_block, .startup_session, .defunct => unreachable, + .list_windows => { - // TODO: parse the content - _ = content; - return null; + // Move to defunct on error blocks. + if (comptime tag == .block_err) return self.defunct(); + return self.receivedListWindows(content) catch self.defunct(); }, }, @@ -155,6 +177,53 @@ pub const Viewer = struct { else => return null, } } + + fn receivedListWindows( + self: *Viewer, + content: []const u8, + ) !Action { + assert(self.state == .list_windows); + + // This stores our new window state from this list-windows output. + var windows: std.ArrayList(Window) = .empty; + errdefer windows.deinit(self.alloc); + + // Parse all our windows + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + const data = output.parseFormatStruct( + Format.list_windows.Struct(), + line, + Format.list_windows.delim, + ) catch |err| { + log.info("failed to parse list-windows line: {s}", .{line}); + return err; + }; + + try windows.append(self.alloc, .{ + .id = data.window_id, + .width = data.window_width, + .height = data.window_height, + }); + } + + // TODO: Diff our prior windows + + // Replace our window list + self.windows.deinit(self.alloc); + self.windows = windows; + + return .exit; + } + + fn defunct(self: *Viewer) Action { + self.state = .defunct; + // In the future we may want to deallocate a bunch of memory + // when we go defunct. + return .exit; + } }; const State = enum { @@ -208,13 +277,15 @@ const Format = struct { }; test "immediate exit" { - var viewer: Viewer = .init; + var viewer = Viewer.init(testing.allocator); + defer viewer.deinit(); try testing.expectEqual(.exit, viewer.next(.exit).?); try testing.expect(viewer.next(.exit) == null); } test "initial flow" { - var viewer: Viewer = .init; + var viewer = Viewer.init(testing.allocator); + defer viewer.deinit(); // First we receive the initial block end try testing.expect(viewer.next(.{ .block_end = "" }) == null); @@ -228,6 +299,17 @@ test "initial flow" { try testing.expect(action == .command); try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); try testing.expectEqual(42, viewer.session_id); + // log.warn("{s}", .{action.command}); + } + + // Simulate our list-windows command + { + const action = viewer.next(.{ + .block_end = + \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + }).?; + _ = action; } try testing.expectEqual(.exit, viewer.next(.exit).?); From c1d686534efc3db38db6d6dd29be86939f652073 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 13:20:54 -0800 Subject: [PATCH 570/702] terminal/tmux: list windows --- src/terminal/tmux/viewer.zig | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 60666b2aa..7ee97fa8c 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -52,6 +52,13 @@ pub const Viewer = struct { /// it is; just send it to tmux as-is. This will include the /// trailing newline so you can send it directly. command: []const u8, + + /// Windows changed. This may add, remove or change windows. The + /// caller is responsible for diffing the new window list against + /// the prior one. Remember that for a given Viewer, window IDs + /// are guaranteed to be stable. Additionally, tmux (as of Dec 2025) + /// never re-uses window IDs within a server process lifetime. + windows: []const Window, }; pub const Window = struct { @@ -141,7 +148,7 @@ pub const Viewer = struct { self.session_id = info.id; self.state = .list_windows; return .{ .command = std.fmt.comptimePrint( - "list-windows -F '{s}'", + "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, ) }; }, @@ -209,13 +216,11 @@ pub const Viewer = struct { }); } - // TODO: Diff our prior windows - // Replace our window list self.windows.deinit(self.alloc); self.windows = windows; - return .exit; + return .{ .windows = self.windows.items }; } fn defunct(self: *Viewer) Action { @@ -309,7 +314,8 @@ test "initial flow" { \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , }).?; - _ = action; + try testing.expect(action == .windows); + try testing.expectEqual(1, action.windows.len); } try testing.expectEqual(.exit, viewer.next(.exit).?); From 3cbc232e31fd59f63a1eaea9df068c4d8df0153a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 07:15:53 -0800 Subject: [PATCH 571/702] terminal/tmux: return allocated list of actions --- src/terminal/tmux/viewer.zig | 197 +++++++++++++++++++++++++---------- 1 file changed, 142 insertions(+), 55 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7ee97fa8c..dc3fdbcfa 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -1,5 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const control = @import("control.zig"); @@ -42,6 +43,10 @@ pub const Viewer = struct { /// The windows in the current session. windows: std.ArrayList(Window), + /// The arena used for the prior action allocated state. This contains + /// the contents for the actions as well as the actions slice itself. + action_arena: ArenaAllocator.State, + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -61,6 +66,11 @@ pub const Viewer = struct { windows: []const Window, }; + pub const Input = union(enum) { + /// Data from tmux was received that needs to be processed. + tmux: control.Notification, + }; + pub const Window = struct { id: usize, width: usize, @@ -81,32 +91,49 @@ pub const Viewer = struct { // set this to a real value. .session_id = 0, .windows = .empty, + .action_arena = .{}, }; } pub fn deinit(self: *Viewer) void { self.windows.deinit(self.alloc); + self.action_arena.promote(self.alloc).deinit(); } - /// Send in the next tmux notification we got from the control mode - /// protocol. The return value is any action that needs to be taken - /// in reaction to this notification (could be none). - pub fn next(self: *Viewer, n: control.Notification) ?Action { - return switch (self.state) { - .startup_block => self.nextStartupBlock(n), - .startup_session => self.nextStartupSession(n), - .defunct => defunct: { - log.info("received notification in defunct state, ignoring", .{}); - break :defunct null; - }, - - // Once we're in the main states, there's a bunch of shared - // logic so we centralize it. - .list_windows => self.nextCommand(n), + /// Send in an input event (such as a tmux protocol notification, + /// keyboard input for a pane, etc.) and process it. The returned + /// list is a set of actions to take as a result of the input prior + /// to the next input. This list may be empty. + pub fn next(self: *Viewer, input: Input) Allocator.Error![]const Action { + return switch (input) { + .tmux => try self.nextTmux(input.tmux), }; } - fn nextStartupBlock(self: *Viewer, n: control.Notification) ?Action { + fn nextTmux( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + return switch (self.state) { + .defunct => defunct: { + log.info("received notification in defunct state, ignoring", .{}); + break :defunct &.{}; + }, + + .startup_block => try self.nextStartupBlock(n), + .startup_session => try self.nextStartupSession(n), + .idle => try self.nextIdle(n), + + // Once we're in the main states, there's a bunch of shared + // logic so we centralize it. + .list_windows => try self.nextCommand(n), + }; + } + + fn nextStartupBlock( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { assert(self.state == .startup_block); switch (n) { @@ -117,7 +144,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => return self.defunct(), + .exit => return try self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -126,69 +153,88 @@ pub const Viewer = struct { // queue the notification, then do notificatins. .block_end, .block_err => { self.state = .startup_session; - return null; + return &.{}; }, // I don't like catch-all else branches but startup is such // a special case of looking for very specific things that // are unlikely to expand. - else => return null, + else => return &.{}, } } - fn nextStartupSession(self: *Viewer, n: control.Notification) ?Action { + fn nextStartupSession( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { assert(self.state == .startup_session); switch (n) { .enter => unreachable, - .exit => return self.defunct(), + .exit => return try self.defunct(), .session_changed => |info| { self.session_id = info.id; self.state = .list_windows; - return .{ .command = std.fmt.comptimePrint( + return try self.singleAction(.{ .command = std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, - ) }; + ) }); }, - else => return null, + else => return &.{}, } } - fn nextCommand(self: *Viewer, n: control.Notification) ?Action { - assert(self.state != .startup_block); - assert(self.state != .startup_session); - assert(self.state != .defunct); + fn nextIdle( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + assert(self.state == .idle); switch (n) { .enter => unreachable, + .exit => return try self.defunct(), + else => return &.{}, + } + } - .exit => return self.defunct(), + fn nextCommand( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + switch (n) { + .enter => unreachable, + + .exit => return try self.defunct(), inline .block_end, .block_err, => |content, tag| switch (self.state) { - .startup_block, .startup_session, .defunct => unreachable, + .startup_block, + .startup_session, + .idle, + .defunct, + => unreachable, .list_windows => { // Move to defunct on error blocks. - if (comptime tag == .block_err) return self.defunct(); - return self.receivedListWindows(content) catch self.defunct(); + if (comptime tag == .block_err) return try self.defunct(); + return self.receivedListWindows(content) catch return try self.defunct(); }, }, // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. - else => return null, + else => return &.{}, } } fn receivedListWindows( self: *Viewer, content: []const u8, - ) !Action { + ) ![]const Action { assert(self.state == .list_windows); // This stores our new window state from this list-windows output. @@ -220,18 +266,46 @@ pub const Viewer = struct { self.windows.deinit(self.alloc); self.windows = windows; - return .{ .windows = self.windows.items }; + // Go into the idle state + self.state = .idle; + + // TODO: Diff with prior window state, dispatch capture-pane + // requests to collect all of the screen contents, other terminal + // state, etc. + + return try self.singleAction(.{ .windows = self.windows.items }); } - fn defunct(self: *Viewer) Action { + /// Helper to return a single action. The input action must not use + /// any allocated memory from `action_arena` since this will reset + /// the arena. + fn singleAction( + self: *Viewer, + action: Action, + ) Allocator.Error![]const Action { + // Make our actual arena + var arena = self.action_arena.promote(self.alloc); + + // Need to be careful to update our internal state after + // doing allocations since the arena takes a copy of the state. + defer self.action_arena = arena.state; + + // Free everything. We could retain some state here if we wanted + // but I don't think its worth it. + _ = arena.reset(.free_all); + + // Make our single action slice. + const alloc = arena.allocator(); + return try alloc.dupe(Action, &.{action}); + } + + fn defunct(self: *Viewer) Allocator.Error![]const Action { self.state = .defunct; - // In the future we may want to deallocate a bunch of memory - // when we go defunct. - return .exit; + return try self.singleAction(.exit); } }; -const State = enum { +const State = union(enum) { /// We start in this state just after receiving the initial /// DCS 1000p opening sequence. We wait for an initial /// begin/end block that is guaranteed to be sent by tmux for @@ -246,8 +320,13 @@ const State = enum { /// Tmux has closed the control mode connection defunct, - /// We're waiting on a list-windows response from tmux. + /// We're waiting on a list-windows response from tmux. This will + /// be used to resynchronize our entire window state. list_windows, + + /// Idle state, we're not actually doing anything right now except + /// waiting for more events from tmux that may change our behavior. + idle, }; /// Format strings used for commands in our viewer. @@ -284,8 +363,11 @@ const Format = struct { test "immediate exit" { var viewer = Viewer.init(testing.allocator); defer viewer.deinit(); - try testing.expectEqual(.exit, viewer.next(.exit).?); - try testing.expect(viewer.next(.exit) == null); + const actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(1, actions.len); + try testing.expectEqual(.exit, actions[0]); + const actions2 = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(0, actions2.len); } test "initial flow" { @@ -293,31 +375,36 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - try testing.expect(viewer.next(.{ .block_end = "" }) == null); + const actions0 = try viewer.next(.{ .tmux = .{ .block_end = "" } }); + try testing.expectEqual(0, actions0.len); // Then we receive session-changed with the initial session { - const action = viewer.next(.{ .session_changed = .{ + const actions = try viewer.next(.{ .tmux = .{ .session_changed = .{ .id = 42, .name = "main", - } }).?; - try testing.expect(action == .command); - try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); + } } }); + try testing.expectEqual(1, actions.len); + try testing.expect(actions[0] == .command); + try testing.expect(std.mem.startsWith(u8, actions[0].command, "list-windows")); try testing.expectEqual(42, viewer.session_id); - // log.warn("{s}", .{action.command}); } // Simulate our list-windows command { - const action = viewer.next(.{ + const actions = try viewer.next(.{ .tmux = .{ .block_end = \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , - }).?; - try testing.expect(action == .windows); - try testing.expectEqual(1, action.windows.len); + } }); + try testing.expectEqual(1, actions.len); + try testing.expect(actions[0] == .windows); + try testing.expectEqual(1, actions[0].windows.len); } - try testing.expectEqual(.exit, viewer.next(.exit).?); - try testing.expect(viewer.next(.exit) == null); + const exit_actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(1, exit_actions.len); + try testing.expectEqual(.exit, exit_actions[0]); + const final_actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(0, final_actions.len); } From 52dbca3d26426937be2e13e2f216177d4e8b467b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 14:10:54 -0800 Subject: [PATCH 572/702] termio: hook up tmux viewer --- src/termio/stream_handler.zig | 77 +++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index e25d635c9..be5cb6418 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -70,6 +70,9 @@ pub const StreamHandler = struct { /// such as XTGETTCAP. dcs: terminal.dcs.Handler = .{}, + /// The tmux control mode viewer state. + tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. @@ -81,9 +84,18 @@ pub const StreamHandler = struct { pub const Stream = terminal.Stream(StreamHandler); + /// True if we have tmux control mode built in. + pub const tmux_enabled = terminal.options.tmux_control_mode; + pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); + if (comptime tmux_enabled) tmux: { + const viewer = self.tmux_viewer orelse break :tmux; + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + } } /// This queues a render operation with the renderer thread. The render @@ -371,10 +383,69 @@ pub const StreamHandler = struct { .tmux => |tmux| tmux: { // If tmux control mode is disabled at the build level, // then this whole block shouldn't be analyzed. - if (comptime !terminal.options.tmux_control_mode) break :tmux; + if (comptime !tmux_enabled) break :tmux; + log.info("tmux control mode event cmd={}", .{tmux}); - // TODO: process it - log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); + switch (tmux) { + .enter => { + // Setup our viewer state + assert(self.tmux_viewer == null); + const viewer = try self.alloc.create(terminal.tmux.Viewer); + errdefer self.alloc.destroy(viewer); + viewer.* = .init(self.alloc); + self.tmux_viewer = viewer; + break :tmux; + }, + + .exit => if (self.tmux_viewer) |viewer| { + // Free our viewer state + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + break :tmux; + }, + + else => {}, + } + + assert(tmux != .enter); + assert(tmux != .exit); + + const viewer = self.tmux_viewer orelse { + // This can only really happen if we failed to + // initialize the viewer on enter. + log.info( + "received tmux control mode command without viewer: {}", + .{tmux}, + ); + + break :tmux; + }; + + for (try viewer.next(.{ .tmux = tmux })) |action| { + log.info("tmux viewer action={}", .{action}); + switch (action) { + .exit => { + // We ignore this because we will fully exit when + // our DCS connection ends. We may want to handle + // this in the future to notify our GUI we're + // disconnected though. + }, + + .command => |command| { + assert(command.len > 0); + assert(command[command.len - 1] == '\n'); + self.messageWriter(try termio.Message.writeReq( + self.alloc, + command, + )); + }, + + .windows => { + // TODO + }, + } + } }, .xtgettcap => |*gettcap| { From b26c42f4a64d9fdb686c3678d08b4ce31b3f7fd6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 14:28:00 -0800 Subject: [PATCH 573/702] terminal/tmux: better formatting for notifications and actions --- src/terminal/tmux/control.zig | 26 +++++++++++++++++++++++++- src/terminal/tmux/viewer.zig | 24 ++++++++++++++++++++++++ src/termio/stream_handler.zig | 6 +++--- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index 3624173dd..79ed530ec 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -531,7 +531,31 @@ pub const Notification = union(enum) { session_id: usize, name: []const u8, }, -}; + + pub fn format(self: Notification, writer: *std.Io.Writer) !void { + const T = Notification; + const info = @typeInfo(T).@"union"; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (u_field.type) { + []const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}), + else => try writer.print("{any}", .{value}), + } + } + } + + try writer.writeAll(" }"); + } + } + }; test "tmux begin/end empty" { const testing = std.testing; diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index dc3fdbcfa..32da1b4e4 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -64,6 +64,30 @@ pub const Viewer = struct { /// are guaranteed to be stable. Additionally, tmux (as of Dec 2025) /// never re-uses window IDs within a server process lifetime. windows: []const Window, + + pub fn format(self: Action, writer: *std.Io.Writer) !void { + const T = Action; + const info = @typeInfo(T).@"union"; + + try writer.writeAll(@typeName(T)); + if (info.tag_type) |TagType| { + try writer.writeAll("{ ."); + try writer.writeAll(@tagName(@as(TagType, self))); + try writer.writeAll(" = "); + + inline for (info.fields) |u_field| { + if (self == @field(TagType, u_field.name)) { + const value = @field(self, u_field.name); + switch (u_field.type) { + []const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}), + else => try writer.print("{any}", .{value}), + } + } + } + + try writer.writeAll(" }"); + } + } }; pub const Input = union(enum) { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index be5cb6418..8218315be 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -384,7 +384,7 @@ pub const StreamHandler = struct { // If tmux control mode is disabled at the build level, // then this whole block shouldn't be analyzed. if (comptime !tmux_enabled) break :tmux; - log.info("tmux control mode event cmd={}", .{tmux}); + log.info("tmux control mode event cmd={f}", .{tmux}); switch (tmux) { .enter => { @@ -415,7 +415,7 @@ pub const StreamHandler = struct { // This can only really happen if we failed to // initialize the viewer on enter. log.info( - "received tmux control mode command without viewer: {}", + "received tmux control mode command without viewer: {f}", .{tmux}, ); @@ -423,7 +423,7 @@ pub const StreamHandler = struct { }; for (try viewer.next(.{ .tmux = tmux })) |action| { - log.info("tmux viewer action={}", .{action}); + log.info("tmux viewer action={f}", .{action}); switch (action) { .exit => { // We ignore this because we will fully exit when From ec5a60a11993467f19ae99d5723304870f060cb8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 07:25:59 -0800 Subject: [PATCH 574/702] terminal/tmux: make sure we always have space for one action --- src/terminal/tmux/viewer.zig | 90 ++++++++++++++++------------------- src/termio/stream_handler.zig | 2 +- 2 files changed, 43 insertions(+), 49 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 32da1b4e4..275f93d5e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -47,6 +47,11 @@ pub const Viewer = struct { /// the contents for the actions as well as the actions slice itself. action_arena: ArenaAllocator.State, + /// A single action pre-allocated that we use for single-action + /// returns (common). This ensures that we can never get allocation + /// errors on single-action returns, especially those such as `.exit`. + action_single: [1]Action, + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -116,6 +121,7 @@ pub const Viewer = struct { .session_id = 0, .windows = .empty, .action_arena = .{}, + .action_single = undefined, }; } @@ -128,36 +134,39 @@ pub const Viewer = struct { /// keyboard input for a pane, etc.) and process it. The returned /// list is a set of actions to take as a result of the input prior /// to the next input. This list may be empty. - pub fn next(self: *Viewer, input: Input) Allocator.Error![]const Action { + pub fn next(self: *Viewer, input: Input) []const Action { + // Developer note: this function must never return an error. If + // an error occurs we must go into a defunct state or some other + // state to gracefully handle it. return switch (input) { - .tmux => try self.nextTmux(input.tmux), + .tmux => self.nextTmux(input.tmux), }; } fn nextTmux( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { return switch (self.state) { .defunct => defunct: { log.info("received notification in defunct state, ignoring", .{}); break :defunct &.{}; }, - .startup_block => try self.nextStartupBlock(n), - .startup_session => try self.nextStartupSession(n), - .idle => try self.nextIdle(n), + .startup_block => self.nextStartupBlock(n), + .startup_session => self.nextStartupSession(n), + .idle => self.nextIdle(n), // Once we're in the main states, there's a bunch of shared // logic so we centralize it. - .list_windows => try self.nextCommand(n), + .list_windows => self.nextCommand(n), }; } fn nextStartupBlock( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .startup_block); switch (n) { @@ -168,7 +177,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => return try self.defunct(), + .exit => return self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -190,18 +199,18 @@ pub const Viewer = struct { fn nextStartupSession( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .startup_session); switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), .session_changed => |info| { self.session_id = info.id; self.state = .list_windows; - return try self.singleAction(.{ .command = std.fmt.comptimePrint( + return self.singleAction(.{ .command = std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, ) }); @@ -214,12 +223,12 @@ pub const Viewer = struct { fn nextIdle( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .idle); switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), else => return &.{}, } } @@ -227,11 +236,11 @@ pub const Viewer = struct { fn nextCommand( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), inline .block_end, .block_err, @@ -244,8 +253,8 @@ pub const Viewer = struct { .list_windows => { // Move to defunct on error blocks. - if (comptime tag == .block_err) return try self.defunct(); - return self.receivedListWindows(content) catch return try self.defunct(); + if (comptime tag == .block_err) return self.defunct(); + return self.receivedListWindows(content) catch return self.defunct(); }, }, @@ -297,35 +306,20 @@ pub const Viewer = struct { // requests to collect all of the screen contents, other terminal // state, etc. - return try self.singleAction(.{ .windows = self.windows.items }); + return self.singleAction(.{ .windows = self.windows.items }); } - /// Helper to return a single action. The input action must not use - /// any allocated memory from `action_arena` since this will reset - /// the arena. - fn singleAction( - self: *Viewer, - action: Action, - ) Allocator.Error![]const Action { - // Make our actual arena - var arena = self.action_arena.promote(self.alloc); - - // Need to be careful to update our internal state after - // doing allocations since the arena takes a copy of the state. - defer self.action_arena = arena.state; - - // Free everything. We could retain some state here if we wanted - // but I don't think its worth it. - _ = arena.reset(.free_all); - + /// Helper to return a single action. The input action may use the arena + /// for allocated memory; this will not touch the arena. + fn singleAction(self: *Viewer, action: Action) []const Action { // Make our single action slice. - const alloc = arena.allocator(); - return try alloc.dupe(Action, &.{action}); + self.action_single[0] = action; + return &self.action_single; } - fn defunct(self: *Viewer) Allocator.Error![]const Action { + fn defunct(self: *Viewer) []const Action { self.state = .defunct; - return try self.singleAction(.exit); + return self.singleAction(.exit); } }; @@ -387,10 +381,10 @@ const Format = struct { test "immediate exit" { var viewer = Viewer.init(testing.allocator); defer viewer.deinit(); - const actions = try viewer.next(.{ .tmux = .exit }); + const actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, actions.len); try testing.expectEqual(.exit, actions[0]); - const actions2 = try viewer.next(.{ .tmux = .exit }); + const actions2 = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(0, actions2.len); } @@ -399,12 +393,12 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - const actions0 = try viewer.next(.{ .tmux = .{ .block_end = "" } }); + const actions0 = viewer.next(.{ .tmux = .{ .block_end = "" } }); try testing.expectEqual(0, actions0.len); // Then we receive session-changed with the initial session { - const actions = try viewer.next(.{ .tmux = .{ .session_changed = .{ + const actions = viewer.next(.{ .tmux = .{ .session_changed = .{ .id = 42, .name = "main", } } }); @@ -416,7 +410,7 @@ test "initial flow" { // Simulate our list-windows command { - const actions = try viewer.next(.{ .tmux = .{ + const actions = viewer.next(.{ .tmux = .{ .block_end = \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , @@ -426,9 +420,9 @@ test "initial flow" { try testing.expectEqual(1, actions[0].windows.len); } - const exit_actions = try viewer.next(.{ .tmux = .exit }); + const exit_actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, exit_actions.len); try testing.expectEqual(.exit, exit_actions[0]); - const final_actions = try viewer.next(.{ .tmux = .exit }); + const final_actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(0, final_actions.len); } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8218315be..ba207ce7b 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -422,7 +422,7 @@ pub const StreamHandler = struct { break :tmux; }; - for (try viewer.next(.{ .tmux = tmux })) |action| { + for (viewer.next(.{ .tmux = tmux })) |action| { log.info("tmux viewer action={f}", .{action}); switch (action) { .exit => { From 86cd4897012758a59d8068b796b449ff7ff37f16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 07:09:11 -0800 Subject: [PATCH 575/702] terminal/tmux: introduce command queue for viewer --- src/terminal/tmux/viewer.zig | 196 ++++++++++++++++++++++++++-------- src/termio/stream_handler.zig | 3 +- 2 files changed, 156 insertions(+), 43 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 275f93d5e..384ad609b 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; +const CircBuf = @import("../../datastruct/main.zig").CircBuf; const control = @import("control.zig"); const output = @import("output.zig"); @@ -19,6 +20,12 @@ const log = std.log.scoped(.terminal_tmux_viewer); // in case something breaks in the future we can consider it. We should // be able to easily unit test all variations seen in the real world. +/// The initial capacity of the command queue. We dynamically resize +/// as necessary so the initial value isn't that important, but if we +/// want to feel good about it we should make it large enough to support +/// our most realistic use cases without resizing. +const COMMAND_QUEUE_INITIAL = 8; + /// A viewer is a tmux control mode client that attempts to create /// a remote view of a tmux session, including providing the ability to send /// new input to the session. @@ -40,6 +47,11 @@ pub const Viewer = struct { /// The current session ID we're attached to. session_id: usize, + /// The list of commands we've sent that we want to send and wait + /// for a response for. We only send one command at a time just + /// to avoid any possible confusion around ordering. + command_queue: CommandQueue, + /// The windows in the current session. windows: std.ArrayList(Window), @@ -52,6 +64,8 @@ pub const Viewer = struct { /// errors on single-action returns, especially those such as `.exit`. action_single: [1]Action, + pub const CommandQueue = CircBuf(Command, undefined); + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -111,7 +125,11 @@ pub const Viewer = struct { /// /// The given allocator is used for all internal state. You must /// call deinit when you're done with the viewer to free it. - pub fn init(alloc: Allocator) Viewer { + pub fn init(alloc: Allocator) Allocator.Error!Viewer { + // Create our initial command queue + var command_queue: CommandQueue = try .init(alloc, COMMAND_QUEUE_INITIAL); + errdefer command_queue.deinit(alloc); + return .{ .alloc = alloc, .state = .startup_block, @@ -119,6 +137,7 @@ pub const Viewer = struct { // until we receive a session-changed notification which will // set this to a real value. .session_id = 0, + .command_queue = command_queue, .windows = .empty, .action_arena = .{}, .action_single = undefined, @@ -127,6 +146,11 @@ pub const Viewer = struct { pub fn deinit(self: *Viewer) void { self.windows.deinit(self.alloc); + { + var it = self.command_queue.iterator(.forward); + while (it.next()) |command| command.deinit(self.alloc); + self.command_queue.deinit(self.alloc); + } self.action_arena.promote(self.alloc).deinit(); } @@ -155,11 +179,7 @@ pub const Viewer = struct { .startup_block => self.nextStartupBlock(n), .startup_session => self.nextStartupSession(n), - .idle => self.nextIdle(n), - - // Once we're in the main states, there's a bunch of shared - // logic so we centralize it. - .list_windows => self.nextCommand(n), + .command_queue => self.nextCommand(n), }; } @@ -209,11 +229,11 @@ pub const Viewer = struct { .session_changed => |info| { self.session_id = info.id; - self.state = .list_windows; - return self.singleAction(.{ .command = std.fmt.comptimePrint( - "list-windows -F '{s}'\n", - .{comptime Format.list_windows.comptimeFormat()}, - ) }); + self.state = .command_queue; + return self.singleAction(self.queueCommand(.list_windows) catch { + log.warn("failed to queue command, becoming defunct", .{}); + return self.defunct(); + }); }, else => return &.{}, @@ -237,39 +257,85 @@ pub const Viewer = struct { self: *Viewer, n: control.Notification, ) []const Action { - switch (n) { - .enter => unreachable, + // We have to be in a command queue, but the command queue MAY + // be empty. If it is empty, then receivedCommandOutput will + // handle it by ignoring any command output. That's okay! + assert(self.state == .command_queue); - .exit => return self.defunct(), + return switch (n) { + .enter => unreachable, + .exit => self.defunct(), inline .block_end, .block_err, - => |content, tag| switch (self.state) { - .startup_block, - .startup_session, - .idle, - .defunct, - => unreachable, - - .list_windows => { - // Move to defunct on error blocks. - if (comptime tag == .block_err) return self.defunct(); - return self.receivedListWindows(content) catch return self.defunct(); - }, + => |content, tag| self.receivedCommandOutput( + content, + tag == .block_err, + ) catch err: { + log.warn("failed to process command output, becoming defunct", .{}); + break :err self.defunct(); }, // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. - else => return &.{}, + else => &.{}, + }; + } + + fn receivedCommandOutput( + self: *Viewer, + content: []const u8, + is_err: bool, + ) ![]const Action { + // If we have no pending commands, this is unexpected. + const command = self.command_queue.first() orelse { + log.info("unexpected block output err={}", .{is_err}); + return &.{}; + }; + self.command_queue.deleteOldest(1); + + // We always free any memory associated with the command + defer command.deinit(self.alloc); + + // We'll use our arena for the return value here so we can + // easily accumulate actions. + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + // Build up our actions to start with the next command if + // we have one. + var actions: std.ArrayList(Action) = .empty; + if (self.command_queue.first()) |next_command| { + try actions.append( + arena_alloc, + .{ .command = next_command.string() }, + ); } + + // Process our command + switch (command.*) { + .user => {}, + .list_windows => try self.receivedListWindows( + arena_alloc, + &actions, + content, + ), + } + + // Our command processing should not change our state + assert(self.state == .command_queue); + + return actions.items; } fn receivedListWindows( self: *Viewer, + arena_alloc: Allocator, + actions: *std.ArrayList(Action), content: []const u8, - ) ![]const Action { - assert(self.state == .list_windows); - + ) !void { // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; errdefer windows.deinit(self.alloc); @@ -299,14 +365,27 @@ pub const Viewer = struct { self.windows.deinit(self.alloc); self.windows = windows; - // Go into the idle state - self.state = .idle; - // TODO: Diff with prior window state, dispatch capture-pane // requests to collect all of the screen contents, other terminal // state, etc. - return self.singleAction(.{ .windows = self.windows.items }); + try actions.append(arena_alloc, .{ .windows = self.windows.items }); + } + + /// This queues the command at the end of the command queue + /// and returns an action representing the next command that + /// should be run (the head). + /// + /// The next command is not removed, because the expectation is + /// that the head of our command list is always sent to tmux. + fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { + // Add our command + try self.command_queue.ensureUnusedCapacity(self.alloc, 1); + self.command_queue.appendAssumeCapacity(command); + + // Get our first command to send, guaranteed to exist since we + // just appended one. + return .{ .command = self.command_queue.first().?.string() }; } /// Helper to return a single action. The input action may use the arena @@ -323,7 +402,7 @@ pub const Viewer = struct { } }; -const State = union(enum) { +const State = enum { /// We start in this state just after receiving the initial /// DCS 1000p opening sequence. We wait for an initial /// begin/end block that is guaranteed to be sent by tmux for @@ -338,13 +417,46 @@ const State = union(enum) { /// Tmux has closed the control mode connection defunct, - /// We're waiting on a list-windows response from tmux. This will - /// be used to resynchronize our entire window state. + /// We're sitting on the command queue waiting for command output + /// in the order provided in the `command_queue` field. This field + /// isn't part of the state because it can be queued at any state. + /// + /// Precondition: if self.command_queue.len > 0, then the first + /// command in the queue has already been sent to tmux (via a + /// `command` Action). The next output is assumed to be the result + /// of this command. + /// + /// To satisfy the above, any transitions INTO this state should + /// send a command Action for the first command in the queue. + command_queue, +}; + +const Command = union(enum) { + /// List all windows so we can sync our window state. list_windows, - /// Idle state, we're not actually doing anything right now except - /// waiting for more events from tmux that may change our behavior. - idle, + /// User command. This is a command provided by the user. Since + /// this is user provided, we can't be sure what it is. + user: []const u8, + + pub fn deinit(self: Command, alloc: Allocator) void { + return switch (self) { + .list_windows => {}, + .user => |v| alloc.free(v), + }; + } + + /// Returns the command to execute. The memory of the return + /// value is always safe as long as this command value is alive. + pub fn string(self: Command) []const u8 { + return switch (self) { + .list_windows => std.fmt.comptimePrint( + "list-windows -F '{s}'\n", + .{comptime Format.list_windows.comptimeFormat()}, + ), + .user => |v| v, + }; + } }; /// Format strings used for commands in our viewer. @@ -379,7 +491,7 @@ const Format = struct { }; test "immediate exit" { - var viewer = Viewer.init(testing.allocator); + var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); const actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, actions.len); @@ -389,7 +501,7 @@ test "immediate exit" { } test "initial flow" { - var viewer = Viewer.init(testing.allocator); + var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); // First we receive the initial block end diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ba207ce7b..eabfd6a4b 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -392,7 +392,8 @@ pub const StreamHandler = struct { assert(self.tmux_viewer == null); const viewer = try self.alloc.create(terminal.tmux.Viewer); errdefer self.alloc.destroy(viewer); - viewer.* = .init(self.alloc); + viewer.* = try .init(self.alloc); + errdefer viewer.deinit(); self.tmux_viewer = viewer; break :tmux; }, From ea09d257a1cd27b66236de474a2e26b05e843631 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 10:45:28 -0800 Subject: [PATCH 576/702] terminal/tmux: initialize panes --- src/terminal/tmux/viewer.zig | 143 ++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 384ad609b..7b0307a8f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -4,6 +4,8 @@ const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const Terminal = @import("../Terminal.zig"); +const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); const output = @import("output.zig"); @@ -55,6 +57,9 @@ pub const Viewer = struct { /// The windows in the current session. windows: std.ArrayList(Window), + /// The panes in the current session, mapped by pane ID. + panes: PanesMap, + /// The arena used for the prior action allocated state. This contains /// the contents for the actions as well as the actions slice itself. action_arena: ArenaAllocator.State, @@ -65,6 +70,7 @@ pub const Viewer = struct { action_single: [1]Action, pub const CommandQueue = CircBuf(Command, undefined); + pub const PanesMap = std.AutoArrayHashMapUnmanaged(usize, Pane); pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end @@ -118,7 +124,20 @@ pub const Viewer = struct { id: usize, width: usize, height: usize, - // TODO: more fields, obviously! + layout_arena: ArenaAllocator.State, + layout: Layout, + + pub fn deinit(self: *Window, alloc: Allocator) void { + self.layout_arena.promote(alloc).deinit(); + } + }; + + pub const Pane = struct { + terminal: Terminal, + + pub fn deinit(self: *Pane, alloc: Allocator) void { + self.terminal.deinit(alloc); + } }; /// Initialize a new viewer. @@ -139,18 +158,27 @@ pub const Viewer = struct { .session_id = 0, .command_queue = command_queue, .windows = .empty, + .panes = .empty, .action_arena = .{}, .action_single = undefined, }; } pub fn deinit(self: *Viewer) void { - self.windows.deinit(self.alloc); + { + for (self.windows.items) |*window| window.deinit(self.alloc); + self.windows.deinit(self.alloc); + } { var it = self.command_queue.iterator(.forward); while (it.next()) |command| command.deinit(self.alloc); self.command_queue.deinit(self.alloc); } + { + var it = self.panes.iterator(); + while (it.next()) |kv| kv.value_ptr.deinit(self.alloc); + self.panes.deinit(self.alloc); + } self.action_arena.promote(self.alloc).deinit(); } @@ -354,22 +382,131 @@ pub const Viewer = struct { return err; }; + // Parse the layout + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const window_alloc = arena.allocator(); + const layout: Layout = Layout.parseWithChecksum( + window_alloc, + data.window_layout, + ) catch |err| { + log.info( + "failed to parse window layout id={} layout={s}", + .{ data.window_id, data.window_layout }, + ); + return err; + }; + try windows.append(self.alloc, .{ .id = data.window_id, .width = data.window_width, .height = data.window_height, + .layout_arena = arena.state, + .layout = layout, }); } + // Setup our windows action so the caller can process GUI + // window changes. + try actions.append(arena_alloc, .{ .windows = windows.items }); + + // Go through the window layout and setup all our panes. We move + // this into a new panes map so that we can easily prune our old + // list. + var panes: PanesMap = .empty; + errdefer { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + panes.deinit(self.alloc); + } + for (windows.items) |window| try initLayout( + self.alloc, + &self.panes, + &panes, + arena_alloc, + actions, + window.layout, + ); + + // No more errors after this point. We're about to replace all + // our owned state with our temporary state, and our errdefers + // above will double-free if there is an error. + errdefer comptime unreachable; + // Replace our window list + for (self.windows.items) |*window| window.deinit(self.alloc); self.windows.deinit(self.alloc); self.windows = windows; + // Replace our panes + { + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + self.panes.deinit(self.alloc); + self.panes = panes; + } + // TODO: Diff with prior window state, dispatch capture-pane // requests to collect all of the screen contents, other terminal // state, etc. + } - try actions.append(arena_alloc, .{ .windows = self.windows.items }); + fn initLayout( + gpa_alloc: Allocator, + panes_old: *PanesMap, + panes_new: *PanesMap, + actions_alloc: Allocator, + actions: *std.ArrayList(Action), + layout: Layout, + ) !void { + switch (layout.content) { + // Nested layouts, continue going. + .horizontal, .vertical => |layouts| { + for (layouts) |l| { + try initLayout( + gpa_alloc, + panes_old, + panes_new, + actions_alloc, + actions, + l, + ); + } + }, + + // A leaf! Initialize. + .pane => |id| pane: { + const gop = try panes_new.getOrPut(gpa_alloc, id); + if (gop.found_existing) { + // We already have the pane setup. It should not exist + // in the old map because we remove that when we set + // it up. + assert(!panes_old.contains(id)); + break :pane; + } + errdefer _ = panes_new.swapRemove(gop.key_ptr.*); + + // We don't have it in our new map. If it exists in our old + // map then we copy it over and we're done. + if (panes_old.fetchSwapRemove(id)) |entry| { + gop.value_ptr.* = entry.value; + break :pane; + } + + // TODO: We need to gracefully handle overflow of our + // max cols/width here. In practice we shouldn't hit this + // so we cast but its not safe. + var t: Terminal = try .init(gpa_alloc, .{ + .cols = @intCast(layout.width), + .rows = @intCast(layout.height), + }); + errdefer t.deinit(gpa_alloc); + + gop.value_ptr.* = .{ + .terminal = t, + }; + }, + } } /// This queues the command at the end of the command queue From 766c306e0437a2f301c11cd9d8e7c1dcf969383b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 19:45:46 -0800 Subject: [PATCH 577/702] terminal/tmux: pane history --- src/terminal/tmux/viewer.zig | 79 +++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7b0307a8f..1c5007625 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -315,14 +315,27 @@ pub const Viewer = struct { content: []const u8, is_err: bool, ) ![]const Action { - // If we have no pending commands, this is unexpected. - const command = self.command_queue.first() orelse { + // Get the command we're expecting output for. We need to get the + // non-pointer value because we are deleting it from the circular + // buffer immediately. This shallow copy is all we need since + // all the memory in Command is owned by GPA. + const command: Command = if (self.command_queue.first()) |ptr| switch (ptr.*) { + // I truly can't explain this. A simple `ptr.*` copy will cause + // our memory to become undefined when deleteOldest is called + // below. I logged all the pointers and they don't match so I + // don't know how its being set to undefined. But a copy like + // this does work. + inline else => |v, tag| @unionInit( + Command, + @tagName(tag), + v, + ), + } else { + // If we have no pending commands, this is unexpected. log.info("unexpected block output err={}", .{is_err}); return &.{}; }; self.command_queue.deleteOldest(1); - - // We always free any memory associated with the command defer command.deinit(self.alloc); // We'll use our arena for the return value here so we can @@ -336,20 +349,25 @@ pub const Viewer = struct { // we have one. var actions: std.ArrayList(Action) = .empty; if (self.command_queue.first()) |next_command| { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + try next_command.formatCommand(&builder.writer); try actions.append( arena_alloc, - .{ .command = next_command.string() }, + .{ .command = builder.writer.buffered() }, ); } // Process our command - switch (command.*) { + switch (command) { .user => {}, .list_windows => try self.receivedListWindows( arena_alloc, &actions, content, ), + .pane_history => { + // TODO + }, } // Our command processing should not change our state @@ -515,6 +533,10 @@ pub const Viewer = struct { /// /// The next command is not removed, because the expectation is /// that the head of our command list is always sent to tmux. + /// + /// Note: this modifies the `action_arena` since this will put + /// the command string into the arena. It does not clear the arena + /// so any previously allocated values remain valid. fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { // Add our command try self.command_queue.ensureUnusedCapacity(self.alloc, 1); @@ -522,7 +544,13 @@ pub const Viewer = struct { // Get our first command to send, guaranteed to exist since we // just appended one. - return .{ .command = self.command_queue.first().?.string() }; + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const next_command = self.command_queue.first().?; + next_command.formatCommand(&builder.writer) catch return error.OutOfMemory; + return .{ .command = builder.writer.buffered() }; } /// Helper to return a single action. The input action may use the arena @@ -572,27 +600,48 @@ const Command = union(enum) { /// List all windows so we can sync our window state. list_windows, + /// Capture history for the given pane ID. + pane_history: usize, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, pub fn deinit(self: Command, alloc: Allocator) void { return switch (self) { - .list_windows => {}, + .list_windows, + .pane_history, + => {}, .user => |v| alloc.free(v), }; } - /// Returns the command to execute. The memory of the return - /// value is always safe as long as this command value is alive. - pub fn string(self: Command) []const u8 { - return switch (self) { - .list_windows => std.fmt.comptimePrint( + /// Format the command into the command that should be executed + /// by tmux. Trailing newlines are appended so this can be sent as-is + /// to tmux. + pub fn formatCommand( + self: Command, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + switch (self) { + .list_windows => try writer.writeAll(std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, + )), + + .pane_history => |id| try writer.print( + // -p = output to stdout instead of buffer + // -e = output escape sequences for SGR + // -S - = start at the top of history ("-") + // -E -1 = end at the last line of history (1 before the + // visible area is -1). + // -t %{d} = target a specific pane ID + "capture-pane -p -e -S - -E -1 -t %{d}", + .{id}, ), - .user => |v| v, - }; + + .user => |v| try writer.writeAll(v), + } } }; From f02a2d5eed7cf59f2eed24cd5b16f225129d9d32 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 07:29:59 -0800 Subject: [PATCH 578/702] terminal/tmux: capture pane --- src/terminal/tmux/viewer.zig | 164 +++++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 54 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 1c5007625..82aed6c2a 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -257,11 +257,17 @@ pub const Viewer = struct { .session_changed => |info| { self.session_id = info.id; - self.state = .command_queue; - return self.singleAction(self.queueCommand(.list_windows) catch { + + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + return self.enterCommandQueue( + arena.allocator(), + .list_windows, + ) catch { log.warn("failed to queue command, becoming defunct", .{}); return self.defunct(); - }); + }; }, else => return &.{}, @@ -348,14 +354,6 @@ pub const Viewer = struct { // Build up our actions to start with the next command if // we have one. var actions: std.ArrayList(Action) = .empty; - if (self.command_queue.first()) |next_command| { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - try next_command.formatCommand(&builder.writer); - try actions.append( - arena_alloc, - .{ .command = builder.writer.buffered() }, - ); - } // Process our command switch (command) { @@ -370,6 +368,18 @@ pub const Viewer = struct { }, } + // After processing commands, we add our next command to + // execute if we have one. We do this last because command + // processing may itself queue more commands. + if (self.command_queue.first()) |next_command| { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + try next_command.formatCommand(&builder.writer); + try actions.append( + arena_alloc, + .{ .command = builder.writer.buffered() }, + ); + } + // Our command processing should not change our state assert(self.state == .command_queue); @@ -382,6 +392,9 @@ pub const Viewer = struct { actions: *std.ArrayList(Action), content: []const u8, ) !void { + // If there is an error, reset our actions to what it was before. + errdefer actions.shrinkRetainingCapacity(actions.items.len); + // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; errdefer windows.deinit(self.alloc); @@ -433,19 +446,50 @@ pub const Viewer = struct { // list. var panes: PanesMap = .empty; errdefer { + // Clear out all the new panes. var panes_it = panes.iterator(); - while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + while (panes_it.next()) |kv| { + if (!self.panes.contains(kv.key_ptr.*)) { + kv.value_ptr.deinit(self.alloc); + } + } panes.deinit(self.alloc); } for (windows.items) |window| try initLayout( self.alloc, &self.panes, &panes, - arena_alloc, - actions, window.layout, ); + // Build up the list of removed panes. + var removed: std.ArrayList(usize) = removed: { + var removed: std.ArrayList(usize) = .empty; + errdefer removed.deinit(self.alloc); + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| { + if (panes.contains(kv.key_ptr.*)) continue; + try removed.append(self.alloc, kv.key_ptr.*); + } + + break :removed removed; + }; + defer removed.deinit(self.alloc); + + // Get our list of added panes and setup our command queue + // to populate them. + // TODO: errdefer cleanup + { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + const pane_id: usize = kv.key_ptr.*; + if (self.panes.contains(pane_id)) continue; + try self.queueCommands(&.{ + .{ .pane_history = pane_id }, + }); + } + } + // No more errors after this point. We're about to replace all // our owned state with our temporary state, and our errdefers // above will double-free if there is an error. @@ -458,8 +502,15 @@ pub const Viewer = struct { // Replace our panes { - var panes_it = self.panes.iterator(); - while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + // First remove our old panes + for (removed.items) |id| if (self.panes.fetchSwapRemove( + id, + )) |entry_const| { + var entry = entry_const; + entry.value.deinit(self.alloc); + }; + // We can now deinit self.panes because the existing + // entries are preserved. self.panes.deinit(self.alloc); self.panes = panes; } @@ -471,10 +522,8 @@ pub const Viewer = struct { fn initLayout( gpa_alloc: Allocator, - panes_old: *PanesMap, + panes_old: *const PanesMap, panes_new: *PanesMap, - actions_alloc: Allocator, - actions: *std.ArrayList(Action), layout: Layout, ) !void { switch (layout.content) { @@ -485,8 +534,6 @@ pub const Viewer = struct { gpa_alloc, panes_old, panes_new, - actions_alloc, - actions, l, ); } @@ -495,19 +542,13 @@ pub const Viewer = struct { // A leaf! Initialize. .pane => |id| pane: { const gop = try panes_new.getOrPut(gpa_alloc, id); - if (gop.found_existing) { - // We already have the pane setup. It should not exist - // in the old map because we remove that when we set - // it up. - assert(!panes_old.contains(id)); - break :pane; - } + if (gop.found_existing) break :pane; errdefer _ = panes_new.swapRemove(gop.key_ptr.*); - // We don't have it in our new map. If it exists in our old - // map then we copy it over and we're done. - if (panes_old.fetchSwapRemove(id)) |entry| { - gop.value_ptr.* = entry.value; + // If we already have this pane, it is already initialized + // so just copy it over. + if (panes_old.getEntry(id)) |entry| { + gop.value_ptr.* = entry.value_ptr.*; break :pane; } @@ -527,30 +568,45 @@ pub const Viewer = struct { } } - /// This queues the command at the end of the command queue - /// and returns an action representing the next command that - /// should be run (the head). - /// - /// The next command is not removed, because the expectation is - /// that the head of our command list is always sent to tmux. - /// - /// Note: this modifies the `action_arena` since this will put - /// the command string into the arena. It does not clear the arena - /// so any previously allocated values remain valid. - fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { + /// Enters the command queue state from any other state, queueing + /// the command and returning an action to execute the first command. + fn enterCommandQueue( + self: *Viewer, + arena_alloc: Allocator, + command: Command, + ) Allocator.Error![]const Action { + assert(self.state != .command_queue); + + // Build our command string to send for the action. + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + // Add our command try self.command_queue.ensureUnusedCapacity(self.alloc, 1); self.command_queue.appendAssumeCapacity(command); - // Get our first command to send, guaranteed to exist since we - // just appended one. - var arena = self.action_arena.promote(self.alloc); - defer self.action_arena = arena.state; - const arena_alloc = arena.allocator(); - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const next_command = self.command_queue.first().?; - next_command.formatCommand(&builder.writer) catch return error.OutOfMemory; - return .{ .command = builder.writer.buffered() }; + // Move into the command queue state + self.state = .command_queue; + + return self.singleAction(action); + } + + /// Queue multiple commands to execute. This doesn't add anything + /// to the actions queue or return actions or anything because the + /// command_queue state will automatically send the next command when + /// it receives output. + fn queueCommands( + self: *Viewer, + commands: []const Command, + ) Allocator.Error!void { + try self.command_queue.ensureUnusedCapacity( + self.alloc, + commands.len, + ); + for (commands) |command| { + self.command_queue.appendAssumeCapacity(command); + } } /// Helper to return a single action. The input action may use the arena @@ -636,7 +692,7 @@ const Command = union(enum) { // -E -1 = end at the last line of history (1 before the // visible area is -1). // -t %{d} = target a specific pane ID - "capture-pane -p -e -S - -E -1 -t %{d}", + "capture-pane -p -e -S - -E -1 -t %{d}\n", .{id}, ), @@ -713,7 +769,7 @@ test "initial flow" { \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , } }); - try testing.expectEqual(1, actions.len); + try testing.expect(actions.len > 0); try testing.expect(actions[0] == .windows); try testing.expectEqual(1, actions[0].windows.len); } From e1e2791fb72d27c0383140dcc2bf2a10021bec45 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 09:48:17 -0800 Subject: [PATCH 579/702] terminal/tmux: pane_history replays it into terminal --- src/terminal/tmux/viewer.zig | 40 ++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 82aed6c2a..e9d318e7f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -358,14 +358,17 @@ pub const Viewer = struct { // Process our command switch (command) { .user => {}, + .list_windows => try self.receivedListWindows( arena_alloc, &actions, content, ), - .pane_history => { - // TODO - }, + + .pane_history => |id| try self.receivedPaneHistory( + id, + content, + ), } // After processing commands, we add our next command to @@ -514,10 +517,29 @@ pub const Viewer = struct { self.panes.deinit(self.alloc); self.panes = panes; } + } - // TODO: Diff with prior window state, dispatch capture-pane - // requests to collect all of the screen contents, other terminal - // state, etc. + fn receivedPaneHistory( + self: *Viewer, + id: usize, + content: []const u8, + ) !void { + // Get our pane + const entry = self.panes.getEntry(id) orelse { + log.info("received pane history for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + + // Get a VT stream from the terminal so we can send data as-is into + // it. This will populate the active area too so it won't be exactly + // correct but we'll get the active contents soon. + var stream = pane.terminal.vtStream(); + defer stream.deinit(); + stream.nextSlice(content) catch |err| { + log.info("failed to process pane history for pane id={}: {}", .{ id, err }); + return err; + }; } fn initLayout( @@ -747,8 +769,10 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - const actions0 = viewer.next(.{ .tmux = .{ .block_end = "" } }); - try testing.expectEqual(0, actions0.len); + { + const actions = viewer.next(.{ .tmux = .{ .block_end = "" } }); + try testing.expectEqual(0, actions.len); + } // Then we receive session-changed with the initial session { From 41bf54100524858f59a9cc2e63a3e86eafd8fa1b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 10:17:03 -0800 Subject: [PATCH 580/702] terminal/tmux: test helper --- src/terminal/tmux/viewer.zig | 178 +++++++++++++++++++++++++++-------- 1 file changed, 138 insertions(+), 40 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index e9d318e7f..8d3194748 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -754,53 +754,151 @@ const Format = struct { } }; +const TestStep = struct { + input: Viewer.Input, + contains_tags: []const std.meta.Tag(Viewer.Action) = &.{}, + contains_command: []const u8 = "", + check: ?*const fn (viewer: *Viewer, []const Viewer.Action) anyerror!void = null, + check_command: ?*const fn (viewer: *Viewer, []const u8) anyerror!void = null, + + fn run(self: TestStep, viewer: *Viewer) !void { + const actions = viewer.next(self.input); + + // Common mistake, forgetting the newline on a command. + for (actions) |action| { + if (action == .command) { + try testing.expect(std.mem.endsWith(u8, action.command, "\n")); + } + } + + for (self.contains_tags) |tag| { + var found = false; + for (actions) |action| { + if (action == tag) { + found = true; + break; + } + } + try testing.expect(found); + } + + if (self.contains_command.len > 0) { + var found = false; + for (actions) |action| { + if (action == .command and + std.mem.startsWith(u8, action.command, self.contains_command)) + { + found = true; + break; + } + } + try testing.expect(found); + } + + if (self.check) |check_fn| { + try check_fn(viewer, actions); + } + + if (self.check_command) |check_fn| { + var found = false; + for (actions) |action| { + if (action == .command) { + found = true; + try check_fn(viewer, action.command); + } + } + try testing.expect(found); + } + } +}; + +/// A helper to run a series of test steps against a viewer and assert +/// that the expected actions are produced. +/// +/// I'm generally not a fan of these types of abstracted tests because +/// it makes diagnosing failures harder, but being able to construct +/// simulated tmux inputs and verify outputs is going to be extremely +/// important since the tmux control mode protocol is very complex and +/// fragile. +fn testViewer(viewer: *Viewer, steps: []const TestStep) !void { + for (steps, 0..) |step, i| { + step.run(viewer) catch |err| { + log.warn("testViewer step failed i={} step={}", .{ i, step }); + return err; + }; + } +} + test "immediate exit" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); - const actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(1, actions.len); - try testing.expectEqual(.exit, actions[0]); - const actions2 = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(0, actions2.len); + + try testViewer(&viewer, &.{ + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + .{ + .input = .{ .tmux = .exit }, + .check = (struct { + fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + } + }).check, + }, + }); } test "initial flow" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); - // First we receive the initial block end - { - const actions = viewer.next(.{ .tmux = .{ .block_end = "" } }); - try testing.expectEqual(0, actions.len); - } - - // Then we receive session-changed with the initial session - { - const actions = viewer.next(.{ .tmux = .{ .session_changed = .{ - .id = 42, - .name = "main", - } } }); - try testing.expectEqual(1, actions.len); - try testing.expect(actions[0] == .command); - try testing.expect(std.mem.startsWith(u8, actions[0].command, "list-windows")); - try testing.expectEqual(42, viewer.session_id); - } - - // Simulate our list-windows command - { - const actions = viewer.next(.{ .tmux = .{ - .block_end = - \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] - , - } }); - try testing.expect(actions.len > 0); - try testing.expect(actions[0] == .windows); - try testing.expectEqual(1, actions[0].windows.len); - } - - const exit_actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(1, exit_actions.len); - try testing.expectEqual(.exit, exit_actions[0]); - const final_actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(0, final_actions.len); + try testViewer(&viewer, &.{ + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 42, + .name = "main", + } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(42, v.session_id); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ + .block_end = + \\Hello, world! + \\ + , + } }, + // Moves on to the next pane + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); } From b7fe9a926da6e479ccd3d06fd13c49f4f68c07a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 11:19:47 -0800 Subject: [PATCH 581/702] terminal/tmux: capture visible area after history --- src/terminal/tmux/viewer.zig | 66 +++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 8d3194748..28a2aaf1e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -369,6 +369,11 @@ pub const Viewer = struct { id, content, ), + + .pane_visible => |id| try self.receivedPaneVisible( + id, + content, + ), } // After processing commands, we add our next command to @@ -489,6 +494,7 @@ pub const Viewer = struct { if (self.panes.contains(pane_id)) continue; try self.queueCommands(&.{ .{ .pane_history = pane_id }, + .{ .pane_visible = pane_id }, }); } } @@ -542,6 +548,31 @@ pub const Viewer = struct { }; } + fn receivedPaneVisible( + self: *Viewer, + id: usize, + content: []const u8, + ) !void { + // Get our pane + const entry = self.panes.getEntry(id) orelse { + log.info("received pane visible for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + + // Erase the active area and reset the cursor to the top-left + // before writing the visible content. + pane.terminal.eraseDisplay(.complete, false); + pane.terminal.setCursorPos(1, 1); + + var stream = pane.terminal.vtStream(); + defer stream.deinit(); + stream.nextSlice(content) catch |err| { + log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); + return err; + }; + } + fn initLayout( gpa_alloc: Allocator, panes_old: *const PanesMap, @@ -681,6 +712,9 @@ const Command = union(enum) { /// Capture history for the given pane ID. pane_history: usize, + /// Capture visible area for the given pane ID. + pane_visible: usize, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -689,6 +723,7 @@ const Command = union(enum) { return switch (self) { .list_windows, .pane_history, + .pane_visible, => {}, .user => |v| alloc.free(v), }; @@ -718,6 +753,15 @@ const Command = union(enum) { .{id}, ), + .pane_visible => |id| try writer.print( + // -p = output to stdout instead of buffer + // -e = output escape sequences for SGR + // -t %{d} = target a specific pane ID + // (no -S/-E = capture visible area only) + "capture-pane -p -e -t %{d}\n", + .{id}, + ), + .user => |v| try writer.writeAll(v), } } @@ -888,7 +932,27 @@ test "initial flow" { \\ , } }, - // Moves on to the next pane + // Moves on to pane_visible for pane 0 + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { From a3e01581bea9907c3d03d180c1eb57850b9d89c5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 11:29:27 -0800 Subject: [PATCH 582/702] terminal/tmux: history capture clears active area --- src/terminal/tmux/viewer.zig | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 28a2aaf1e..aa9c91a03 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -4,6 +4,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const Screen = @import("../Screen.zig"); const Terminal = @import("../Terminal.zig"); const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); @@ -536,16 +537,34 @@ pub const Viewer = struct { return; }; const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.active; // Get a VT stream from the terminal so we can send data as-is into // it. This will populate the active area too so it won't be exactly // correct but we'll get the active contents soon. - var stream = pane.terminal.vtStream(); + var stream = t.vtStream(); defer stream.deinit(); stream.nextSlice(content) catch |err| { log.info("failed to process pane history for pane id={}: {}", .{ id, err }); return err; }; + + // Populate the active area to be empty since this is only history. + // We'll fill it with blanks and move the cursor to the top-left. + t.carriageReturn(); + for (0..t.rows) |_| try t.index(); + t.setCursorPos(1, 1); + + // Our active area should be empty + if (comptime std.debug.runtime_safety) { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + screen.dumpString(&discarding.writer, .{ + .tl = screen.pages.getTopLeft(.active), + .unwrap = false, + }) catch unreachable; + assert(discarding.count == 0); + } } fn receivedPaneVisible( @@ -929,7 +948,6 @@ test "initial flow" { .input = .{ .tmux = .{ .block_end = \\Hello, world! - \\ , } }, // Moves on to pane_visible for pane 0 @@ -939,6 +957,28 @@ test "initial flow" { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); } }).check, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello, world!", str); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + } + }).check, }, .{ .input = .{ .tmux = .{ .block_end = "" } }, From 50ac848672d3752e67af125101f9bccd75748f8b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 12:53:18 -0800 Subject: [PATCH 583/702] terminal/tmux: capture both primary/alt screen --- src/terminal/tmux/viewer.zig | 116 ++++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 21 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index aa9c91a03..5df5b83bb 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -5,6 +5,7 @@ const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const Screen = @import("../Screen.zig"); +const ScreenSet = @import("../ScreenSet.zig"); const Terminal = @import("../Terminal.zig"); const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); @@ -366,13 +367,15 @@ pub const Viewer = struct { content, ), - .pane_history => |id| try self.receivedPaneHistory( - id, + .pane_history => |cap| try self.receivedPaneHistory( + cap.screen_key, + cap.id, content, ), - .pane_visible => |id| try self.receivedPaneVisible( - id, + .pane_visible => |cap| try self.receivedPaneVisible( + cap.screen_key, + cap.id, content, ), } @@ -494,8 +497,10 @@ pub const Viewer = struct { const pane_id: usize = kv.key_ptr.*; if (self.panes.contains(pane_id)) continue; try self.queueCommands(&.{ - .{ .pane_history = pane_id }, - .{ .pane_visible = pane_id }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, }); } } @@ -528,6 +533,7 @@ pub const Viewer = struct { fn receivedPaneHistory( self: *Viewer, + screen_key: ScreenSet.Key, id: usize, content: []const u8, ) !void { @@ -538,6 +544,7 @@ pub const Viewer = struct { }; const pane: *Pane = entry.value_ptr; const t: *Terminal = &pane.terminal; + _ = try t.switchScreen(screen_key); const screen: *Screen = t.screens.active; // Get a VT stream from the terminal so we can send data as-is into @@ -569,6 +576,7 @@ pub const Viewer = struct { fn receivedPaneVisible( self: *Viewer, + screen_key: ScreenSet.Key, id: usize, content: []const u8, ) !void { @@ -578,13 +586,15 @@ pub const Viewer = struct { return; }; const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + _ = try t.switchScreen(screen_key); // Erase the active area and reset the cursor to the top-left // before writing the visible content. - pane.terminal.eraseDisplay(.complete, false); - pane.terminal.setCursorPos(1, 1); + t.eraseDisplay(.complete, false); + t.setCursorPos(1, 1); - var stream = pane.terminal.vtStream(); + var stream = t.vtStream(); defer stream.deinit(); stream.nextSlice(content) catch |err| { log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); @@ -729,15 +739,20 @@ const Command = union(enum) { list_windows, /// Capture history for the given pane ID. - pane_history: usize, + pane_history: CapturePane, /// Capture visible area for the given pane ID. - pane_visible: usize, + pane_visible: CapturePane, /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, + const CapturePane = struct { + id: usize, + screen_key: ScreenSet.Key, + }; + pub fn deinit(self: Command, alloc: Allocator) void { return switch (self) { .list_windows, @@ -761,24 +776,34 @@ const Command = union(enum) { .{comptime Format.list_windows.comptimeFormat()}, )), - .pane_history => |id| try writer.print( + .pane_history => |cap| try writer.print( // -p = output to stdout instead of buffer // -e = output escape sequences for SGR + // -a = capture alternate screen (only valid for alternate) + // -q = quiet, don't error if alternate screen doesn't exist // -S - = start at the top of history ("-") // -E -1 = end at the last line of history (1 before the // visible area is -1). // -t %{d} = target a specific pane ID - "capture-pane -p -e -S - -E -1 -t %{d}\n", - .{id}, + "capture-pane -p -e -q {s}-S - -E -1 -t %{d}\n", + .{ + if (cap.screen_key == .alternate) "-a " else "", + cap.id, + }, ), - .pane_visible => |id| try writer.print( + .pane_visible => |cap| try writer.print( // -p = output to stdout instead of buffer // -e = output escape sequences for SGR + // -a = capture alternate screen (only valid for alternate) + // -q = quiet, don't error if alternate screen doesn't exist // -t %{d} = target a specific pane ID // (no -S/-E = capture visible area only) - "capture-pane -p -e -t %{d}\n", - .{id}, + "capture-pane -p -e -q {s}-t %{d}\n", + .{ + if (cap.screen_key == .alternate) "-a " else "", + cap.id, + }, ), .user => |v| try writer.writeAll(v), @@ -938,9 +963,11 @@ test "initial flow" { } }, .contains_tags = &.{ .windows, .command }, .contains_command = "capture-pane", + // pane_history for pane 0 (primary) .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, @@ -950,11 +977,12 @@ test "initial flow" { \\Hello, world! , } }, - // Moves on to pane_visible for pane 0 + // Moves on to pane_visible for pane 0 (primary) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, .check = (struct { @@ -982,21 +1010,67 @@ test "initial flow" { }, .{ .input = .{ .tmux = .{ .block_end = "" } }, - // Moves on to pane_history for pane 1 + // Moves on to pane_history for pane 0 (alternate) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { - try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, .{ .input = .{ .tmux = .{ .block_end = "" } }, - // Moves on to pane_visible for pane 1 + // Moves on to pane_visible for pane 0 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 (primary) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 (primary) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, From 938e419e042bfd9322b5180e6ac54c122f558a36 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 13:11:58 -0800 Subject: [PATCH 584/702] terminal/tmux: handle output events --- src/terminal/tmux/viewer.zig | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 5df5b83bb..f6cf6292b 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -13,6 +13,12 @@ const output = @import("output.zig"); const log = std.log.scoped(.terminal_tmux_viewer); +// TODO: A list of TODOs as I think about them. +// - We need to make startup more robust so session and block can happen +// out of order. +// - We need to ignore `output` for panes that aren't yet initialized +// (until capture-panes are complete). + // NOTE: There is some fragility here that can possibly break if tmux // changes their implementation. In particular, the order of notifications // and assurances about what is sent when are based on reading the tmux @@ -312,6 +318,20 @@ pub const Viewer = struct { break :err self.defunct(); }, + .output => |out| output: { + self.receivedOutput( + out.pane_id, + out.data, + ) catch |err| { + log.warn( + "failed to process output for pane id={}: {}", + .{ out.pane_id, err }, + ); + }; + + break :output &.{}; + }, + // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. else => &.{}, @@ -602,6 +622,26 @@ pub const Viewer = struct { }; } + fn receivedOutput( + self: *Viewer, + id: usize, + data: []const u8, + ) !void { + const entry = self.panes.getEntry(id) orelse { + log.info("received output for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice(data) catch |err| { + log.info("failed to process output for pane id={}: {}", .{ id, err }); + return err; + }; + } + fn initLayout( gpa_alloc: Allocator, panes_old: *const PanesMap, @@ -1074,6 +1114,33 @@ test "initial flow" { } }).check, }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + }, + .{ + .input = .{ .tmux = .{ .output = .{ .pane_id = 0, .data = "new output" } } }, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expect(std.mem.containsAtLeast(u8, str, 1, "new output")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .output = .{ .pane_id = 999, .data = "ignored" } } }, + .check = (struct { + fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + } + }).check, + }, .{ .input = .{ .tmux = .exit }, .contains_tags = &.{.exit}, From 64ef640127c7a48172a27990f240a8c068b0ea70 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 13:52:53 -0800 Subject: [PATCH 585/702] terminal/tmux: exhaustive switch for command --- src/terminal/tmux/viewer.zig | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index f6cf6292b..9c6fa1b1f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -18,6 +18,8 @@ const log = std.log.scoped(.terminal_tmux_viewer); // out of order. // - We need to ignore `output` for panes that aren't yet initialized // (until capture-panes are complete). +// - We should note what the active window pane is on the tmux side; +// we can use this at least for initial focus. // NOTE: There is some fragility here that can possibly break if tmux // changes their implementation. In particular, the order of notifications @@ -332,9 +334,30 @@ pub const Viewer = struct { break :output &.{}; }, - // TODO: Use exhaustive matching here, determine if we need - // to handle the other cases. - else => &.{}, + // TODO: There's real logic to do for these. + .session_changed, + .layout_change, + .window_add, + => &.{}, + + // The active pane changed. We don't care about this because + // we handle our own focus. + .window_pane_changed => &.{}, + + // We ignore this one. It means a session was created or + // destroyed. If it was our own session we will get an exit + // notification very soon. If it is another session we don't + // care. + .sessions_changed => &.{}, + + // We don't use window names for anything, currently. + .window_renamed => &.{}, + + // This is for other clients, which we don't do anything about. + // For us, we'll get `exit` or `session_changed`, respectively. + .client_detached, + .client_session_changed, + => &.{}, }; } From 071070faa3a5d9d56e4f802218cd6e8a31075670 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 14:11:25 -0800 Subject: [PATCH 586/702] terminal/tmux: handle session_changed inside command loop --- src/terminal/tmux/viewer.zig | 136 ++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 9c6fa1b1f..3b401f44e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -315,9 +315,9 @@ pub const Viewer = struct { => |content, tag| self.receivedCommandOutput( content, tag == .block_err, - ) catch err: { + ) catch { log.warn("failed to process command output, becoming defunct", .{}); - break :err self.defunct(); + return self.defunct(); }, .output => |out| output: { @@ -334,8 +334,14 @@ pub const Viewer = struct { break :output &.{}; }, + // Session changed means we switched to a different tmux session. + // We need to reset our state and start fresh with list-windows. + .session_changed => |info| self.sessionChanged(info.id) catch { + log.warn("failed to handle session change, becoming defunct", .{}); + return self.defunct(); + }, + // TODO: There's real logic to do for these. - .session_changed, .layout_change, .window_add, => &.{}, @@ -361,6 +367,47 @@ pub const Viewer = struct { }; } + /// When a session changes, we have to basically reset our whole state. + /// To do this, we emit an empty windows event (so callers can clear all + /// windows), reset ourself, and start all over. + fn sessionChanged( + self: *Viewer, + session_id: usize, + ) (Allocator.Error || std.Io.Writer.Error)![]const Action { + // Build up a new viewer. Its the easiest way to reset ourselves. + var replacement: Viewer = try .init(self.alloc); + errdefer replacement.deinit(); + + // Build actions: empty windows notification + list-windows command + var arena = replacement.action_arena.promote(replacement.alloc); + const arena_alloc = arena.allocator(); + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, .{ .windows = &.{} }); + + // Setup our command queue + try actions.appendSlice( + arena_alloc, + try replacement.enterCommandQueue( + arena_alloc, + .list_windows, + ), + ); + + // Save arena state back before swap + replacement.action_arena = arena.state; + + // Swap our self, no more error handling after this. + errdefer comptime unreachable; + self.deinit(); + self.* = replacement; + + // Set our session ID and jump directly to the list + self.session_id = session_id; + + assert(self.state == .command_queue); + return actions.items; + } + fn receivedCommandOutput( self: *Viewer, content: []const u8, @@ -1000,6 +1047,89 @@ test "immediate exit" { }); } +test "session changed resets state" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "first", + } } }, + .contains_command = "list-windows", + }, + // Receive window layout with two panes (same format as "initial flow" test) + .{ + .input = .{ .tmux = .{ + .block_end = + \\$1 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.session_id); + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(2, v.panes.count()); + } + }).check, + }, + // Now session changes - should reset everything + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 2, + .name = "second", + } } }, + .contains_tags = &.{ .windows, .command }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + // Session ID should be updated + try testing.expectEqual(2, v.session_id); + // Windows should be cleared (empty windows action sent) + var found_empty_windows = false; + for (actions) |action| { + if (action == .windows and action.windows.len == 0) { + found_empty_windows = true; + } + } + try testing.expect(found_empty_windows); + // Old windows should be cleared + try testing.expectEqual(0, v.windows.items.len); + // Old panes should be cleared + try testing.expectEqual(0, v.panes.count()); + } + }).check, + }, + // Receive new window layout for new session (same layout, different session/window) + // Uses same pane IDs 0,1 - they should be re-created since old panes were cleared + .{ + .input = .{ .tmux = .{ + .block_end = + \\$2 @1 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.session_id); + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(1, v.windows.items[0].id); + // Panes 0 and 1 should be created (fresh, since old ones were cleared) + try testing.expectEqual(2, v.panes.count()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + test "initial flow" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); From 5df95ba210b40ef55a61d0401816b8d1c3099bd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 00:07:05 +0000 Subject: [PATCH 587/702] build(deps): bump peter-evans/create-pull-request from 7.0.11 to 8.0.0 Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7.0.11 to 8.0.0. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/22a9089034f40e5a961c8808d113e2c98fb63676...98357b18bf14b5342f975ff684046ec3b2a07725) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-version: 8.0.0 dependency-type: direct:production update-type: version-update:semver-major ... 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 ca65c2a21..bceb8aef1 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@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: title: Update iTerm2 colorschemes base: main From 1a2b3c165ac049ded7c893f23ea5ee1205bd35d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 15:31:44 -0800 Subject: [PATCH 588/702] terminal/tmux: layoutChanged handling --- src/terminal/tmux/viewer.zig | 436 ++++++++++++++++++++++++++++------- 1 file changed, 356 insertions(+), 80 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 3b401f44e..b8579d1d5 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -341,10 +341,20 @@ pub const Viewer = struct { return self.defunct(); }, + // Layout changed of a single window. + .layout_change => |info| self.layoutChanged( + info.window_id, + info.layout, + ) catch { + // Note: in the future, we can probably handle a failure + // here with a fallback to remove this one window, list + // windows again, and try again. + log.warn("failed to handle layout change, becoming defunct", .{}); + return self.defunct(); + }, + // TODO: There's real logic to do for these. - .layout_change, - .window_add, - => &.{}, + .window_add => &.{}, // The active pane changed. We don't care about this because // we handle our own focus. @@ -367,6 +377,164 @@ pub const Viewer = struct { }; } + /// When the layout changes for a single window, a pane may be added + /// or removed that we've never seen, in addition to the layout itself + /// physically changing. + /// + /// To handle this, its similar to list-windows except we expect the + /// window to already exist. We update the layout, do the initLayout + /// call for any diffs, setup commands to capture any new panes, + /// prune any removed panes. + fn layoutChanged( + self: *Viewer, + window_id: usize, + layout_str: []const u8, + ) ![]const Action { + // Find the window this layout change is for. + const window: *Window = window: for (self.windows.items) |*w| { + if (w.id == window_id) break :window w; + } else { + log.info("layout change for unknown window id={}", .{window_id}); + return &.{}; + }; + + // Clear our prior window arena and setup our layout + window.layout = layout: { + var arena = window.layout_arena.promote(self.alloc); + defer window.layout_arena = arena.state; + _ = arena.reset(.retain_capacity); + break :layout Layout.parseWithChecksum( + arena.allocator(), + layout_str, + ) catch |err| { + log.info( + "failed to parse window layout id={} layout={s}", + .{ window_id, layout_str }, + ); + return err; + }; + }; + + // If our command queue started out empty and becomes non-empty, + // then we need to send down the command. + const command_queue_empty = self.command_queue.empty(); + + // Reset our arena so we can build up actions. + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + // Our initial action is to definitely let the caller know that + // some windows changed. + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, .{ .windows = self.windows.items }); + + // Sync up our panes + try self.syncLayouts(self.windows.items); + + // If our command queue was empty and now its not we need to add + // a command to the output. + assert(self.state == .command_queue); + if (command_queue_empty and !self.command_queue.empty()) { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const command = self.command_queue.first().?; + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + try actions.append(arena_alloc, action); + } + + return actions.items; + } + + fn syncLayouts( + self: *Viewer, + windows: []const Window, + ) !void { + // Go through the window layout and setup all our panes. We move + // this into a new panes map so that we can easily prune our old + // list. + var panes: PanesMap = .empty; + errdefer { + // Clear out all the new panes. + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + if (!self.panes.contains(kv.key_ptr.*)) { + kv.value_ptr.deinit(self.alloc); + } + } + panes.deinit(self.alloc); + } + for (windows) |window| try initLayout( + self.alloc, + &self.panes, + &panes, + window.layout, + ); + + // Build up the list of removed panes. + var removed: std.ArrayList(usize) = removed: { + var removed: std.ArrayList(usize) = .empty; + errdefer removed.deinit(self.alloc); + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| { + if (panes.contains(kv.key_ptr.*)) continue; + try removed.append(self.alloc, kv.key_ptr.*); + } + + break :removed removed; + }; + defer removed.deinit(self.alloc); + + // Ensure we can add the windows + try self.windows.ensureTotalCapacity(self.alloc, windows.len); + + // Get our list of added panes and setup our command queue + // to populate them. + // TODO: errdefer cleanup + { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + const pane_id: usize = kv.key_ptr.*; + if (self.panes.contains(pane_id)) continue; + try self.queueCommands(&.{ + .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, + }); + } + } + + // No more errors after this point. We're about to replace all + // our owned state with our temporary state, and our errdefers + // above will double-free if there is an error. + errdefer comptime unreachable; + + // Replace our window list if it changed. We assume it didn't + // change if our pointer is pointing to the same data. + if (windows.ptr != self.windows.items.ptr) { + for (self.windows.items) |*window| window.deinit(self.alloc); + self.windows.clearRetainingCapacity(); + self.windows.appendSliceAssumeCapacity(windows); + } + + // Replace our panes + { + // First remove our old panes + for (removed.items) |id| if (self.panes.fetchSwapRemove( + id, + )) |entry_const| { + var entry = entry_const; + entry.value.deinit(self.alloc); + }; + // We can now deinit self.panes because the existing + // entries are preserved. + self.panes.deinit(self.alloc); + self.panes = panes; + } + } + /// When a session changes, we have to basically reset our whole state. /// To do this, we emit an empty windows event (so callers can clear all /// windows), reset ourself, and start all over. @@ -499,7 +667,7 @@ pub const Viewer = struct { // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; - errdefer windows.deinit(self.alloc); + defer windows.deinit(self.alloc); // Parse all our windows var it = std.mem.splitScalar(u8, content, '\n'); @@ -543,82 +711,8 @@ pub const Viewer = struct { // window changes. try actions.append(arena_alloc, .{ .windows = windows.items }); - // Go through the window layout and setup all our panes. We move - // this into a new panes map so that we can easily prune our old - // list. - var panes: PanesMap = .empty; - errdefer { - // Clear out all the new panes. - var panes_it = panes.iterator(); - while (panes_it.next()) |kv| { - if (!self.panes.contains(kv.key_ptr.*)) { - kv.value_ptr.deinit(self.alloc); - } - } - panes.deinit(self.alloc); - } - for (windows.items) |window| try initLayout( - self.alloc, - &self.panes, - &panes, - window.layout, - ); - - // Build up the list of removed panes. - var removed: std.ArrayList(usize) = removed: { - var removed: std.ArrayList(usize) = .empty; - errdefer removed.deinit(self.alloc); - var panes_it = self.panes.iterator(); - while (panes_it.next()) |kv| { - if (panes.contains(kv.key_ptr.*)) continue; - try removed.append(self.alloc, kv.key_ptr.*); - } - - break :removed removed; - }; - defer removed.deinit(self.alloc); - - // Get our list of added panes and setup our command queue - // to populate them. - // TODO: errdefer cleanup - { - var panes_it = panes.iterator(); - while (panes_it.next()) |kv| { - const pane_id: usize = kv.key_ptr.*; - if (self.panes.contains(pane_id)) continue; - try self.queueCommands(&.{ - .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, - .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, - .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, - .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, - }); - } - } - - // No more errors after this point. We're about to replace all - // our owned state with our temporary state, and our errdefers - // above will double-free if there is an error. - errdefer comptime unreachable; - - // Replace our window list - for (self.windows.items) |*window| window.deinit(self.alloc); - self.windows.deinit(self.alloc); - self.windows = windows; - - // Replace our panes - { - // First remove our old panes - for (removed.items) |id| if (self.panes.fetchSwapRemove( - id, - )) |entry_const| { - var entry = entry_const; - entry.value.deinit(self.alloc); - }; - // We can now deinit self.panes because the existing - // entries are preserved. - self.panes.deinit(self.alloc); - self.panes = panes; - } + // Sync up our layouts. This will populate unknown panes, prune, etc. + try self.syncLayouts(windows.items); } fn receivedPaneHistory( @@ -1300,3 +1394,185 @@ test "initial flow" { }, }); } + +test "layout change" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(1, v.panes.count()); + try testing.expect(v.panes.contains(0)); + } + }).check, + }, + // Complete all capture-pane commands for pane 0 (primary and alternate) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Now send a layout_change that splits into two panes + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{.windows}, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Should still have 1 window + try testing.expectEqual(1, v.windows.items.len); + // Should now have 2 panes (0 and 2) + try testing.expectEqual(2, v.panes.count()); + try testing.expect(v.panes.contains(0)); + try testing.expect(v.panes.contains(2)); + // Commands should be queued for the new pane + try testing.expectEqual(4, v.command_queue.len()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout_change does not return command when queue not empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + // Do NOT complete capture-pane commands - queue still has commands. + // Send a layout_change that splits into two panes. + // This should NOT return a command action since queue was not empty. + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{.windows}, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.panes.count()); + // Should not contain a command action + for (actions) |action| { + try testing.expect(action != .command); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout_change returns command when queue was empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + }, + // Complete all capture-pane commands for pane 0 + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Queue should now be empty + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(v.command_queue.empty()); + } + }).check, + }, + // Now send a layout_change that splits into two panes. + // This should return a command action since we're queuing commands + // for the new pane and the queue was empty. + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.panes.count()); + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 582ea5d84bab67a56f061dce22b46e56f223d1e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 17:15:23 -0800 Subject: [PATCH 589/702] terminal/tmux: window add --- src/terminal/tmux/viewer.zig | 148 ++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index b8579d1d5..c3860c6e4 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -353,8 +353,11 @@ pub const Viewer = struct { return self.defunct(); }, - // TODO: There's real logic to do for these. - .window_add => &.{}, + // A window was added to this session. + .window_add => |info| self.windowAdd(info.id) catch { + log.warn("failed to handle window add, becoming defunct", .{}); + return self.defunct(); + }, // The active pane changed. We don't care about this because // we handle our own focus. @@ -447,6 +450,40 @@ pub const Viewer = struct { return actions.items; } + /// When a window is added to the session, we need to refresh our window + /// list to get the new window's information. + fn windowAdd(self: *Viewer, window_id: usize) ![]const Action { + _ = window_id; // We refresh all windows via list-windows + + // If our command queue started out empty and becomes non-empty, + // then we need to send down the command. + const command_queue_empty = self.command_queue.empty(); + + // Queue list-windows to get the updated window list + try self.queueCommands(&.{.list_windows}); + + // If our command queue was empty and now it's not, we need to add + // a command to the output. + assert(self.state == .command_queue); + if (command_queue_empty) { + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const command = self.command_queue.first().?; + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, action); + return actions.items; + } + + return &.{}; + } + fn syncLayouts( self: *Viewer, windows: []const Window, @@ -1576,3 +1613,110 @@ test "layout_change returns command when queue was empty" { }, }); } + +test "window_add queues list_windows when queue empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + }, + // Complete all capture-pane commands for pane 0 + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Queue should now be empty + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(v.command_queue.empty()); + } + }).check, + }, + // Now send window_add - should trigger list-windows command + .{ + .input = .{ .tmux = .{ .window_add = .{ .id = 1 } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Command queue should have list_windows + try testing.expect(!v.command_queue.empty()); + try testing.expectEqual(1, v.command_queue.len()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "window_add queues list_windows when queue not empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Queue should have capture-pane commands + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + // Do NOT complete capture-pane commands - queue still has commands. + // Send window_add - should queue list-windows but NOT return command action + .{ + .input = .{ .tmux = .{ .window_add = .{ .id = 1 } } }, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + // Should not contain a command action since queue was not empty + for (actions) |action| { + try testing.expect(action != .command); + } + // But list_windows should be in the queue + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 4c30c5aa765c1c76c5c4c1b1285b66af61f1840a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 20:19:20 -0800 Subject: [PATCH 590/702] terminal/tmux: cleanup command queue logic --- src/terminal/tmux/viewer.zig | 220 +++++++++++++++++------------------ 1 file changed, 109 insertions(+), 111 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index c3860c6e4..306bcd69d 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -306,43 +306,73 @@ pub const Viewer = struct { // handle it by ignoring any command output. That's okay! assert(self.state == .command_queue); - return switch (n) { + // Clear our prior arena so it is ready to be used for any + // actions immediately. + { + var arena = self.action_arena.promote(self.alloc); + _ = arena.reset(.free_all); + self.action_arena = arena.state; + } + + // Setup our empty actions list that commands can populate. + var actions: std.ArrayList(Action) = .empty; + + // Track whether the in-flight command slot is available. Starts true + // if queue is empty (no command in flight). Set to true when a command + // completes (block_end/block_err) or the queue is reset (session_changed). + var command_consumed = self.command_queue.empty(); + + switch (n) { .enter => unreachable, - .exit => self.defunct(), + .exit => return self.defunct(), inline .block_end, .block_err, - => |content, tag| self.receivedCommandOutput( - content, - tag == .block_err, - ) catch { - log.warn("failed to process command output, becoming defunct", .{}); - return self.defunct(); - }, - - .output => |out| output: { - self.receivedOutput( - out.pane_id, - out.data, - ) catch |err| { - log.warn( - "failed to process output for pane id={}: {}", - .{ out.pane_id, err }, - ); + => |content, tag| { + self.receivedCommandOutput( + &actions, + content, + tag == .block_err, + ) catch { + log.warn("failed to process command output, becoming defunct", .{}); + return self.defunct(); }; - break :output &.{}; + // Command is consumed since a block end/err is the output + // from a command. + command_consumed = true; + }, + + .output => |out| self.receivedOutput( + out.pane_id, + out.data, + ) catch |err| { + log.warn( + "failed to process output for pane id={}: {}", + .{ out.pane_id, err }, + ); }, // Session changed means we switched to a different tmux session. // We need to reset our state and start fresh with list-windows. - .session_changed => |info| self.sessionChanged(info.id) catch { - log.warn("failed to handle session change, becoming defunct", .{}); - return self.defunct(); + // This completely replaces the viewer, so treat it like a fresh start. + .session_changed => |info| { + self.sessionChanged( + &actions, + info.id, + ) catch { + log.warn("failed to handle session change, becoming defunct", .{}); + return self.defunct(); + }; + + // Command is consumed because sessionChanged resets + // our entire viewer. + command_consumed = true; }, // Layout changed of a single window. .layout_change => |info| self.layoutChanged( + &actions, info.window_id, info.layout, ) catch { @@ -361,23 +391,53 @@ pub const Viewer = struct { // The active pane changed. We don't care about this because // we handle our own focus. - .window_pane_changed => &.{}, + .window_pane_changed => {}, // We ignore this one. It means a session was created or // destroyed. If it was our own session we will get an exit // notification very soon. If it is another session we don't // care. - .sessions_changed => &.{}, + .sessions_changed => {}, // We don't use window names for anything, currently. - .window_renamed => &.{}, + .window_renamed => {}, // This is for other clients, which we don't do anything about. // For us, we'll get `exit` or `session_changed`, respectively. .client_detached, .client_session_changed, - => &.{}, - }; + => {}, + } + + // After processing commands, we add our next command to + // execute if we have one. We do this last because command + // processing may itself queue more commands. We only emit a + // command if a prior command was consumed (or never existed). + if (self.state == .command_queue and command_consumed) { + if (self.command_queue.first()) |next_command| { + // We should not have any commands, because our nextCommand + // always queues them. + if (comptime std.debug.runtime_safety) { + for (actions.items) |action| { + if (action == .command) assert(false); + } + } + + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + next_command.formatCommand(&builder.writer) catch + return self.defunct(); + actions.append( + arena_alloc, + .{ .command = builder.writer.buffered() }, + ) catch return self.defunct(); + } + } + + return actions.items; } /// When the layout changes for a single window, a pane may be added @@ -390,15 +450,16 @@ pub const Viewer = struct { /// prune any removed panes. fn layoutChanged( self: *Viewer, + actions: *std.ArrayList(Action), window_id: usize, layout_str: []const u8, - ) ![]const Action { + ) !void { // Find the window this layout change is for. const window: *Window = window: for (self.windows.items) |*w| { if (w.id == window_id) break :window w; } else { log.info("layout change for unknown window id={}", .{window_id}); - return &.{}; + return; }; // Clear our prior window arena and setup our layout @@ -418,70 +479,29 @@ pub const Viewer = struct { }; }; - // If our command queue started out empty and becomes non-empty, - // then we need to send down the command. - const command_queue_empty = self.command_queue.empty(); - // Reset our arena so we can build up actions. var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; - _ = arena.reset(.free_all); const arena_alloc = arena.allocator(); // Our initial action is to definitely let the caller know that // some windows changed. - var actions: std.ArrayList(Action) = .empty; try actions.append(arena_alloc, .{ .windows = self.windows.items }); // Sync up our panes try self.syncLayouts(self.windows.items); - - // If our command queue was empty and now its not we need to add - // a command to the output. - assert(self.state == .command_queue); - if (command_queue_empty and !self.command_queue.empty()) { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const command = self.command_queue.first().?; - command.formatCommand(&builder.writer) catch return error.OutOfMemory; - const action: Action = .{ .command = builder.writer.buffered() }; - try actions.append(arena_alloc, action); - } - - return actions.items; } /// When a window is added to the session, we need to refresh our window /// list to get the new window's information. - fn windowAdd(self: *Viewer, window_id: usize) ![]const Action { + fn windowAdd( + self: *Viewer, + window_id: usize, + ) !void { _ = window_id; // We refresh all windows via list-windows - // If our command queue started out empty and becomes non-empty, - // then we need to send down the command. - const command_queue_empty = self.command_queue.empty(); - // Queue list-windows to get the updated window list try self.queueCommands(&.{.list_windows}); - - // If our command queue was empty and now it's not, we need to add - // a command to the output. - assert(self.state == .command_queue); - if (command_queue_empty) { - var arena = self.action_arena.promote(self.alloc); - defer self.action_arena = arena.state; - _ = arena.reset(.free_all); - const arena_alloc = arena.allocator(); - - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const command = self.command_queue.first().?; - command.formatCommand(&builder.writer) catch return error.OutOfMemory; - const action: Action = .{ .command = builder.writer.buffered() }; - - var actions: std.ArrayList(Action) = .empty; - try actions.append(arena_alloc, action); - return actions.items; - } - - return &.{}; } fn syncLayouts( @@ -577,26 +597,26 @@ pub const Viewer = struct { /// windows), reset ourself, and start all over. fn sessionChanged( self: *Viewer, + actions: *std.ArrayList(Action), session_id: usize, - ) (Allocator.Error || std.Io.Writer.Error)![]const Action { + ) (Allocator.Error || std.Io.Writer.Error)!void { // Build up a new viewer. Its the easiest way to reset ourselves. var replacement: Viewer = try .init(self.alloc); errdefer replacement.deinit(); + // Our actions must start out empty so we don't mix arenas + assert(actions.items.len == 0); + errdefer actions.* = .empty; + // Build actions: empty windows notification + list-windows command var arena = replacement.action_arena.promote(replacement.alloc); const arena_alloc = arena.allocator(); - var actions: std.ArrayList(Action) = .empty; try actions.append(arena_alloc, .{ .windows = &.{} }); - // Setup our command queue - try actions.appendSlice( - arena_alloc, - try replacement.enterCommandQueue( - arena_alloc, - .list_windows, - ), - ); + // Setup our command queue and put ourselves in the command queue + // state. + try replacement.queueCommands(&.{.list_windows}); + replacement.state = .command_queue; // Save arena state back before swap replacement.action_arena = arena.state; @@ -610,14 +630,14 @@ pub const Viewer = struct { self.session_id = session_id; assert(self.state == .command_queue); - return actions.items; } fn receivedCommandOutput( self: *Viewer, + actions: *std.ArrayList(Action), content: []const u8, is_err: bool, - ) ![]const Action { + ) !void { // Get the command we're expecting output for. We need to get the // non-pointer value because we are deleting it from the circular // buffer immediately. This shallow copy is all we need since @@ -636,7 +656,7 @@ pub const Viewer = struct { } else { // If we have no pending commands, this is unexpected. log.info("unexpected block output err={}", .{is_err}); - return &.{}; + return; }; self.command_queue.deleteOldest(1); defer command.deinit(self.alloc); @@ -645,20 +665,15 @@ pub const Viewer = struct { // easily accumulate actions. var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; - _ = arena.reset(.free_all); const arena_alloc = arena.allocator(); - // Build up our actions to start with the next command if - // we have one. - var actions: std.ArrayList(Action) = .empty; - // Process our command switch (command) { .user => {}, .list_windows => try self.receivedListWindows( arena_alloc, - &actions, + actions, content, ), @@ -674,23 +689,6 @@ pub const Viewer = struct { content, ), } - - // After processing commands, we add our next command to - // execute if we have one. We do this last because command - // processing may itself queue more commands. - if (self.command_queue.first()) |next_command| { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - try next_command.formatCommand(&builder.writer); - try actions.append( - arena_alloc, - .{ .command = builder.writer.buffered() }, - ); - } - - // Our command processing should not change our state - assert(self.state == .command_queue); - - return actions.items; } fn receivedListWindows( From bf46c4ebe74d0e668762e84e690e86ca1389e486 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 20:49:03 -0800 Subject: [PATCH 591/702] terminal/tmux: many more output formats --- src/terminal/tmux/output.zig | 318 ++++++++++++++++++++++++++++++++++- 1 file changed, 312 insertions(+), 6 deletions(-) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index cff1a982d..02dca23e6 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -95,16 +95,107 @@ pub fn FormatStruct(comptime vars: []const Variable) type { /// a subset of them here that are relevant to the use case of implementing /// control mode for terminal emulators. pub const Variable = enum { + /// 1 if pane is in alternate screen. + alternate_on, + /// Saved cursor X in alternate screen. + alternate_saved_x, + /// Saved cursor Y in alternate screen. + alternate_saved_y, + /// 1 if bracketed paste mode is enabled. + bracketed_paste, + /// 1 if the cursor is blinking. + cursor_blinking, + /// Cursor colour in pane. Possible formats: + /// - Named colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, + /// `cyan`, `white`, `default`, `terminal`, or bright variants. + /// - 256 colors: `colour` where N is 0-255 (e.g., `colour100`). + /// - RGB hex: `#RRGGBB` (e.g., `#ff0000`). + /// - Empty string if unset. + cursor_colour, + /// Pane cursor flag. + cursor_flag, + /// Cursor shape in pane. Possible values: `block`, `underline`, `bar`, + /// or `default`. + cursor_shape, + /// Cursor X position in pane. + cursor_x, + /// Cursor Y position in pane. + cursor_y, + /// 1 if focus reporting is enabled. + focus_flag, + /// Pane insert flag. + insert_flag, + /// Pane keypad cursor flag. + keypad_cursor_flag, + /// Pane keypad flag. + keypad_flag, + /// Pane mouse all flag. + mouse_all_flag, + /// Pane mouse any flag. + mouse_any_flag, + /// Pane mouse button flag. + mouse_button_flag, + /// Pane mouse SGR flag. + mouse_sgr_flag, + /// Pane mouse standard flag. + mouse_standard_flag, + /// Pane mouse UTF-8 flag. + mouse_utf8_flag, + /// Pane origin flag. + origin_flag, + /// Unique pane ID prefixed with `%` (e.g., `%0`, `%42`). + pane_id, + /// Pane tab positions as a comma-separated list of 0-indexed column + /// numbers (e.g., `8,16,24,32`). Empty string if no tabs are set. + pane_tabs, + /// Bottom of scroll region in pane. + scroll_region_lower, + /// Top of scroll region in pane. + scroll_region_upper, + /// Unique session ID prefixed with `$` (e.g., `$0`, `$42`). session_id, + /// Unique window ID prefixed with `@` (e.g., `@0`, `@42`). window_id, + /// Width of window. window_width, + /// Height of window. window_height, + /// Window layout description, ignoring zoomed window panes. Format is + /// `,` where checksum is a 4-digit hex CRC16 and layout + /// encodes pane dimensions as `WxH,X,Y[,ID]` with `{...}` for horizontal + /// splits and `[...]` for vertical splits. window_layout, + /// Pane wrap flag. + wrap_flag, /// Parse the given string value into the appropriate resulting /// type for this variable. pub fn parse(comptime self: Variable, value: []const u8) !Type(self) { return switch (self) { + .alternate_on, + .bracketed_paste, + .cursor_blinking, + .cursor_flag, + .focus_flag, + .insert_flag, + .keypad_cursor_flag, + .keypad_flag, + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_sgr_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .origin_flag, + .wrap_flag, + => std.mem.eql(u8, value, "1"), + .alternate_saved_x, + .alternate_saved_y, + .cursor_x, + .cursor_y, + .scroll_region_lower, + .scroll_region_upper, + => try std.fmt.parseInt(usize, value, 10), .session_id => if (value.len >= 2 and value[0] == '$') try std.fmt.parseInt(usize, value[1..], 10) else @@ -113,24 +204,105 @@ pub const Variable = enum { try std.fmt.parseInt(usize, value[1..], 10) else return error.FormatError, + .pane_id => if (value.len >= 2 and value[0] == '%') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, .window_width => try std.fmt.parseInt(usize, value, 10), .window_height => try std.fmt.parseInt(usize, value, 10), - .window_layout => value, + .cursor_colour, + .cursor_shape, + .pane_tabs, + .window_layout, + => value, }; } /// The type of the parsed value for this variable type. pub fn Type(comptime self: Variable) type { return switch (self) { - .session_id => usize, - .window_id => usize, - .window_width => usize, - .window_height => usize, - .window_layout => []const u8, + .alternate_on, + .bracketed_paste, + .cursor_blinking, + .cursor_flag, + .focus_flag, + .insert_flag, + .keypad_cursor_flag, + .keypad_flag, + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_sgr_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .origin_flag, + .wrap_flag, + => bool, + .alternate_saved_x, + .alternate_saved_y, + .cursor_x, + .cursor_y, + .scroll_region_lower, + .scroll_region_upper, + .session_id, + .window_id, + .pane_id, + .window_width, + .window_height, + => usize, + .cursor_colour, + .cursor_shape, + .pane_tabs, + .window_layout, + => []const u8, }; } }; +test "parse alternate_on" { + try testing.expectEqual(true, try Variable.parse(.alternate_on, "1")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "0")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "true")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "yes")); +} + +test "parse alternate_saved_x" { + try testing.expectEqual(0, try Variable.parse(.alternate_saved_x, "0")); + try testing.expectEqual(42, try Variable.parse(.alternate_saved_x, "42")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_x, "abc")); +} + +test "parse alternate_saved_y" { + try testing.expectEqual(0, try Variable.parse(.alternate_saved_y, "0")); + try testing.expectEqual(42, try Variable.parse(.alternate_saved_y, "42")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_y, "abc")); +} + +test "parse cursor_x" { + try testing.expectEqual(0, try Variable.parse(.cursor_x, "0")); + try testing.expectEqual(79, try Variable.parse(.cursor_x, "79")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_x, "abc")); +} + +test "parse cursor_y" { + try testing.expectEqual(0, try Variable.parse(.cursor_y, "0")); + try testing.expectEqual(23, try Variable.parse(.cursor_y, "23")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_y, "abc")); +} + +test "parse scroll_region_upper" { + try testing.expectEqual(0, try Variable.parse(.scroll_region_upper, "0")); + try testing.expectEqual(5, try Variable.parse(.scroll_region_upper, "5")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_upper, "abc")); +} + +test "parse scroll_region_lower" { + try testing.expectEqual(0, try Variable.parse(.scroll_region_lower, "0")); + try testing.expectEqual(23, try Variable.parse(.scroll_region_lower, "23")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_lower, "abc")); +} + test "parse session id" { try testing.expectEqual(42, try Variable.parse(.session_id, "$42")); try testing.expectEqual(0, try Variable.parse(.session_id, "$0")); @@ -176,6 +348,140 @@ test "parse window layout" { try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)")); } +test "parse cursor_flag" { + try testing.expectEqual(true, try Variable.parse(.cursor_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "true")); +} + +test "parse insert_flag" { + try testing.expectEqual(true, try Variable.parse(.insert_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "true")); +} + +test "parse keypad_cursor_flag" { + try testing.expectEqual(true, try Variable.parse(.keypad_cursor_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "true")); +} + +test "parse keypad_flag" { + try testing.expectEqual(true, try Variable.parse(.keypad_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "true")); +} + +test "parse mouse_any_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_any_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "true")); +} + +test "parse mouse_button_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_button_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "true")); +} + +test "parse mouse_sgr_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_sgr_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "true")); +} + +test "parse mouse_standard_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_standard_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "true")); +} + +test "parse mouse_utf8_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_utf8_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "true")); +} + +test "parse wrap_flag" { + try testing.expectEqual(true, try Variable.parse(.wrap_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "true")); +} + +test "parse bracketed_paste" { + try testing.expectEqual(true, try Variable.parse(.bracketed_paste, "1")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "0")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "true")); +} + +test "parse cursor_blinking" { + try testing.expectEqual(true, try Variable.parse(.cursor_blinking, "1")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "0")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "true")); +} + +test "parse focus_flag" { + try testing.expectEqual(true, try Variable.parse(.focus_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "true")); +} + +test "parse mouse_all_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_all_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "true")); +} + +test "parse origin_flag" { + try testing.expectEqual(true, try Variable.parse(.origin_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "true")); +} + +test "parse pane_id" { + try testing.expectEqual(42, try Variable.parse(.pane_id, "%42")); + try testing.expectEqual(0, try Variable.parse(.pane_id, "%0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "@0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "%")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.pane_id, "%abc")); +} + +test "parse cursor_colour" { + try testing.expectEqualStrings("red", try Variable.parse(.cursor_colour, "red")); + try testing.expectEqualStrings("#ff0000", try Variable.parse(.cursor_colour, "#ff0000")); + try testing.expectEqualStrings("", try Variable.parse(.cursor_colour, "")); +} + +test "parse cursor_shape" { + try testing.expectEqualStrings("block", try Variable.parse(.cursor_shape, "block")); + try testing.expectEqualStrings("underline", try Variable.parse(.cursor_shape, "underline")); + try testing.expectEqualStrings("bar", try Variable.parse(.cursor_shape, "bar")); + try testing.expectEqualStrings("", try Variable.parse(.cursor_shape, "")); +} + +test "parse pane_tabs" { + try testing.expectEqualStrings("0,8,16,24", try Variable.parse(.pane_tabs, "0,8,16,24")); + try testing.expectEqualStrings("", try Variable.parse(.pane_tabs, "")); + try testing.expectEqualStrings("0", try Variable.parse(.pane_tabs, "0")); +} + test "parseFormatStruct single field" { const T = FormatStruct(&.{.session_id}); const result = try parseFormatStruct(T, "$42", ' '); From 58000f5821040060fe8c07c97073bff80886ebd1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 09:28:52 -0800 Subject: [PATCH 592/702] terminal/tmux: build up pane states --- src/terminal/tmux/viewer.zig | 372 ++++++++++++++++++++++++++++++++++- 1 file changed, 370 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 306bcd69d..5384e293f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -3,7 +3,9 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; +const size = @import("../size.zig"); const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const CursorStyle = @import("../cursor.zig").Style; const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); const Terminal = @import("../Terminal.zig"); @@ -551,9 +553,11 @@ pub const Viewer = struct { // TODO: errdefer cleanup { var panes_it = panes.iterator(); + var added: bool = false; while (panes_it.next()) |kv| { const pane_id: usize = kv.key_ptr.*; if (self.panes.contains(pane_id)) continue; + added = true; try self.queueCommands(&.{ .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, @@ -561,6 +565,10 @@ pub const Viewer = struct { .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, }); } + + // If we added any panes, then we also want to resync the pane + // state (terminal modes and cursor positions and so on). + if (added) try self.queueCommands(&.{.pane_state}); } // No more errors after this point. We're about to replace all @@ -671,6 +679,8 @@ pub const Viewer = struct { switch (command) { .user => {}, + .pane_state => try self.receivedPaneState(content), + .list_windows => try self.receivedListWindows( arena_alloc, actions, @@ -750,6 +760,137 @@ pub const Viewer = struct { try self.syncLayouts(windows.items); } + fn receivedPaneState( + self: *Viewer, + content: []const u8, + ) !void { + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + + const data = output.parseFormatStruct( + Format.list_panes.Struct(), + line, + Format.list_panes.delim, + ) catch |err| { + log.info("failed to parse list-panes line: {s}", .{line}); + return err; + }; + + // Get the pane for this ID + const entry = self.panes.getEntry(data.pane_id) orelse { + log.info("received pane state for untracked pane id={}", .{data.pane_id}); + continue; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + + // Determine which screen to use based on alternate_on + const screen_key: ScreenSet.Key = if (data.alternate_on) .alternate else .primary; + + // Set cursor position on the appropriate screen (tmux uses 0-based) + if (t.screens.get(screen_key)) |screen| { + cursor: { + const cursor_x = std.math.cast( + size.CellCountInt, + data.cursor_x, + ) orelse break :cursor; + const cursor_y = std.math.cast( + size.CellCountInt, + data.cursor_y, + ) orelse break :cursor; + if (cursor_x >= screen.pages.cols or + cursor_y >= screen.pages.rows) break :cursor; + screen.cursorAbsolute(cursor_x, cursor_y); + } + + // Set cursor shape on this screen + if (data.cursor_shape.len > 0) { + if (std.mem.eql(u8, data.cursor_shape, "block")) { + screen.cursor.cursor_style = .block; + } else if (std.mem.eql(u8, data.cursor_shape, "underline")) { + screen.cursor.cursor_style = .underline; + } else if (std.mem.eql(u8, data.cursor_shape, "bar")) { + screen.cursor.cursor_style = .bar; + } + } + // "default" or unknown: leave as-is + } + + // Set alternate screen saved cursor position + if (t.screens.get(.alternate)) |alt_screen| cursor: { + const alt_x = std.math.cast( + size.CellCountInt, + data.alternate_saved_x, + ) orelse break :cursor; + const alt_y = std.math.cast( + size.CellCountInt, + data.alternate_saved_y, + ) orelse break :cursor; + + // If our coordinates are outside our screen we ignore it. + // tmux actually sends MAX_INT for when there isn't a set + // cursor position, so this isn't theoretical. + if (alt_x >= alt_screen.pages.cols or + alt_y >= alt_screen.pages.rows) break :cursor; + + alt_screen.cursorAbsolute(alt_x, alt_y); + } + + // Set cursor visibility + t.modes.set(.cursor_visible, data.cursor_flag); + + // Set cursor blinking + t.modes.set(.cursor_blinking, data.cursor_blinking); + + // Terminal modes + t.modes.set(.insert, data.insert_flag); + t.modes.set(.wraparound, data.wrap_flag); + t.modes.set(.keypad_keys, data.keypad_flag); + t.modes.set(.cursor_keys, data.keypad_cursor_flag); + t.modes.set(.origin, data.origin_flag); + + // Mouse modes + t.modes.set(.mouse_event_any, data.mouse_all_flag); + t.modes.set(.mouse_event_button, data.mouse_any_flag); + t.modes.set(.mouse_event_normal, data.mouse_button_flag); + t.modes.set(.mouse_event_x10, data.mouse_standard_flag); + t.modes.set(.mouse_format_utf8, data.mouse_utf8_flag); + t.modes.set(.mouse_format_sgr, data.mouse_sgr_flag); + + // Focus and bracketed paste + t.modes.set(.focus_event, data.focus_flag); + t.modes.set(.bracketed_paste, data.bracketed_paste); + + // Scroll region (tmux uses 0-based values) + scroll: { + const scroll_top = std.math.cast( + size.CellCountInt, + data.scroll_region_upper, + ) orelse break :scroll; + const scroll_bottom = std.math.cast( + size.CellCountInt, + data.scroll_region_lower, + ) orelse break :scroll; + t.scrolling_region.top = scroll_top; + t.scrolling_region.bottom = scroll_bottom; + } + + // Tab stops - parse comma-separated list and set + t.tabstops.reset(0); // Clear all tabstops first + if (data.pane_tabs.len > 0) { + var tabs_it = std.mem.splitScalar(u8, data.pane_tabs, ','); + while (tabs_it.next()) |tab_str| { + const col = std.fmt.parseInt(usize, tab_str, 10) catch continue; + const col_cell = std.math.cast(size.CellCountInt, col) orelse continue; + if (col_cell >= t.cols) continue; + t.tabstops.set(col_cell); + } + } + } + } + fn receivedPaneHistory( self: *Viewer, screen_key: ScreenSet.Key, @@ -983,6 +1124,10 @@ const Command = union(enum) { /// Capture visible area for the given pane ID. pane_visible: CapturePane, + /// Capture the pane terminal state as best we can. The pane ID(s) + /// are part of the output so we can map it back to our panes. + pane_state, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -997,6 +1142,7 @@ const Command = union(enum) { .list_windows, .pane_history, .pane_visible, + .pane_state, => {}, .user => |v| alloc.free(v), }; @@ -1045,6 +1191,11 @@ const Command = union(enum) { }, ), + .pane_state => try writer.writeAll(std.fmt.comptimePrint( + "list-panes -F '{s}'\n", + .{comptime Format.list_panes.comptimeFormat()}, + )), + .user => |v| try writer.writeAll(v), } } @@ -1059,6 +1210,45 @@ const Format = struct { /// guaranteed to not appear in any of the variable outputs. delim: u8, + const list_panes: Format = .{ + .delim = ';', + .vars = &.{ + .pane_id, + // Cursor position & appearance + .cursor_x, + .cursor_y, + .cursor_flag, + .cursor_shape, + .cursor_colour, + .cursor_blinking, + // Alternate screen + .alternate_on, + .alternate_saved_x, + .alternate_saved_y, + // Terminal modes + .insert_flag, + .wrap_flag, + .keypad_flag, + .keypad_cursor_flag, + .origin_flag, + // Mouse modes + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .mouse_sgr_flag, + // Focus & special features + .focus_flag, + .bracketed_paste, + // Scroll region + .scroll_region_upper, + .scroll_region_lower, + // Tab stops + .pane_tabs, + }, + }; + const list_windows: Format = .{ .delim = ' ', .vars = &.{ @@ -1461,6 +1651,8 @@ test "layout change" { }).check, }, // Complete all capture-pane commands for pane 0 (primary and alternate) + // plus pane_state + .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, @@ -1482,8 +1674,8 @@ test "layout change" { try testing.expectEqual(2, v.panes.count()); try testing.expect(v.panes.contains(0)); try testing.expect(v.panes.contains(2)); - // Commands should be queued for the new pane - try testing.expectEqual(4, v.command_queue.len()); + // Commands should be queued for the new pane (4 capture-pane + 1 pane_state) + try testing.expectEqual(5, v.command_queue.len()); } }).check, }, @@ -1718,3 +1910,179 @@ test "window_add queues list_windows when queue not empty" { }, }); } + +test "two pane flow with pane state" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial block_end from attach + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Session changed notification + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 0, + .name = "0", + } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, v.session_id); + } + }).check, + }, + // list-windows output with 2 panes in a vertical split + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 165 79 ca97,165x79,0,0[165x40,0,0,0,165x38,0,41,4] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.windows.items.len); + const window = v.windows.items[0]; + try testing.expectEqual(0, window.id); + try testing.expectEqual(165, window.width); + try testing.expectEqual(79, window.height); + try testing.expectEqual(2, v.panes.count()); + try testing.expect(v.panes.contains(0)); + try testing.expect(v.panes.contains(4)); + } + }).check, + }, + // capture-pane pane 0 primary history + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + \\prompt % + , + } }, + }, + // capture-pane pane 0 primary visible + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + // History has 2 lines with "prompt %" (padded to screen width) + try testing.expect(std.mem.containsAtLeast(u8, str, 2, "prompt %")); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("prompt %", str); + } + } + }).check, + }, + // capture-pane pane 0 alternate history (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 0 alternate visible (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 4 primary history + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + }, + // capture-pane pane 4 primary visible + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("prompt %", str); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + // Active screen starts with "prompt %" at beginning + try testing.expect(std.mem.startsWith(u8, str, "prompt %")); + } + } + }).check, + }, + // capture-pane pane 4 alternate history (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 4 alternate visible (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // list-panes output with terminal state + .{ + .input = .{ .tmux = .{ + .block_end = + \\%0;42;0;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;39;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160 + \\%4;10;5;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;37;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160 + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Pane 0: cursor at (42, 0), cursor visible, wraparound on + { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.get(.primary).?; + try testing.expectEqual(42, screen.cursor.x); + try testing.expectEqual(0, screen.cursor.y); + try testing.expect(t.modes.get(.cursor_visible)); + try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.modes.get(.insert)); + try testing.expect(!t.modes.get(.origin)); + try testing.expect(!t.modes.get(.keypad_keys)); + try testing.expect(!t.modes.get(.cursor_keys)); + } + // Pane 4: cursor at (10, 5), cursor visible, wraparound on + { + const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.get(.primary).?; + try testing.expectEqual(10, screen.cursor.x); + try testing.expectEqual(5, screen.cursor.y); + try testing.expect(t.modes.get(.cursor_visible)); + try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.modes.get(.insert)); + try testing.expect(!t.modes.get(.origin)); + try testing.expect(!t.modes.get(.keypad_keys)); + try testing.expect(!t.modes.get(.cursor_keys)); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 29bb18d8cd20ea092d4023a89563bfc9f0f90fbd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:33:56 -0800 Subject: [PATCH 593/702] terminal/tmux: grab tmux version on startup --- src/terminal/tmux/output.zig | 11 ++++ src/terminal/tmux/viewer.zig | 120 ++++++++++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index 02dca23e6..6b8073e44 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -154,6 +154,8 @@ pub const Variable = enum { scroll_region_upper, /// Unique session ID prefixed with `$` (e.g., `$0`, `$42`). session_id, + /// Server version (e.g., `3.5a`). + version, /// Unique window ID prefixed with `@` (e.g., `@0`, `@42`). window_id, /// Width of window. @@ -213,6 +215,7 @@ pub const Variable = enum { .cursor_colour, .cursor_shape, .pane_tabs, + .version, .window_layout, => value, }; @@ -253,6 +256,7 @@ pub const Variable = enum { .cursor_colour, .cursor_shape, .pane_tabs, + .version, .window_layout, => []const u8, }; @@ -482,6 +486,13 @@ test "parse pane_tabs" { try testing.expectEqualStrings("0", try Variable.parse(.pane_tabs, "0")); } +test "parse version" { + try testing.expectEqualStrings("3.5a", try Variable.parse(.version, "3.5a")); + try testing.expectEqualStrings("3.5", try Variable.parse(.version, "3.5")); + try testing.expectEqualStrings("next-3.5", try Variable.parse(.version, "next-3.5")); + try testing.expectEqualStrings("", try Variable.parse(.version, "")); +} + test "parseFormatStruct single field" { const T = FormatStruct(&.{.session_id}); const result = try parseFormatStruct(T, "$42", ' '); diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 5384e293f..002f85c5f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -61,6 +61,11 @@ pub const Viewer = struct { /// The current session ID we're attached to. session_id: usize, + /// The tmux server version string (e.g., "3.5a"). We capture this + /// on startup because it will allow us to change behavior between + /// versions as necessary. + tmux_version: []const u8, + /// The list of commands we've sent that we want to send and wait /// for a response for. We only send one command at a time just /// to avoid any possible confusion around ordering. @@ -168,6 +173,7 @@ pub const Viewer = struct { // until we receive a session-changed notification which will // set this to a real value. .session_id = 0, + .tmux_version = "", .command_queue = command_queue, .windows = .empty, .panes = .empty, @@ -191,6 +197,9 @@ pub const Viewer = struct { while (it.next()) |kv| kv.value_ptr.deinit(self.alloc); self.panes.deinit(self.alloc); } + if (self.tmux_version.len > 0) { + self.alloc.free(self.tmux_version); + } self.action_arena.promote(self.alloc).deinit(); } @@ -273,9 +282,10 @@ pub const Viewer = struct { var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; _ = arena.reset(.free_all); + return self.enterCommandQueue( arena.allocator(), - .list_windows, + &.{ .tmux_version, .list_windows }, ) catch { log.warn("failed to queue command, becoming defunct", .{}); return self.defunct(); @@ -626,6 +636,9 @@ pub const Viewer = struct { try replacement.queueCommands(&.{.list_windows}); replacement.state = .command_queue; + // Transfer preserved version to replacement + replacement.tmux_version = try replacement.alloc.dupe(u8, self.tmux_version); + // Save arena state back before swap replacement.action_arena = arena.state; @@ -698,9 +711,33 @@ pub const Viewer = struct { cap.id, content, ), + + .tmux_version => try self.receivedTmuxVersion(content), } } + fn receivedTmuxVersion( + self: *Viewer, + content: []const u8, + ) !void { + const line = std.mem.trim(u8, content, " \t\r\n"); + if (line.len == 0) return; + + const data = output.parseFormatStruct( + Format.tmux_version.Struct(), + line, + Format.tmux_version.delim, + ) catch |err| { + log.info("failed to parse tmux version: {s}", .{line}); + return err; + }; + + if (self.tmux_version.len > 0) { + self.alloc.free(self.tmux_version); + } + self.tmux_version = try self.alloc.dupe(u8, data.version); + } + fn receivedListWindows( self: *Viewer, arena_alloc: Allocator, @@ -1031,22 +1068,23 @@ pub const Viewer = struct { } /// Enters the command queue state from any other state, queueing - /// the command and returning an action to execute the first command. + /// the commands and returning an action to execute the first command. fn enterCommandQueue( self: *Viewer, arena_alloc: Allocator, - command: Command, + commands: []const Command, ) Allocator.Error![]const Action { assert(self.state != .command_queue); + assert(commands.len > 0); // Build our command string to send for the action. var builder: std.Io.Writer.Allocating = .init(arena_alloc); - command.formatCommand(&builder.writer) catch return error.OutOfMemory; + commands[0].formatCommand(&builder.writer) catch return error.OutOfMemory; const action: Action = .{ .command = builder.writer.buffered() }; - // Add our command - try self.command_queue.ensureUnusedCapacity(self.alloc, 1); - self.command_queue.appendAssumeCapacity(command); + // Add our commands + try self.command_queue.ensureUnusedCapacity(self.alloc, commands.len); + for (commands) |cmd| self.command_queue.appendAssumeCapacity(cmd); // Move into the command queue state self.state = .command_queue; @@ -1128,6 +1166,9 @@ const Command = union(enum) { /// are part of the output so we can map it back to our panes. pane_state, + /// Get the tmux server version. + tmux_version, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -1143,6 +1184,7 @@ const Command = union(enum) { .pane_history, .pane_visible, .pane_state, + .tmux_version, => {}, .user => |v| alloc.free(v), }; @@ -1196,6 +1238,11 @@ const Command = union(enum) { .{comptime Format.list_panes.comptimeFormat()}, )), + .tmux_version => try writer.writeAll(std.fmt.comptimePrint( + "display-message -p '{s}'\n", + .{comptime Format.tmux_version.comptimeFormat()}, + )), + .user => |v| try writer.writeAll(v), } } @@ -1260,6 +1307,11 @@ const Format = struct { }, }; + const tmux_version: Format = .{ + .delim = ' ', + .vars = &.{.version}, + }; + /// The format string, available at comptime. pub fn comptimeFormat(comptime self: Format) []const u8 { return output.comptimeFormat(self.vars, self.delim); @@ -1378,6 +1430,11 @@ test "session changed resets state" { .id = 1, .name = "first", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive window layout with two panes (same format as "initial flow" test) @@ -1393,10 +1450,11 @@ test "session changed resets state" { try testing.expectEqual(1, v.session_id); try testing.expectEqual(1, v.windows.items.len); try testing.expectEqual(2, v.panes.count()); + try testing.expectEqualStrings("3.5a", v.tmux_version); } }).check, }, - // Now session changes - should reset everything + // Now session changes - should reset everything but keep version .{ .input = .{ .tmux = .{ .session_changed = .{ .id = 2, @@ -1420,6 +1478,8 @@ test "session changed resets state" { try testing.expectEqual(0, v.windows.items.len); // Old panes should be cleared try testing.expectEqual(0, v.panes.count()); + // Version should still be preserved + try testing.expectEqualStrings("3.5a", v.tmux_version); } }).check, }, @@ -1460,13 +1520,23 @@ test "initial flow" { .id = 42, .name = "main", } } }, - .contains_command = "list-windows", + .contains_command = "display-message", .check = (struct { fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { try testing.expectEqual(42, v.session_id); } }).check, }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqualStrings("3.5a", v.tmux_version); + } + }).check, + }, .{ .input = .{ .tmux = .{ .block_end = @@ -1632,6 +1702,11 @@ test "layout change" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1698,6 +1773,11 @@ test "layout_change does not return command when queue not empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1754,6 +1834,11 @@ test "layout_change returns command when queue was empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1816,6 +1901,11 @@ test "window_add queues list_windows when queue empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1872,6 +1962,11 @@ test "window_add queues list_windows when queue not empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1924,13 +2019,18 @@ test "two pane flow with pane state" { .id = 0, .name = "0", } } }, - .contains_command = "list-windows", + .contains_command = "display-message", .check = (struct { fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { try testing.expectEqual(0, v.session_id); } }).check, }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, // list-windows output with 2 panes in a vertical split .{ .input = .{ .tmux = .{ From b3e7c922630398457a301c3fcd2921bdc282e24b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:34:35 -0800 Subject: [PATCH 594/702] fmt --- src/terminal/tmux/control.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index 79ed530ec..dbc64b340 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -555,7 +555,7 @@ pub const Notification = union(enum) { try writer.writeAll(" }"); } } - }; +}; test "tmux begin/end empty" { const testing = std.testing; From 37f467c023e901c9125e940fdc379d1bf36c1d06 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:37:48 -0800 Subject: [PATCH 595/702] terminal/tmux: docs --- src/terminal/tmux/viewer.zig | 104 +++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 002f85c5f..0fcaaf207 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -51,6 +51,110 @@ const COMMAND_QUEUE_INITIAL = 8; /// /// This struct helps move through a state machine of connecting to a tmux /// session, negotiating capabilities, listing window state, etc. +/// +/// ## Viewer Lifecycle +/// +/// The viewer progresses through several states from initial connection +/// to steady-state operation. Here is the full flow: +/// +/// ``` +/// ┌─────────────────────────────────────────────┐ +/// │ TMUX CONTROL MODE START │ +/// │ (DCS 1000p received by host) │ +/// └─────────────────┬───────────────────────────┘ +/// │ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ startup_block │ +/// │ │ +/// │ Wait for initial %begin/%end block from │ +/// │ tmux. This is the response to the initial │ +/// │ command (e.g., "attach -t 0"). │ +/// └─────────────────┬───────────────────────────┘ +/// │ %end / %error +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ startup_session │ +/// │ │ +/// │ Wait for %session-changed notification │ +/// │ to get the initial session ID. │ +/// └─────────────────┬───────────────────────────┘ +/// │ %session-changed +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ command_queue │ +/// │ │ +/// │ Main operating state. Process commands │ +/// │ sequentially and handle notifications. │ +/// └─────────────────────────────────────────────┘ +/// │ +/// ┌───────────────────────────┼───────────────────────────┐ +/// │ │ │ +/// ▼ ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐ +/// │ tmux_version │ │ list_windows │ │ %output / %layout- │ +/// │ │ │ │ │ change / etc. │ +/// │ Query tmux version for │ │ Get all windows in the │ │ │ +/// │ compatibility checks. │ │ current session. │ │ Handle live updates │ +/// └──────────────────────────┘ └────────────┬─────────────┘ │ from tmux server. │ +/// │ └────────────────────────┘ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ syncLayouts │ +/// │ │ +/// │ For each window, parse layout and sync │ +/// │ panes. New panes trigger capture commands. │ +/// └─────────────────┬───────────────────────────┘ +/// │ +/// ┌───────────────────────────┴───────────────────────────┐ +/// │ For each new pane: │ +/// ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ +/// │ pane_history │ │ pane_visible │ +/// │ (primary screen) │ │ (primary screen) │ +/// │ │ │ │ +/// │ Capture scrollback │ │ Capture visible area │ +/// │ history into terminal. │ │ into terminal. │ +/// └──────────────────────────┘ └──────────────────────────┘ +/// │ │ +/// ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ +/// │ pane_history │ │ pane_visible │ +/// │ (alternate screen) │ │ (alternate screen) │ +/// └──────────────────────────┘ └──────────────────────────┘ +/// │ │ +/// └───────────────────────────┬───────────────────────────┘ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ pane_state │ +/// │ │ +/// │ Query cursor position, cursor style, │ +/// │ and alternate screen mode for all panes. │ +/// └─────────────────────────────────────────────┘ +/// │ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ READY FOR OPERATION │ +/// │ │ +/// │ Panes are populated with content. The │ +/// │ viewer handles %output for live updates, │ +/// │ %layout-change for pane changes, and │ +/// │ %session-changed for session switches. │ +/// └─────────────────────────────────────────────┘ +/// ``` +/// +/// ## Error Handling +/// +/// At any point, if an unrecoverable error occurs or tmux sends `%exit`, +/// the viewer transitions to the `defunct` state and emits an `.exit` action. +/// +/// ## Session Changes +/// +/// When `%session-changed` is received during `command_queue` state, the +/// viewer resets itself completely: clears all windows/panes, emits an +/// empty windows action, and restarts the `list_windows` flow for the new +/// session. +/// pub const Viewer = struct { /// Allocator used for all internal state. alloc: Allocator, From 05c704b2471ca43e8c3fa3616121824f5c37c65b Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Mon, 8 Dec 2025 10:50:46 -0600 Subject: [PATCH 596/702] build: skip git version detection when used as dependency Detect if ghostty is being built as a dependency by comparing the build root with ghostty's source directory. When used as a dependency, skip git detection entirely and use the version from build.zig.zon. This fixes build failures when downstream projects have git tags that don't match ghostty's version format. Previously, ghostty would read the downstream project's git tags and panic at Config.zig:246 with "tagged releases must be in vX.Y.Z format matching build.zig". --- build.zig | 6 ++++++ src/build/Config.zig | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/build.zig b/build.zig index 5fd611b6c..472c3957a 100644 --- a/build.zig +++ b/build.zig @@ -2,6 +2,7 @@ const std = @import("std"); const assert = std.debug.assert; const builtin = @import("builtin"); const buildpkg = @import("src/build/main.zig"); + const appVersion = @import("build.zig.zon").version; const minimumZigVersion = @import("build.zig.zon").minimum_zig_version; @@ -317,3 +318,8 @@ pub fn build(b: *std.Build) !void { try translations_step.addError("cannot update translations when i18n is disabled", .{}); } } + +/// Marker used by Config.zig to detect if ghostty is the build root. +/// This avoids running logic such as Git tag checking when Ghostty +/// is used as a dependency. +pub const _ghostty_build_root = true; diff --git a/src/build/Config.zig b/src/build/Config.zig index e88213d71..981cd7de5 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -218,6 +218,22 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { try std.SemanticVersion.parse(v) else version: { const app_version = try std.SemanticVersion.parse(appVersion); + + // Detect if ghostty is being built as a dependency by checking if the + // build root has our marker. When used as a dependency, we skip git + // detection entirely to avoid reading the downstream project's git state. + const is_dependency = !@hasDecl( + @import("root"), + "_ghostty_build_root", + ); + if (is_dependency) { + break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + }; + } + // If no explicit version is given, we try to detect it from git. const vsn = GitVersion.detect(b) catch |err| switch (err) { // If Git isn't available we just make an unknown dev version. From 7642b8bec4294ecdaf9184fd69ed761a7e2aa422 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 13:13:35 -0800 Subject: [PATCH 597/702] build: highway system integration should default to false --- src/build/SharedDeps.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index e530e4885..5e2cd40b9 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -719,7 +719,7 @@ pub fn addSimd( } // Highway - if (b.systemIntegrationOption("highway", .{})) { + if (b.systemIntegrationOption("highway", .{ .default = false })) { m.linkSystemLibrary("libhwy", dynamic_link_opts); } else { if (b.lazyDependency("highway", .{ From 93d77ae43672dd1c8017a63cf93516b9157054fe Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 16 Nov 2025 02:24:10 -0800 Subject: [PATCH 598/702] Always use overlay scroller, flash when mouse moved --- macos/Sources/Ghostty/SurfaceScrollView.swift | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceScrollView.swift b/macos/Sources/Ghostty/SurfaceScrollView.swift index 4e81eda14..157136136 100644 --- a/macos/Sources/Ghostty/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/SurfaceScrollView.swift @@ -34,10 +34,15 @@ class SurfaceScrollView: NSView { scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = false scrollView.usesPredominantAxisScrolling = true + // Always use the overlay style. See mouseMoved for how we make + // it usable without a scroll wheel or gestures. + scrollView.scrollerStyle = .overlay // hide default background to show blur effect properly scrollView.drawsBackground = false - // don't let the content view clip it's subviews, to enable the + // don't let the content view clip its subviews, to enable the // surface to draw the background behind non-overlay scrollers + // (we currently only use overlay scrollers, but might as well + // configure the views correctly in case we change our mind) scrollView.contentView.clipsToBounds = false // The document view is what the scrollview is actually going @@ -107,7 +112,10 @@ class SurfaceScrollView: NSView { observers.append(NotificationCenter.default.addObserver( forName: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil, - queue: .main + // Since this observer is used to immediately override the event + // that produced the notification, we let it run synchronously on + // the posting thread. + queue: nil ) { [weak self] _ in self?.handleScrollerStyleChange() }) @@ -176,10 +184,10 @@ class SurfaceScrollView: NSView { 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) + updateTrackingAreas() } /// Positions the surface view to fill the currently visible rectangle. @@ -240,6 +248,7 @@ class SurfaceScrollView: NSView { /// Handles scrollbar style changes private func handleScrollerStyleChange() { + scrollView.scrollerStyle = .overlay synchronizeCoreSurface() } @@ -350,4 +359,32 @@ class SurfaceScrollView: NSView { } return contentHeight } + + // MARK: Mouse events + + override func mouseMoved(with: NSEvent) { + // When the OS preferred style is .legacy, the user should be able to + // click and drag the scroller without using scroll wheels or gestures, + // so we flash it when the mouse is moved over the scrollbar area. + guard NSScroller.preferredScrollerStyle == .legacy else { return } + scrollView.flashScrollers() + } + + override func updateTrackingAreas() { + // To update our tracking area we just recreate it all. + trackingAreas.forEach { removeTrackingArea($0) } + + super.updateTrackingAreas() + + // Our tracking area is the scroller frame + guard let scroller = scrollView.verticalScroller else { return } + addTrackingArea(NSTrackingArea( + rect: convert(scroller.bounds, from: scroller), + options: [ + .mouseMoved, + .activeInKeyWindow, + ], + owner: self, + userInfo: nil)) + } } From c0951ce6d8887ea81e29b3485dc828d3f9191601 Mon Sep 17 00:00:00 2001 From: Denys Zhak Date: Sat, 6 Dec 2025 20:44:30 +0000 Subject: [PATCH 599/702] macOS: fix tab context menu opens on macOS 26 with titlebar tabs --- .../TitlebarTabsTahoeTerminalWindow.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 7ce138c2a..802e98dc1 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -8,6 +8,9 @@ import SwiftUI class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() + + /// Tb bar view for event routing + private weak var tabBarView: NSView? /// Titlebar tabs can't support the update accessory because of the way we layout /// the native tabs back into the menu bar. @@ -67,6 +70,30 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool viewModel.isMainWindow = false } + + override func sendEvent(_ event: NSEvent) { + guard let tabBarView, viewModel.hasTabBar else { + super.sendEvent(event) + return + } + + let isRightClick = + event.type == .rightMouseDown || + (event.type == .otherMouseDown && event.buttonNumber == 2) + + guard isRightClick else { + super.sendEvent(event) + return + } + let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) + + if tabBarView.bounds.contains(locationInTabBar) { + tabBarView.rightMouseDown(with: event) + } else { + super.sendEvent(event) + } + } + // This is called by macOS for native tabbing in order to add the tab bar. We hook into // this, detect the tab bar being added, and override its behavior. override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { @@ -148,6 +175,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool let tabBar = findTabBar() else { return } + self.tabBarView = tabBar + // View model updates must happen on their own ticks. DispatchQueue.main.async { [weak self] in self?.viewModel.hasTabBar = true @@ -206,6 +235,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Remove the observer so we can call setup again. self.tabBarObserver = nil + self.tabBarView = nil // Wait a tick to let the new tab bars appear and then set them up. DispatchQueue.main.async { @@ -223,6 +253,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Clear our observations self.tabBarObserver = nil + self.tabBarView = nil } // MARK: NSToolbarDelegate From 969bcbe8e308a72aa96a5a8d47c53ffc6708bcb7 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 7 Dec 2025 09:02:03 +0100 Subject: [PATCH 600/702] Update macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift --- .../Window Styles/TitlebarTabsTahoeTerminalWindow.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 802e98dc1..a58b8ba91 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -9,7 +9,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool /// The view model for SwiftUI views private var viewModel = ViewModel() - /// Tb bar view for event routing + /// Tab bar view for event routing private weak var tabBarView: NSView? /// Titlebar tabs can't support the update accessory because of the way we layout From 76c2de6088581c7d634679b67bec8d9f1b90576c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:09:26 -0800 Subject: [PATCH 601/702] macos: remove the tabBarView variable we can search it --- .../TitlebarTabsTahoeTerminalWindow.swift | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index a58b8ba91..5d910d2e0 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -8,9 +8,6 @@ import SwiftUI class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() - - /// Tab bar view for event routing - private weak var tabBarView: NSView? /// Titlebar tabs can't support the update accessory because of the way we layout /// the native tabs back into the menu bar. @@ -71,27 +68,35 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool viewModel.isMainWindow = false } + /// On our Tahoe titlebar tabs, we need to fix up right click events because they don't work + /// naturally due to whatever mess we made. override func sendEvent(_ event: NSEvent) { - guard let tabBarView, viewModel.hasTabBar else { + guard viewModel.hasTabBar else { super.sendEvent(event) return } let isRightClick = event.type == .rightMouseDown || - (event.type == .otherMouseDown && event.buttonNumber == 2) - + (event.type == .otherMouseDown && event.buttonNumber == 2) || + (event.type == .leftMouseDown && event.modifierFlags.contains(.control)) guard isRightClick else { super.sendEvent(event) return } - let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) - - if tabBarView.bounds.contains(locationInTabBar) { - tabBarView.rightMouseDown(with: event) - } else { + + guard let tabBarView = findTabBar() else { super.sendEvent(event) + return } + + let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) + guard tabBarView.bounds.contains(locationInTabBar) else { + super.sendEvent(event) + return + } + + tabBarView.rightMouseDown(with: event) } // This is called by macOS for native tabbing in order to add the tab bar. We hook into @@ -175,8 +180,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool let tabBar = findTabBar() else { return } - self.tabBarView = tabBar - // View model updates must happen on their own ticks. DispatchQueue.main.async { [weak self] in self?.viewModel.hasTabBar = true @@ -235,7 +238,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Remove the observer so we can call setup again. self.tabBarObserver = nil - self.tabBarView = nil // Wait a tick to let the new tab bars appear and then set them up. DispatchQueue.main.async { @@ -253,7 +255,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Clear our observations self.tabBarObserver = nil - self.tabBarView = nil } // MARK: NSToolbarDelegate From 625d7274bf0bcebf17b5cd4ffa853165269489a6 Mon Sep 17 00:00:00 2001 From: George Papadakis Date: Mon, 1 Dec 2025 20:15:53 +0200 Subject: [PATCH 602/702] Add close tabs on the right action --- include/ghostty.h | 1 + .../Terminal/TerminalController.swift | 95 ++++++++++++++++++- .../Window Styles/TerminalWindow.swift | 60 ++++++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 7 ++ macos/Sources/Ghostty/Package.swift | 3 + pkg/apple-sdk/build.zig | 27 ++++++ src/Surface.zig | 1 + src/apprt/action.zig | 2 + src/apprt/gtk/class/tab.zig | 1 + src/apprt/gtk/ui/1.5/window.blp | 30 ++++++ src/input/Binding.zig | 6 +- src/input/command.zig | 5 + 12 files changed, 231 insertions(+), 7 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6cafe8773..cb8646560 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -714,6 +714,7 @@ typedef struct { typedef enum { GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, + GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, } ghostty_action_close_tab_mode_e; // apprt.surface.Message.ChildExited diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5cc2c67f1..1083fb405 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -104,6 +104,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr selector: #selector(onCloseOtherTabs), name: .ghosttyCloseOtherTabs, object: nil) + center.addObserver( + self, + selector: #selector(onCloseTabsOnTheRight), + name: .ghosttyCloseTabsOnTheRight, + object: nil) center.addObserver( self, selector: #selector(onResetWindowSize), @@ -627,6 +632,48 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + private func closeTabsOnTheRightImmediately() { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + if let undoManager { + undoManager.beginUndoGrouping() + } + defer { + undoManager?.endUndoGrouping() + } + + for (_, candidate) in tabsToClose { + if let controller = candidate.windowController as? TerminalController { + controller.closeTabImmediately(registerRedo: false) + } + } + + if let undoManager { + undoManager.setActionName("Close Tabs on the Right") + + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration + ) { target in + DispatchQueue.main.async { + target.window?.makeKeyAndOrderFront(nil) + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration + ) { target in + target.closeTabsOnTheRightImmediately() + } + } + } + } + /// Closes the current window (including any other tabs) immediately and without /// confirmation. This will setup proper undo state so the action can be undone. private func closeWindowImmediately() { @@ -1078,24 +1125,24 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // If we only have one window then we have no other tabs to close guard tabGroup.windows.count > 1 else { return } - + // Check if we have to confirm close. guard tabGroup.windows.contains(where: { window in // Ignore ourself if window == self.window { return false } - + // Ignore non-terminals guard let controller = window.windowController as? TerminalController else { return false } - + // Check if any surfaces require confirmation return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) }) else { self.closeOtherTabsImmediately() return } - + confirmClose( messageText: "Close Other Tabs?", informativeText: "At least one other tab still has a running process. If you close the tab the process will be killed." @@ -1104,6 +1151,35 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + @IBAction func closeTabsOnTheRight(_ sender: Any?) { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + let needsConfirm = tabsToClose.contains { (_, candidate) in + guard let controller = candidate.windowController as? TerminalController else { + return false + } + + return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) + } + + if !needsConfirm { + self.closeTabsOnTheRightImmediately() + return + } + + confirmClose( + messageText: "Close Tabs on the Right?", + informativeText: "At least one tab to the right still has a running process. If you close the tab the process will be killed." + ) { + self.closeTabsOnTheRightImmediately() + } + } + @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) @@ -1305,6 +1381,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeOtherTabs(self) } + @objc private func onCloseTabsOnTheRight(notification: SwiftUI.Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.contains(target) else { return } + closeTabsOnTheRight(self) + } + @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } @@ -1367,6 +1449,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr extension TerminalController { override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { + case #selector(closeTabsOnTheRight): + guard let window, let tabGroup = window.tabGroup else { return false } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } + return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } + case #selector(returnToDefaultSize): guard let window else { return false } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 2208d99cf..cbbbf99f7 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -26,6 +26,8 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() + + private var tabMenuObserver: NSObjectProtocol? = nil /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -53,6 +55,15 @@ class TerminalWindow: NSWindow { override func awakeFromNib() { // Notify that this terminal window has loaded NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + + tabMenuObserver = NotificationCenter.default.addObserver( + forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"), + object: nil, + queue: .main + ) { [weak self] note in + guard let self, let menu = note.object as? NSMenu else { return } + self.configureTabContextMenuIfNeeded(menu) + } // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then @@ -202,6 +213,8 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + func findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. // @@ -277,6 +290,47 @@ class TerminalWindow: NSWindow { } } + private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { + guard isTabContextMenu(menu) else { return } + if let existing = menu.items.first(where: { $0.identifier == Self.closeTabsOnRightMenuItemIdentifier }) { + menu.removeItem(existing) + } + guard let terminalController else { return } + + let title = NSLocalizedString("Close Tabs on the Right", comment: "Tab context menu option") + let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = terminalController + item.isEnabled = true + + let closeOtherIndex = menu.items.firstIndex(where: { menuItem in + guard let action = menuItem.action else { return false } + let name = NSStringFromSelector(action).lowercased() + return name.contains("close") && name.contains("other") && name.contains("tab") + }) + + let closeThisIndex = menu.items.firstIndex(where: { menuItem in + guard let action = menuItem.action else { return false } + let name = NSStringFromSelector(action).lowercased() + return name.contains("close") && name.contains("tab") + }) + + if let idx = closeOtherIndex { + menu.insertItem(item, at: idx + 1) + } else if let idx = closeThisIndex { + menu.insertItem(item, at: idx + 1) + } else { + menu.addItem(item) + } + } + + private func isTabContextMenu(_ menu: NSMenu) -> Bool { + guard NSApp.keyWindow === self else { return false } + let selectorNames = menu.items.compactMap { $0.action }.map { NSStringFromSelector($0).lowercased() } + return selectorNames.contains { $0.contains("close") && $0.contains("tab") } + } + + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -517,6 +571,12 @@ class TerminalWindow: NSWindow { standardWindowButton(.miniaturizeButton)?.isHidden = true standardWindowButton(.zoomButton)?.isHidden = true } + + deinit { + if let observer = tabMenuObserver { + NotificationCenter.default.removeObserver(observer) + } + } // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 39ebbb51f..f6452e54e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -861,6 +861,13 @@ extension Ghostty { ) return + case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT: + NotificationCenter.default.post( + name: .ghosttyCloseTabsOnTheRight, + object: surfaceView + ) + return + default: assertionFailure() } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 7ee815caa..4b3eb60aa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -380,6 +380,9 @@ extension Notification.Name { /// Close other tabs static let ghosttyCloseOtherTabs = Notification.Name("com.mitchellh.ghostty.closeOtherTabs") + /// Close tabs to the right of the focused tab + static let ghosttyCloseTabsOnTheRight = Notification.Name("com.mitchellh.ghostty.closeTabsOnTheRight") + /// Close window static let ghosttyCloseWindow = Notification.Name("com.mitchellh.ghostty.closeWindow") diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index c573c3910..32cb726fd 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -30,6 +30,7 @@ pub fn addPaths( framework: []const u8, system_include: []const u8, library: []const u8, + cxx_include: []const u8, }) = .{}; }; @@ -82,11 +83,36 @@ pub fn addPaths( }); }; + const cxx_include_path = cxx: { + const preferred = try std.fs.path.join(b.allocator, &.{ + libc.sys_include_dir.?, + "c++", + "v1", + }); + if (std.fs.accessAbsolute(preferred, .{})) |_| { + break :cxx preferred; + } else |_| {} + + const sdk_root = std.fs.path.dirname(libc.sys_include_dir.?).?; + const fallback = try std.fs.path.join(b.allocator, &.{ + sdk_root, + "include", + "c++", + "v1", + }); + if (std.fs.accessAbsolute(fallback, .{})) |_| { + break :cxx fallback; + } else |_| {} + + break :cxx preferred; + }; + gop.value_ptr.* = .{ .libc = path, .framework = framework_path, .system_include = libc.sys_include_dir.?, .library = library_path, + .cxx_include = cxx_include_path, }; } @@ -107,5 +133,6 @@ pub fn addPaths( // https://github.com/ziglang/zig/issues/24024 step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework }); step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include }); + step.root_module.addSystemIncludePath(.{ .cwd_relative = value.cxx_include }); step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/src/Surface.zig b/src/Surface.zig index 653178bdc..9e7ad0b97 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5299,6 +5299,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool switch (v) { .this => .this, .other => .other, + .right => .right, }, ), diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 00bf8685a..365f525f8 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -767,6 +767,8 @@ pub const CloseTabMode = enum(c_int) { this, /// Close all other tabs. other, + /// Close all tabs to the right of the current tab. + right, }; pub const CommandFinished = struct { diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index c8b5607a6..fb3b8b0ef 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -347,6 +347,7 @@ pub const Tab = extern struct { switch (mode) { .this => tab_view.closePage(page), .other => tab_view.closeOtherPages(page), + .right => tab_view.closePagesAfter(page), } } diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index 8c0a7bedb..de06b04da 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -162,6 +162,7 @@ template $GhosttyWindow: Adw.ApplicationWindow { page-attached => $page_attached(); page-detached => $page_detached(); create-window => $tab_create_window(); + menu-model: tab_context_menu; shortcuts: none; } } @@ -192,6 +193,35 @@ menu split_menu { } } +menu tab_context_menu { + section { + item { + label: _("New Tab"); + action: "win.new-tab"; + } + } + + section { + item { + label: _("Close Tab"); + action: "tab.close"; + target: "this"; + } + + item { + label: _("Close Other Tabs"); + action: "tab.close"; + target: "other"; + } + + item { + label: _("Close Tabs on the Right"); + action: "tab.close"; + target: "right"; + } + } +} + menu main_menu { section { item { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 1e7db3592..66fe03651 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -600,9 +600,8 @@ pub const Action = union(enum) { /// of the `confirm-close-surface` configuration setting. close_surface, - /// Close the current tab and all splits therein _or_ close all tabs and - /// splits thein of tabs _other_ than the current tab, depending on the - /// mode. + /// Close the current tab and all splits therein, close all other tabs, or + /// close every tab to the right of the current one depending on the mode. /// /// If the mode is not specified, defaults to closing the current tab. /// @@ -1005,6 +1004,7 @@ pub const Action = union(enum) { pub const CloseTabMode = enum { this, other, + right, pub const default: CloseTabMode = .this; }; diff --git a/src/input/command.zig b/src/input/command.zig index 72fb7f4ee..6baeca23b 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -538,6 +538,11 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Close Other Tabs", .description = "Close all tabs in this window except the current one.", }, + .{ + .action = .{ .close_tab = .right }, + .title = "Close Tabs on the Right", + .description = "Close every tab to the right of the current one.", + }, }, .close_window => comptime &.{.{ From cca10f3ca8b701c9c34bbcd1fc918e0de3e004e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:17:25 -0800 Subject: [PATCH 603/702] Revert GTK UI changes, apple-sdk build stuff --- pkg/apple-sdk/build.zig | 27 --------------------------- src/apprt/gtk/ui/1.5/window.blp | 30 ------------------------------ src/input/command.zig | 2 +- 3 files changed, 1 insertion(+), 58 deletions(-) diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index 32cb726fd..c573c3910 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -30,7 +30,6 @@ pub fn addPaths( framework: []const u8, system_include: []const u8, library: []const u8, - cxx_include: []const u8, }) = .{}; }; @@ -83,36 +82,11 @@ pub fn addPaths( }); }; - const cxx_include_path = cxx: { - const preferred = try std.fs.path.join(b.allocator, &.{ - libc.sys_include_dir.?, - "c++", - "v1", - }); - if (std.fs.accessAbsolute(preferred, .{})) |_| { - break :cxx preferred; - } else |_| {} - - const sdk_root = std.fs.path.dirname(libc.sys_include_dir.?).?; - const fallback = try std.fs.path.join(b.allocator, &.{ - sdk_root, - "include", - "c++", - "v1", - }); - if (std.fs.accessAbsolute(fallback, .{})) |_| { - break :cxx fallback; - } else |_| {} - - break :cxx preferred; - }; - gop.value_ptr.* = .{ .libc = path, .framework = framework_path, .system_include = libc.sys_include_dir.?, .library = library_path, - .cxx_include = cxx_include_path, }; } @@ -133,6 +107,5 @@ pub fn addPaths( // https://github.com/ziglang/zig/issues/24024 step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework }); step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include }); - step.root_module.addSystemIncludePath(.{ .cwd_relative = value.cxx_include }); step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index de06b04da..8c0a7bedb 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -162,7 +162,6 @@ template $GhosttyWindow: Adw.ApplicationWindow { page-attached => $page_attached(); page-detached => $page_detached(); create-window => $tab_create_window(); - menu-model: tab_context_menu; shortcuts: none; } } @@ -193,35 +192,6 @@ menu split_menu { } } -menu tab_context_menu { - section { - item { - label: _("New Tab"); - action: "win.new-tab"; - } - } - - section { - item { - label: _("Close Tab"); - action: "tab.close"; - target: "this"; - } - - item { - label: _("Close Other Tabs"); - action: "tab.close"; - target: "other"; - } - - item { - label: _("Close Tabs on the Right"); - action: "tab.close"; - target: "right"; - } - } -} - menu main_menu { section { item { diff --git a/src/input/command.zig b/src/input/command.zig index 6baeca23b..4cbe9ffc4 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -540,7 +540,7 @@ fn actionCommands(action: Action.Key) []const Command { }, .{ .action = .{ .close_tab = .right }, - .title = "Close Tabs on the Right", + .title = "Close Tabs to the Right", .description = "Close every tab to the right of the current one.", }, }, From 10bac6a5dd94f072bcb4d95cd956d0516b50ff7b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 10 Dec 2025 22:26:40 -0600 Subject: [PATCH 604/702] benchmark: use newer bytes api to generate ascii --- src/synthetic/cli/Ascii.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index 22ca1ffb5..d416189ce 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -36,10 +36,12 @@ pub fn run(_: *Ascii, writer: *std.Io.Writer, rand: std.Random) !void { var gen: Bytes = .{ .rand = rand, .alphabet = ascii, + .min_len = 1024, + .max_len = 1024, }; while (true) { - gen.next(writer, 1024) catch |err| { + _ = gen.write(writer) catch |err| { const Error = error{ WriteFailed, BrokenPipe } || @TypeOf(err); switch (@as(Error, err)) { error.BrokenPipe => return, // stdout closed From 4424451c59eb16189054b1787b247e762fe74c4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:24:54 -0800 Subject: [PATCH 605/702] macos: remove to "close to the right" --- macos/Sources/Features/Terminal/TerminalController.swift | 6 ++---- .../Features/Terminal/Window Styles/TerminalWindow.swift | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 1083fb405..a275c3f39 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -640,9 +640,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } guard !tabsToClose.isEmpty else { return } - if let undoManager { - undoManager.beginUndoGrouping() - } + undoManager?.beginUndoGrouping() defer { undoManager?.endUndoGrouping() } @@ -654,7 +652,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } if let undoManager { - undoManager.setActionName("Close Tabs on the Right") + undoManager.setActionName("Close Tabs to the Right") undoManager.registerUndo( withTarget: self, diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index cbbbf99f7..1f9f10502 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -297,7 +297,7 @@ class TerminalWindow: NSWindow { } guard let terminalController else { return } - let title = NSLocalizedString("Close Tabs on the Right", comment: "Tab context menu option") + let title = NSLocalizedString("Close Tabs to the Right", comment: "Tab context menu option") let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = terminalController From cfdcd50e184240e48fe6b6d9e0bd6ed0afb3ae46 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 10 Dec 2025 22:30:19 -0600 Subject: [PATCH 606/702] benchmark: generate more types of OSC sequences --- src/os/string_encoding.zig | 13 ++++ src/synthetic/Osc.zig | 123 +++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/os/string_encoding.zig b/src/os/string_encoding.zig index 162023ad2..042001ea7 100644 --- a/src/os/string_encoding.zig +++ b/src/os/string_encoding.zig @@ -265,3 +265,16 @@ test "percent 7" { @memcpy(&src, s); try std.testing.expectError(error.DecodeError, urlPercentDecode(&src)); } + +/// Is the given character valid in URI percent encoding? +fn isValidChar(c: u8) bool { + return switch (c) { + ' ', ';', '=' => false, + else => return std.ascii.isPrint(c), + }; +} + +/// Write data to the writer after URI percent encoding. +pub fn urlPercentEncode(writer: *std.Io.Writer, data: []const u8) std.Io.Writer.Error!void { + try std.Uri.Component.percentEncode(writer, data, isValidChar); +} diff --git a/src/synthetic/Osc.zig b/src/synthetic/Osc.zig index b43079e1a..00de43f7f 100644 --- a/src/synthetic/Osc.zig +++ b/src/synthetic/Osc.zig @@ -5,12 +5,23 @@ const std = @import("std"); const assert = std.debug.assert; const Generator = @import("Generator.zig"); const Bytes = @import("Bytes.zig"); +const urlPercentEncode = @import("../os/string_encoding.zig").urlPercentEncode; /// Valid OSC request kinds that can be generated. pub const ValidKind = enum { change_window_title, prompt_start, prompt_end, + end_of_input, + end_of_command, + rxvt_notify, + mouse_shape, + clipboard_operation, + report_pwd, + hyperlink_start, + hyperlink_end, + conemu_progress, + iterm2_notification, }; /// Invalid OSC request kinds that can be generated. @@ -55,6 +66,9 @@ fn checkOscAlphabet(c: u8) bool { /// The alphabet for random bytes in OSCs (omitting 0x1B and 0x07). pub const osc_alphabet = Bytes.generateAlphabet(checkOscAlphabet); +pub const ascii_alphabet = Bytes.generateAlphabet(std.ascii.isPrint); +pub const alphabetic_alphabet = Bytes.generateAlphabet(std.ascii.isAlphabetic); +pub const alphanumeric_alphabet = Bytes.generateAlphabet(std.ascii.isAlphanumeric); pub fn generator(self: *Osc) Generator { return .init(self, next); @@ -143,6 +157,115 @@ fn nextUnwrappedValidExact(self: *const Osc, writer: *std.Io.Writer, k: ValidKin if (max_len < 4) break :prompt_end; try writer.writeAll("133;B"); // End prompt }, + + .end_of_input => end_of_input: { + if (max_len < 5) break :end_of_input; + var remaining = max_len; + try writer.writeAll("133;C"); // End prompt + remaining -= 5; + if (self.rand.boolean()) cmdline: { + const prefix = ";cmdline_url="; + if (remaining < prefix.len + 1) break :cmdline; + try writer.writeAll(prefix); + remaining -= prefix.len; + var buf: [128]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try self.bytes().newAlphabet(ascii_alphabet).atMost(@min(remaining, buf.len)).format(&w); + try urlPercentEncode(writer, w.buffered()); + remaining -= w.buffered().len; + } + }, + + .end_of_command => end_of_command: { + if (max_len < 4) break :end_of_command; + try writer.writeAll("133;D"); // End prompt + if (self.rand.boolean()) exit_code: { + if (max_len < 7) break :exit_code; + try writer.print(";{d}", .{self.rand.int(u8)}); + } + }, + + .mouse_shape => mouse_shape: { + if (max_len < 4) break :mouse_shape; + try writer.print("22;{f}", .{self.bytes().newAlphabet(alphabetic_alphabet).atMost(@min(32, max_len - 3))}); // Start prompt + }, + + .rxvt_notify => rxvt_notify: { + const prefix = "777;notify;"; + if (max_len < prefix.len) break :rxvt_notify; + var remaining = max_len; + try writer.writeAll(prefix); + remaining -= prefix.len; + remaining -= try self.bytes().newAlphabet(kv_alphabet).atMost(@min(remaining - 2, 32)).write(writer); + try writer.writeByte(';'); + remaining -= 1; + remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer); + }, + + .clipboard_operation => { + try writer.writeAll("52;"); + var remaining = max_len - 3; + if (self.rand.boolean()) { + remaining -= try self.bytes().newAlphabet(alphabetic_alphabet).atMost(1).write(writer); + } + try writer.writeByte(';'); + remaining -= 1; + if (self.rand.boolean()) { + remaining -= try self.bytes().newAlphabet(osc_alphabet).atMost(remaining).write(writer); + } + }, + + .report_pwd => report_pwd: { + const prefix = "7;file://localhost"; + if (max_len < prefix.len) break :report_pwd; + var remaining = max_len; + try writer.writeAll(prefix); + remaining -= prefix.len; + for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| { + try writer.writeByte('/'); + remaining -= 1; + remaining -= try self.bytes().newAlphabet(alphanumeric_alphabet).atMost(@min(16, remaining)).write(writer); + } + }, + + .hyperlink_start => { + try writer.writeAll("8;"); + if (self.rand.boolean()) { + try writer.print("id={f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)}); + } + try writer.writeAll(";https://localhost"); + for (0..self.rand.intRangeAtMost(usize, 2, 5)) |_| { + try writer.print("/{f}", .{self.bytes().newAlphabet(alphanumeric_alphabet).atMost(16)}); + } + }, + + .hyperlink_end => hyperlink_end: { + if (max_len < 3) break :hyperlink_end; + try writer.writeAll("8;;"); + }, + + .conemu_progress => { + try writer.writeAll("9;"); + switch (self.rand.intRangeAtMost(u3, 0, 4)) { + 0, 3 => |c| { + try writer.print(";{d}", .{c}); + }, + 1, 2, 4 => |c| { + if (self.rand.boolean()) { + try writer.print(";{d}", .{c}); + } else { + try writer.print(";{d};{d}", .{ c, self.rand.intRangeAtMost(u8, 0, 100) }); + } + }, + else => unreachable, + } + }, + + .iterm2_notification => iterm2_notification: { + if (max_len < 3) break :iterm2_notification; + // add a prefix to ensure that this is not interpreted as a ConEmu OSC + try writer.print("9;_{f}", .{self.bytes().newAlphabet(ascii_alphabet).atMost(max_len - 3)}); + }, } } From 01a75ceec4e7619345cb5f1031b98626bbe85f3d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 10 Dec 2025 22:31:27 -0600 Subject: [PATCH 607/702] benchmark: add option to microbenchmark OSC parser --- src/benchmark/OscParser.zig | 118 ++++++++++++++++++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/synthetic/cli/Osc.zig | 26 +++++++- 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/benchmark/OscParser.zig diff --git a/src/benchmark/OscParser.zig b/src/benchmark/OscParser.zig new file mode 100644 index 000000000..6243aba7d --- /dev/null +++ b/src/benchmark/OscParser.zig @@ -0,0 +1,118 @@ +//! This benchmark tests the throughput of the OSC parser. +const OscParser = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Parser = @import("../terminal/osc.zig").Parser; +const log = std.log.scoped(.@"osc-parser-bench"); + +opts: Options, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +parser: Parser, + +pub const Options = struct { + /// 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. + data: ?[]const u8 = null, +}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*OscParser { + const ptr = try alloc.create(OscParser); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .opts = opts, + .data_f = null, + .parser = .init(alloc), + }; + return ptr; +} + +pub fn destroy(self: *OscParser, alloc: Allocator) void { + self.parser.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *OscParser) Benchmark { + return .init(self, .{ + .stepFn = step, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; + self.parser.reset(); +} + +fn teardown(ptr: *anyopaque) void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn step(ptr: *anyopaque) Benchmark.Error!void { + const self: *OscParser = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var r = f.reader(&read_buf); + + var osc_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + while (true) { + r.interface.fill(@bitSizeOf(usize) / 8) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + const len = r.interface.takeInt(usize, .little) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + + if (len > osc_buf.len) return error.BenchmarkFailed; + + r.interface.readSliceAll(osc_buf[0..len]) catch |err| switch (err) { + error.EndOfStream => return, + error.ReadFailed => return error.BenchmarkFailed, + }; + + for (osc_buf[0..len]) |c| self.parser.next(c); + _ = self.parser.end(std.ascii.control_code.bel); + self.parser.reset(); + } +} + +test OscParser { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *OscParser = 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 816ecd3f6..13f070774 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -12,6 +12,7 @@ pub const Action = enum { @"terminal-parser", @"terminal-stream", @"is-symbol", + @"osc-parser", /// Returns the struct associated with the action. The struct /// should have a few decls: @@ -29,6 +30,7 @@ pub const Action = enum { .@"grapheme-break" => @import("GraphemeBreak.zig"), .@"terminal-parser" => @import("TerminalParser.zig"), .@"is-symbol" => @import("IsSymbol.zig"), + .@"osc-parser" => @import("OscParser.zig"), }; } }; diff --git a/src/synthetic/cli/Osc.zig b/src/synthetic/cli/Osc.zig index 8250b81de..686563fc3 100644 --- a/src/synthetic/cli/Osc.zig +++ b/src/synthetic/cli/Osc.zig @@ -10,6 +10,14 @@ const log = std.log.scoped(.@"terminal-stream-bench"); pub const Options = struct { /// Probability of generating a valid value. @"p-valid": f64 = 0.5, + + style: enum { + /// Write all OSC data, including ESC ] and ST for end-to-end tests + streaming, + /// Only write data, prefixed with a length, used for testing just the + /// OSC parser. + parser, + } = .streaming, }; opts: Options, @@ -40,9 +48,21 @@ pub fn run(self: *Osc, writer: *std.Io.Writer, rand: std.Random) !void { var fixed: std.Io.Writer = .fixed(&buf); try gen.next(&fixed, buf.len); const data = fixed.buffered(); - writer.writeAll(data) catch |err| switch (err) { - error.WriteFailed => return, - }; + switch (self.opts.style) { + .streaming => { + writer.writeAll(data) catch |err| switch (err) { + error.WriteFailed => return, + }; + }, + .parser => { + writer.writeInt(usize, data.len - 3, .little) catch |err| switch (err) { + error.WriteFailed => return, + }; + writer.writeAll(data[2 .. data.len - 1]) catch |err| switch (err) { + error.WriteFailed => return, + }; + }, + } } } From f612e4632cc84ebad71c266c704f0d5bcfc1f829 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:43:38 -0800 Subject: [PATCH 608/702] macos: clean up some style on tab bar context menu configuring --- .../Window Styles/TerminalWindow.swift | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 1f9f10502..0ae4c3b02 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -56,12 +56,14 @@ class TerminalWindow: NSWindow { // Notify that this terminal window has loaded NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + // This is fragile, but there doesn't seem to be an official API for customizing + // native tab bar menus. tabMenuObserver = NotificationCenter.default.addObserver( forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"), object: nil, queue: .main - ) { [weak self] note in - guard let self, let menu = note.object as? NSMenu else { return } + ) { [weak self] n in + guard let self, let menu = n.object as? NSMenu else { return } self.configureTabContextMenuIfNeeded(menu) } @@ -292,32 +294,26 @@ class TerminalWindow: NSWindow { private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { guard isTabContextMenu(menu) else { return } - if let existing = menu.items.first(where: { $0.identifier == Self.closeTabsOnRightMenuItemIdentifier }) { + + let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = nil + item.isEnabled = true + + // Remove any previously configured items, because the menu is + // cached across different tab targets. + if let existing = menu.items.first(where: { $0.identifier == item.identifier }) { menu.removeItem(existing) } - guard let terminalController else { return } - let title = NSLocalizedString("Close Tabs to the Right", comment: "Tab context menu option") - let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") - item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = terminalController - item.isEnabled = true - - let closeOtherIndex = menu.items.firstIndex(where: { menuItem in - guard let action = menuItem.action else { return false } - let name = NSStringFromSelector(action).lowercased() - return name.contains("close") && name.contains("other") && name.contains("tab") - }) - - let closeThisIndex = menu.items.firstIndex(where: { menuItem in - guard let action = menuItem.action else { return false } - let name = NSStringFromSelector(action).lowercased() - return name.contains("close") && name.contains("tab") - }) - - if let idx = closeOtherIndex { + // Insert it wherever we can + if let idx = menu.items.firstIndex(where: { + $0.action == NSSelectorFromString("performCloseOtherTabs:") + }) { menu.insertItem(item, at: idx + 1) - } else if let idx = closeThisIndex { + } else if let idx = menu.items.firstIndex(where: { + $0.action == NSSelectorFromString("performClose:") + }) { menu.insertItem(item, at: idx + 1) } else { menu.addItem(item) @@ -326,8 +322,17 @@ class TerminalWindow: NSWindow { private func isTabContextMenu(_ menu: NSMenu) -> Bool { guard NSApp.keyWindow === self else { return false } - let selectorNames = menu.items.compactMap { $0.action }.map { NSStringFromSelector($0).lowercased() } - return selectorNames.contains { $0.contains("close") && $0.contains("tab") } + + // These are the target selectors, at least for macOS 26. + let tabContextSelectors: Set = [ + "performClose:", + "performCloseOtherTabs:", + "moveTabToNewWindow:", + "toggleTabOverview:" + ] + + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) + return !selectorNames.isDisjoint(with: tabContextSelectors) } From dc641c7861c44b8ecdfb8a3747d99c8bc5360e41 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:47:15 -0800 Subject: [PATCH 609/702] macos: change to NSMenu extension --- .../Window Styles/TerminalWindow.swift | 19 ++---------- .../Helpers/Extensions/NSMenu+Extension.swift | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/NSMenu+Extension.swift diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 0ae4c3b02..997996e3b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -299,23 +299,8 @@ class TerminalWindow: NSWindow { item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = nil item.isEnabled = true - - // Remove any previously configured items, because the menu is - // cached across different tab targets. - if let existing = menu.items.first(where: { $0.identifier == item.identifier }) { - menu.removeItem(existing) - } - - // Insert it wherever we can - if let idx = menu.items.firstIndex(where: { - $0.action == NSSelectorFromString("performCloseOtherTabs:") - }) { - menu.insertItem(item, at: idx + 1) - } else if let idx = menu.items.firstIndex(where: { - $0.action == NSSelectorFromString("performClose:") - }) { - menu.insertItem(item, at: idx + 1) - } else { + if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && + !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) } } diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift new file mode 100644 index 000000000..7ddfa419f --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -0,0 +1,29 @@ +import AppKit + +extension NSMenu { + /// Inserts a menu item after an existing item with the specified action selector. + /// + /// If an item with the same identifier already exists, it is removed first to avoid duplicates. + /// This is useful when menus are cached and reused across different targets. + /// + /// - Parameters: + /// - item: The menu item to insert. + /// - action: The action selector to search for. The new item will be inserted after the first + /// item with this action. + /// - Returns: `true` if the item was inserted after the specified action, `false` if the action + /// was not found and the item was not inserted. + @discardableResult + func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool { + if let identifier = item.identifier, + let existing = items.first(where: { $0.identifier == identifier }) { + removeItem(existing) + } + + guard let idx = items.firstIndex(where: { $0.action == action }) else { + return false + } + + insertItem(item, at: idx + 1) + return true + } +} From 1387dbefad18809a95a5eaacb7a3f223891d0e9c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:50:26 -0800 Subject: [PATCH 610/702] macos: target should be the correct target --- .../Terminal/Window Styles/TerminalWindow.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 997996e3b..b8c9d4c7d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -294,11 +294,19 @@ class TerminalWindow: NSWindow { private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { guard isTabContextMenu(menu) else { return } + + // Get the target from an existing menu item. The native tab context menu items + // target the specific window/controller that was right-clicked, not the focused one. + // We need to use that same target so validation and action use the correct tab. + let targetController = menu.items + .first { $0.action == NSSelectorFromString("performClose:") } + .flatMap { $0.target as? NSWindow } + .flatMap { $0.windowController as? TerminalController } let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = nil - item.isEnabled = true + item.target = targetController + if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) From eb75d48e6b59f32cbad65ee7233586aa84940541 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:56:07 -0800 Subject: [PATCH 611/702] macos: add xmark to other tab close items --- .../Terminal/Window Styles/TerminalWindow.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index b8c9d4c7d..77ee98cb4 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -303,14 +303,23 @@ class TerminalWindow: NSWindow { .flatMap { $0.target as? NSWindow } .flatMap { $0.windowController as? TerminalController } + // Close tabs to the right let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = targetController - + item.setImageIfDesired(systemSymbolName: "xmark") if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) } + + // Other close items should have the xmark to match Safari on macOS 26 + for menuItem in menu.items { + if menuItem.action == NSSelectorFromString("performClose:") || + menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { + menuItem.setImageIfDesired(systemSymbolName: "xmark") + } + } } private func isTabContextMenu(_ menu: NSMenu) -> Bool { From 3352d5f0810200e74b1bd537f6c70a3a3018e957 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:57:36 -0800 Subject: [PATCH 612/702] Fix up close right description --- src/input/command.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/command.zig b/src/input/command.zig index 4cbe9ffc4..b3f9e86b6 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -541,7 +541,7 @@ fn actionCommands(action: Action.Key) []const Command { .{ .action = .{ .close_tab = .right }, .title = "Close Tabs to the Right", - .description = "Close every tab to the right of the current one.", + .description = "Close all tabs to the right of the current one.", }, }, From 4a6d551941c5c8000e0f0921dbc5af37ee119da3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 21:20:38 -0800 Subject: [PATCH 613/702] macos: don't put NSMenu extension in iOS build --- macos/Ghostty.xcodeproj/project.pbxproj | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index ca420afaa..b70eb131b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -156,6 +156,7 @@ "Helpers/Extensions/NSAppearance+Extension.swift", "Helpers/Extensions/NSApplication+Extension.swift", "Helpers/Extensions/NSImage+Extension.swift", + "Helpers/Extensions/NSMenu+Extension.swift", "Helpers/Extensions/NSMenuItem+Extension.swift", "Helpers/Extensions/NSPasteboard+Extension.swift", "Helpers/Extensions/NSScreen+Extension.swift", @@ -876,7 +877,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GK79KXBF4F; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -915,7 +916,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GK79KXBF4F; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -954,7 +955,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = GK79KXBF4F; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From 669733d59775f013066e573aa7c88da3c4bc2f34 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 21:21:03 -0800 Subject: [PATCH 614/702] macos: remove iOS signing (dev team) --- macos/Ghostty.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b70eb131b..31e812f0c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -877,7 +877,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = GK79KXBF4F; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -916,7 +916,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = GK79KXBF4F; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -955,7 +955,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = GK79KXBF4F; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From f96aca7a3f96e057e72e8745446f4d1dbd5820e3 Mon Sep 17 00:00:00 2001 From: "Felipe M.B." Date: Thu, 11 Dec 2025 04:10:03 -0300 Subject: [PATCH 615/702] Fix typo in po/README_CONTRIB Change translable to translatable. --- po/README_CONTRIBUTORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/po/README_CONTRIBUTORS.md b/po/README_CONTRIBUTORS.md index 2c405acf3..e232c0620 100644 --- a/po/README_CONTRIBUTORS.md +++ b/po/README_CONTRIBUTORS.md @@ -9,7 +9,7 @@ for any localization that they may add. ## GTK -In the GTK app runtime, translable strings are mainly sourced from Blueprint +In the GTK app runtime, translatable strings are mainly sourced from Blueprint files (located under `src/apprt/gtk/ui`). Blueprints have a native syntax for translatable strings, which look like this: From 3b2f551dc0ba282c42eaf45448e019c970e83c08 Mon Sep 17 00:00:00 2001 From: "Felipe M.B." Date: Thu, 11 Dec 2025 07:49:07 -0300 Subject: [PATCH 616/702] Fix typo in po/README_TRANS via the its -> via its --- po/README_TRANSLATORS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/po/README_TRANSLATORS.md b/po/README_TRANSLATORS.md index 582d5037c..25b7cab5b 100644 --- a/po/README_TRANSLATORS.md +++ b/po/README_TRANSLATORS.md @@ -44,9 +44,9 @@ intended to be regenerated by code contributors. If there is a problem with the template file, please reach out to a code contributor. Instead, only edit the translation file corresponding to your language/locale, -identified via the its _locale name_: for example, `de_DE.UTF-8.po` would be -the translation file for German (language code `de`) as spoken in Germany -(country code `DE`). The GNU `gettext` manual contains +identified via its _locale name_: for example, `de_DE.UTF-8.po` would be the +translation file for German (language code `de`) as spoken in Germany (country +code `DE`). The GNU `gettext` manual contains [further information about locale names](https://www.gnu.org/software/gettext/manual/gettext.html#Locale-Names-1), including a list of language and country codes. From 0d8c193bda92347a9e7273728f93a084e2934292 Mon Sep 17 00:00:00 2001 From: benodiwal Date: Thu, 11 Dec 2025 16:43:16 +0530 Subject: [PATCH 617/702] fix(terminal): prevent integer overflow in hash_map layoutForCapacity Co-Authored-By: Sachin --- src/terminal/hash_map.zig | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index 23b10950e..989302df2 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -856,13 +856,17 @@ fn HashMapUnmanaged( pub fn layoutForCapacity(new_capacity: Size) Layout { assert(new_capacity == 0 or std.math.isPowerOfTwo(new_capacity)); + // Cast to usize to prevent overflow in size calculations. + // See: https://github.com/ziglang/zig/pull/19048 + const cap: usize = new_capacity; + // Pack our metadata, keys, and values. const meta_start = @sizeOf(Header); - const meta_end = @sizeOf(Header) + new_capacity * @sizeOf(Metadata); + const meta_end = @sizeOf(Header) + cap * @sizeOf(Metadata); const keys_start = std.mem.alignForward(usize, meta_end, key_align); - const keys_end = keys_start + new_capacity * @sizeOf(K); + const keys_end = keys_start + cap * @sizeOf(K); const vals_start = std.mem.alignForward(usize, keys_end, val_align); - const vals_end = vals_start + new_capacity * @sizeOf(V); + const vals_end = vals_start + cap * @sizeOf(V); // Our total memory size required is the end of our values // aligned to the base required alignment. @@ -1512,3 +1516,26 @@ test "OffsetHashMap remake map" { try expectEqual(5, map.get(5).?); } } + +test "layoutForCapacity no overflow for large capacity" { + // Test that layoutForCapacity correctly handles large capacities without overflow. + // Prior to the fix, new_capacity (u32) was multiplied before widening to usize, + // causing overflow when new_capacity * @sizeOf(K) exceeded 2^32. + // See: https://github.com/ghostty-org/ghostty/issues/9862 + const Map = AutoHashMapUnmanaged(u64, u64); + + // Use 2^30 capacity - this would overflow in u32 when multiplied by @sizeOf(u64)=8 + // 0x40000000 * 8 = 0x2_0000_0000 which wraps to 0 in u32 + const large_cap: Map.Size = 1 << 30; + const layout = Map.layoutForCapacity(large_cap); + + // With the fix, total_size should be at least cap * (sizeof(K) + sizeof(V)) + // = 2^30 * 16 = 2^34 bytes = 16 GiB + // Without the fix, this would wrap and produce a much smaller value. + const min_expected: usize = @as(usize, large_cap) * (@sizeOf(u64) + @sizeOf(u64)); + try expect(layout.total_size >= min_expected); + + // Also verify the individual offsets don't wrap + try expect(layout.keys_start > 0); + try expect(layout.vals_start > layout.keys_start); +} From b224b690545e2e51eef1e86365698709ee01a82f Mon Sep 17 00:00:00 2001 From: Devzeth Date: Thu, 11 Dec 2025 01:05:51 +0100 Subject: [PATCH 618/702] fix(terminal): increase grapheme_bytes instead of hyperlink_bytes during reflow When reflowing content with many graphemes, the code incorrectly increased hyperlink_bytes capacity instead of grapheme_bytes, causing GraphemeMapOutOfMemory errors. --- src/terminal/PageList.zig | 85 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 29f414e03..9e14e2a75 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1220,7 +1220,7 @@ const ReflowCursor = struct { // with graphemes then we increase capacity. if (self.page.graphemeCount() >= self.page.graphemeCapacity()) { try self.adjustCapacity(list, .{ - .hyperlink_bytes = cap.grapheme_bytes * 2, + .grapheme_bytes = cap.grapheme_bytes * 2, }); } @@ -10758,3 +10758,86 @@ test "PageList clears history" { .x = 0, }, s.getTopLeft(.active)); } + +test "PageList resize reflow grapheme map capacity exceeded" { + // This test verifies that when reflowing content with many graphemes, + // the grapheme map capacity is correctly increased when needed. + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 10, 0); + defer s.deinit(); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + + // Get the grapheme capacity from the page. We need more than this many + // graphemes in a single destination page to trigger capacity increase + // during reflow. Since each source page can only hold this many graphemes, + // we create two source pages with graphemes that will merge into one + // destination page. + const grapheme_capacity = s.pages.first.?.data.graphemeCapacity(); + // Use slightly more than half the capacity per page, so combined they + // exceed the capacity of a single destination page. + const graphemes_per_page = grapheme_capacity / 2 + grapheme_capacity / 4; + + // Grow to the capacity of the first page and add more rows + // so that we have two pages total. + { + const page = &s.pages.first.?.data; + page.pauseIntegrityChecks(true); + for (page.size.rows..page.capacity.rows) |_| { + _ = try s.grow(); + } + page.pauseIntegrityChecks(false); + try testing.expectEqual(@as(usize, 1), s.totalPages()); + try s.growRows(graphemes_per_page); + try testing.expectEqual(@as(usize, 2), s.totalPages()); + + // We now have two pages. + try testing.expect(s.pages.first.? != s.pages.last.?); + try testing.expectEqual(s.pages.last.?, s.pages.first.?.next); + } + + // Add graphemes to both pages. We add graphemes to rows at the END of the + // first page, and graphemes to rows at the START of the second page. + // When reflowing to 2 columns, these rows will wrap and stay together + // on the same destination page, requiring capacity increase. + + // Add graphemes to the end of the first page (last rows) + { + const page = &s.pages.first.?.data; + const start_row = page.size.rows - graphemes_per_page; + for (0..graphemes_per_page) |i| { + const y = start_row + i; + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0301))); + } + } + + // Add graphemes to the beginning of the second page + { + const page = &s.pages.last.?.data; + const count = @min(graphemes_per_page, page.size.rows); + for (0..count) |y| { + const rac = page.getRowAndCell(0, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'B' }, + }; + try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0302))); + } + } + + // Resize to fewer columns to trigger reflow. + // The graphemes from both pages will be copied to destination pages. + // They will all end up in a contiguous region of the destination. + // If the bug exists (hyperlink_bytes increased instead of grapheme_bytes), + // this will fail with GraphemeMapOutOfMemory when we exceed capacity. + try s.resize(.{ .cols = 2, .reflow = true }); + + // Verify the resize succeeded + try testing.expectEqual(@as(usize, 2), s.cols); +} From 1a65c1aae2f35e4accd9c2b4761d92dbd7d742ae Mon Sep 17 00:00:00 2001 From: George Papadakis Date: Mon, 1 Dec 2025 22:24:10 +0200 Subject: [PATCH 619/702] feat(macos): add tab color picker to tab context menu --- .../Terminal/TerminalController.swift | 96 +++-- .../Terminal/TerminalRestorable.swift | 7 +- .../Window Styles/TerminalWindow.swift | 357 ++++++++++++++++-- .../Helpers/Extensions/NSMenu+Extension.swift | 13 +- 4 files changed, 404 insertions(+), 69 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index a275c3f39..1b2a31928 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -54,6 +54,17 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig + /// The accent color that should be rendered for this tab. + var tabColor: TerminalWindow.TabColor = .none { + didSet { + guard tabColor != oldValue else { return } + if let terminalWindow = window as? TerminalWindow { + terminalWindow.display(tabColor: tabColor) + } + window?.invalidateRestorableState() + } + } + /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] @@ -148,7 +159,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) - + // Whenever our surface tree changes in any way (new split, close split, etc.) // we want to invalidate our state. invalidateRestorableState() @@ -195,7 +206,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr $0.window?.isMainWindow ?? false } ?? lastMain ?? all.last } - + // The last controller to be main. We use this when paired with "preferredParent" // to find the preferred window to attach new tabs, perform actions, etc. We // always prefer the main window but if there isn't any (because we're triggered @@ -517,13 +528,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr fromTopLeftOffsetX: CGFloat(x), offsetY: CGFloat(y), windowSize: frame.size) - + // Clamp the origin to ensure the window stays fully visible on screen var safeOrigin = origin let vf = screen.visibleFrame safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width) safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) - + // Return our new origin var result = frame result.origin = safeOrigin @@ -558,7 +569,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeWindowImmediately() return } - + // Undo if let undoManager, let undoState { // Register undo action to restore the tab @@ -579,15 +590,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - + window.close() } - + private func closeOtherTabsImmediately() { guard let window = window else { return } guard let tabGroup = window.tabGroup else { return } guard tabGroup.windows.count > 1 else { return } - + // Start an undo grouping if let undoManager { undoManager.beginUndoGrouping() @@ -595,7 +606,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr defer { undoManager?.endUndoGrouping() } - + // Iterate through all tabs except the current one. for window in tabGroup.windows where window != self.window { // We ignore any non-terminal tabs. They don't currently exist and we can't @@ -607,10 +618,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr controller.closeTabImmediately(registerRedo: false) } } - + if let undoManager { undoManager.setActionName("Close Other Tabs") - + // We need to register an undo that refocuses this window. Otherwise, the // undo operation above for each tab will steal focus. undoManager.registerUndo( @@ -620,7 +631,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { target.window?.makeKeyAndOrderFront(nil) } - + // Register redo action undoManager.registerUndo( withTarget: target, @@ -746,7 +757,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr case (nil, nil): return true } } - + // Find the index of the key window in our sorted states. This is a bit verbose // but we only need this for this style of undo so we don't want to add it to // UndoState. @@ -772,12 +783,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let controllers = undoStates.map { undoState in TerminalController(ghostty, with: undoState) } - + // The first controller becomes the parent window for all tabs. // If we don't have a first controller (shouldn't be possible?) // then we can't restore tabs. guard let firstController = controllers.first else { return } - + // Add all subsequent controllers as tabs to the first window for controller in controllers.dropFirst() { controller.showWindow(nil) @@ -786,7 +797,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr firstWindow.addTabbedWindow(newWindow, ordered: .above) } } - + // Make the appropriate window key. If we had a key window, restore it. // Otherwise, make the last window key. if let keyWindowIndex, keyWindowIndex < controllers.count { @@ -852,12 +863,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let focusedSurface: UUID? let tabIndex: Int? weak var tabGroup: NSWindowTabGroup? + let tabColor: TerminalWindow.TabColor } convenience init(_ ghostty: Ghostty.App, with undoState: UndoState ) { self.init(ghostty, withSurfaceTree: undoState.surfaceTree) + self.tabColor = undoState.tabColor // Show the window and restore its frame showWindow(nil) @@ -898,7 +911,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr surfaceTree: surfaceTree, focusedSurface: focusedSurface?.id, tabIndex: window.tabGroup?.windows.firstIndex(of: window), - tabGroup: window.tabGroup) + tabGroup: window.tabGroup, + tabColor: tabColor) } //MARK: - NSWindowController @@ -939,14 +953,17 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr viewModel: self, delegate: self, )) - + + if let terminalWindow = window as? TerminalWindow { + terminalWindow.display(tabColor: tabColor) + } // 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. @@ -956,13 +973,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - + // 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. @@ -1073,7 +1090,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if let window { LastWindowPosition.shared.save(window) } - + // Remember our last main Self.lastMain = self } @@ -1120,7 +1137,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr @IBAction func closeOtherTabs(_ sender: Any?) { guard let window = window else { return } guard let tabGroup = window.tabGroup else { return } - + // If we only have one window then we have no other tabs to close guard tabGroup.windows.count > 1 else { return } @@ -1178,6 +1195,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + + func setTabColor(_ color: TerminalWindow.TabColor) { + tabColor = color + } + @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) @@ -1219,7 +1241,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } //MARK: - TerminalViewDelegate - + override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) @@ -1283,7 +1305,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Get our target window let targetWindow = tabbedWindows[finalIndex] - + // Moving tabs on macOS 26 RC causes very nasty visual glitches in the titlebar tabs. // I believe this is due to messed up constraints for our hacky tab bar. I'd like to // find a better workaround. For now, this improves things dramatically. @@ -1296,7 +1318,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { selectedWindow.makeKey() } - + return } } @@ -1451,24 +1473,24 @@ extension TerminalController { guard let window, let tabGroup = window.tabGroup else { return false } guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } - + case #selector(returnToDefaultSize): guard let window else { return false } - + // Native fullscreen windows can't revert to default size. if window.styleMask.contains(.fullScreen) { return false } - + // If we're fullscreen at all then we can't change size if fullscreenStyle?.isFullscreen ?? false { return false } - + // If our window is already the default size or we don't have a // default size, then disable. return defaultSize?.isChanged(for: window) ?? false - + default: return super.validateMenuItem(item) } @@ -1484,10 +1506,10 @@ extension TerminalController { 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): @@ -1496,11 +1518,11 @@ extension TerminalController { 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): @@ -1509,13 +1531,13 @@ extension TerminalController { 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. diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 71e54b612..852cad581 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,16 +4,18 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 5 + static let version: Int = 6 let focusedSurface: String? let surfaceTree: SplitTree let effectiveFullscreenMode: FullscreenMode? + let tabColorRawValue: Int init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode + self.tabColorRawValue = controller.tabColor.rawValue } init?(coder aDecoder: NSCoder) { @@ -31,6 +33,7 @@ class TerminalRestorableState: Codable { self.surfaceTree = v.value.surfaceTree self.focusedSurface = v.value.focusedSurface self.effectiveFullscreenMode = v.value.effectiveFullscreenMode + self.tabColorRawValue = v.value.tabColorRawValue } func encode(with coder: NSCoder) { @@ -94,6 +97,8 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } + c.tabColor = TerminalWindow.TabColor(rawValue: state.tabColorRawValue) ?? .none + // Setup our restored state on the controller // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 77ee98cb4..1361bbd1f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -7,10 +7,10 @@ import GhosttyKit class TerminalWindow: NSWindow { /// Posted when a terminal window awakes from nib. static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake") - + /// Posted when a terminal window will close static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose") - + /// This is the key in UserDefaults to use for the default `level` value. This is /// used by the manual float on top menu item feature. static let defaultLevelKey: String = "TerminalDefaultLevel" @@ -20,15 +20,20 @@ class TerminalWindow: NSWindow { /// Reset split zoom button in titlebar private let resetZoomAccessory = NSTitlebarAccessoryViewController() - + /// Update notification UI in titlebar private let updateAccessory = NSTitlebarAccessoryViewController() + /// Visual indicator that mirrors the selected tab color. + private let tabColorIndicator = TabColorIndicator() + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() - private var tabMenuObserver: NSObjectProtocol? = nil - + private var tabColorSelection: TabColor = .none { + didSet { tabColorIndicator.tabColor = tabColorSelection } + } + /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. var supportsUpdateAccessory: Bool { @@ -40,7 +45,11 @@ class TerminalWindow: NSWindow { var terminalController: TerminalController? { windowController as? TerminalController } - + + func display(tabColor: TabColor) { + tabColorSelection = tabColor + } + // MARK: NSWindow Overrides override var toolbar: NSToolbar? { @@ -66,7 +75,7 @@ class TerminalWindow: NSWindow { guard let self, let menu = n.object as? NSMenu else { return } self.configureTabContextMenuIfNeeded(menu) } - + // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then // tabs restore as separate windows. @@ -74,14 +83,14 @@ class TerminalWindow: NSWindow { DispatchQueue.main.async { self.tabbingMode = .automatic } - + // All new windows are based on the app config at the time of creation. guard let appDelegate = NSApp.delegate as? AppDelegate else { return } let config = appDelegate.ghostty.config // Setup our initial config derivedConfig = .init(config) - + // If there is a hardcoded title in the configuration, we set that // immediately. Future `set_title` apprt actions will override this // if necessary but this ensures our window loads with the proper @@ -116,7 +125,7 @@ class TerminalWindow: NSWindow { })) addTitlebarAccessoryViewController(resetZoomAccessory) resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false - + // Create update notification accessory if supportsUpdateAccessory { updateAccessory.layoutAttribute = .right @@ -132,9 +141,19 @@ class TerminalWindow: NSWindow { // Setup the accessory view for tabs that shows our keyboard shortcuts, // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues // where buttons were not clickable. - let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton]) + tabColorIndicator.translatesAutoresizingMaskIntoConstraints = false + tabColorIndicator.widthAnchor.constraint(equalToConstant: 12).isActive = true + tabColorIndicator.heightAnchor.constraint(equalToConstant: 4).isActive = true + tabColorIndicator.tabColor = tabColorSelection + + let stackView = NSStackView() + stackView.orientation = .horizontal stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - stackView.spacing = 3 + stackView.spacing = 4 + stackView.alignment = .centerY + stackView.addArrangedSubview(tabColorIndicator) + stackView.addArrangedSubview(keyEquivalentLabel) + stackView.addArrangedSubview(resetZoomTabButton) tab.accessoryView = stackView // Get our saved level @@ -145,7 +164,7 @@ class TerminalWindow: NSWindow { // still become key/main and receive events. override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } - + override func close() { NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) super.close() @@ -216,6 +235,9 @@ class TerminalWindow: NSWindow { static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") + private static let tabColorHeaderIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorHeader") + private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") func findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. @@ -279,7 +301,7 @@ class TerminalWindow: NSWindow { if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) { removeTitlebarAccessoryViewController(at: idx) } - + // We don't need to do this with the update accessory. I don't know why but // everything works fine. } @@ -302,29 +324,37 @@ class TerminalWindow: NSWindow { .first { $0.action == NSSelectorFromString("performClose:") } .flatMap { $0.target as? NSWindow } .flatMap { $0.windowController as? TerminalController } - + // Close tabs to the right let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = targetController item.setImageIfDesired(systemSymbolName: "xmark") - if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && - !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { + let insertionIndex: UInt + if let idx = menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) { + insertionIndex = idx + } else if let idx = menu.insertItem(item, after: NSSelectorFromString("performClose:")) { + insertionIndex = idx + } else { menu.addItem(item) + insertionIndex = UInt(menu.items.count - 1) } // Other close items should have the xmark to match Safari on macOS 26 for menuItem in menu.items { if menuItem.action == NSSelectorFromString("performClose:") || - menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { + menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { menuItem.setImageIfDesired(systemSymbolName: "xmark") } } + + removeTabColorSection(from: menu) + insertTabColorSection(into: menu, startingAt: Int(insertionIndex) + 1) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { guard NSApp.keyWindow === self else { return false } - + // These are the target selectors, at least for macOS 26. let tabContextSelectors: Set = [ "performClose:", @@ -332,12 +362,56 @@ class TerminalWindow: NSWindow { "moveTabToNewWindow:", "toggleTabOverview:" ] - + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) return !selectorNames.isDisjoint(with: tabContextSelectors) } + private func removeTabColorSection(from menu: NSMenu) { + let identifiers: Set = [ + Self.tabColorSeparatorIdentifier, + Self.tabColorHeaderIdentifier, + Self.tabColorPaletteIdentifier + ] + + for (index, item) in menu.items.enumerated().reversed() { + guard let identifier = item.identifier else { continue } + if identifiers.contains(identifier) { + menu.removeItem(at: index) + } + } + } + + private func insertTabColorSection(into menu: NSMenu, startingAt index: Int) { + guard let terminalController else { return } + + var insertionIndex = index + + let separator = NSMenuItem.separator() + separator.identifier = Self.tabColorSeparatorIdentifier + menu.insertItem(separator, at: insertionIndex) + insertionIndex += 1 + + let headerTitle = NSLocalizedString("Tab Color", comment: "Tab color context menu section title") + let headerItem = NSMenuItem() + headerItem.identifier = Self.tabColorHeaderIdentifier + headerItem.title = headerTitle + headerItem.isEnabled = false + menu.insertItem(headerItem, at: insertionIndex) + insertionIndex += 1 + + let paletteItem = NSMenuItem() + paletteItem.identifier = Self.tabColorPaletteIdentifier + let paletteView = TabColorPaletteView( + selectedColor: tabColorSelection + ) { [weak terminalController] color in + terminalController?.setTabColor(color) + } + paletteItem.view = paletteView + menu.insertItem(paletteItem, at: insertionIndex) + } + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -549,7 +623,7 @@ class TerminalWindow: NSWindow { private func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. - guard let x, let y else { + guard x != nil, y != nil else { if (!LastWindowPosition.shared.restore(self)) { center() } @@ -568,7 +642,7 @@ class TerminalWindow: NSWindow { center() return } - + let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen) setFrameOrigin(frame.origin) } @@ -584,7 +658,7 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } - + // MARK: Config struct DerivedConfig { @@ -651,12 +725,12 @@ extension TerminalWindow { } } } - + /// A pill-shaped button that displays update status and provides access to update actions. struct UpdateAccessoryView: View { @ObservedObject var viewModel: ViewModel @ObservedObject var model: UpdateViewModel - + var body: some View { // We use the same top/trailing padding so that it hugs the same. UpdatePill(model: model) @@ -666,3 +740,236 @@ extension TerminalWindow { } } + +extension TerminalWindow { + enum TabColor: Int, CaseIterable { + case none + case blue + case purple + case pink + case red + case orange + case yellow + case green + case teal + case graphite + + static let paletteRows: [[TabColor]] = [ + [.none, .blue, .purple, .pink, .red], + [.orange, .yellow, .green, .teal, .graphite], + ] + + var localizedName: String { + switch self { + case .none: + return NSLocalizedString("None", comment: "Tab color option label") + case .blue: + return NSLocalizedString("Blue", comment: "Tab color option label") + case .purple: + return NSLocalizedString("Purple", comment: "Tab color option label") + case .pink: + return NSLocalizedString("Pink", comment: "Tab color option label") + case .red: + return NSLocalizedString("Red", comment: "Tab color option label") + case .orange: + return NSLocalizedString("Orange", comment: "Tab color option label") + case .yellow: + return NSLocalizedString("Yellow", comment: "Tab color option label") + case .green: + return NSLocalizedString("Green", comment: "Tab color option label") + case .teal: + return NSLocalizedString("Teal", comment: "Tab color option label") + case .graphite: + return NSLocalizedString("Graphite", comment: "Tab color option label") + } + } + + var displayColor: NSColor? { + switch self { + case .none: + return nil + case .blue: + return .systemBlue + case .purple: + return .systemPurple + case .pink: + return .systemPink + case .red: + return .systemRed + case .orange: + return .systemOrange + case .yellow: + return .systemYellow + case .green: + return .systemGreen + case .teal: + if #available(macOS 13.0, *) { + return .systemMint + } else { + return .systemTeal + } + case .graphite: + return .systemGray + } + } + + func swatchImage(selected: Bool) -> NSImage { + let size = NSSize(width: 18, height: 18) + return NSImage(size: size, flipped: false) { rect in + let circleRect = rect.insetBy(dx: 1, dy: 1) + let circlePath = NSBezierPath(ovalIn: circleRect) + + if let fillColor = self.displayColor { + fillColor.setFill() + circlePath.fill() + } else { + NSColor.clear.setFill() + circlePath.fill() + NSColor.quaternaryLabelColor.setStroke() + circlePath.lineWidth = 1 + circlePath.stroke() + } + + if self == .none { + let slash = NSBezierPath() + slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2)) + slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2)) + slash.lineWidth = 1.5 + NSColor.secondaryLabelColor.setStroke() + slash.stroke() + } + + if selected { + let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)) + highlight.lineWidth = 2 + NSColor.controlAccentColor.setStroke() + highlight.stroke() + } + + return true + } + } + } +} + +private final class TabColorIndicator: NSView { + var tabColor: TerminalWindow.TabColor = .none { + didSet { updateAppearance() } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + updateAppearance() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + updateAppearance() + } + + private func updateAppearance() { + guard let layer else { return } + layer.cornerRadius = bounds.height / 2 + + if let color = tabColor.displayColor { + alphaValue = 1 + layer.backgroundColor = color.cgColor + layer.borderWidth = 0 + layer.borderColor = nil + } else { + alphaValue = 0 + layer.backgroundColor = NSColor.clear.cgColor + layer.borderWidth = 0 + layer.borderColor = nil + } + } +} + +private final class TabColorPaletteView: NSView { + private let stackView = NSStackView() + private var selectedColor: TerminalWindow.TabColor + private let selectionHandler: (TerminalWindow.TabColor) -> Void + private var buttons: [NSButton] = [] + + init(selectedColor: TerminalWindow.TabColor, + selectionHandler: @escaping (TerminalWindow.TabColor) -> Void) { + self.selectedColor = selectedColor + self.selectionHandler = selectionHandler + super.init(frame: NSRect(origin: .zero, size: NSSize(width: 180, height: 60))) + + stackView.orientation = .vertical + stackView.spacing = 6 + addSubview(stackView) + + for row in TerminalWindow.TabColor.paletteRows { + let rowStack = NSStackView() + rowStack.orientation = .horizontal + rowStack.spacing = 6 + + for color in row { + let button = makeButton(for: color) + rowStack.addArrangedSubview(button) + buttons.append(button) + } + + stackView.addArrangedSubview(rowStack) + } + + translatesAutoresizingMaskIntoConstraints = true + setFrameSize(intrinsicContentSize) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + NSSize(width: 190, height: 70) + } + + override func layout() { + super.layout() + stackView.frame = bounds.insetBy(dx: 10, dy: 6) + } + + private func makeButton(for color: TerminalWindow.TabColor) -> NSButton { + let button = NSButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyUpOrDown + button.image = color.swatchImage(selected: color == selectedColor) + button.setButtonType(.momentaryChange) + button.isBordered = false + button.focusRingType = .none + button.target = self + button.action = #selector(onSelectColor(_:)) + button.tag = color.rawValue + button.toolTip = color.localizedName + + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 24), + button.heightAnchor.constraint(equalToConstant: 24) + ]) + + return button + } + + @objc private func onSelectColor(_ sender: NSButton) { + guard let color = TerminalWindow.TabColor(rawValue: sender.tag) else { return } + selectedColor = color + updateButtonImages() + selectionHandler(color) + } + + private func updateButtonImages() { + for button in buttons { + guard let color = TerminalWindow.TabColor(rawValue: button.tag) else { continue } + button.image = color.swatchImage(selected: color == selectedColor) + } + } +} diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift index 7ddfa419f..0166047c0 100644 --- a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -10,20 +10,21 @@ extension NSMenu { /// - item: The menu item to insert. /// - action: The action selector to search for. The new item will be inserted after the first /// item with this action. - /// - Returns: `true` if the item was inserted after the specified action, `false` if the action - /// was not found and the item was not inserted. + /// - Returns: The index where the item was inserted, or `nil` if the action was not found + /// and the item was not inserted. @discardableResult - func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool { + func insertItem(_ item: NSMenuItem, after action: Selector) -> UInt? { if let identifier = item.identifier, let existing = items.first(where: { $0.identifier == identifier }) { removeItem(existing) } guard let idx = items.firstIndex(where: { $0.action == action }) else { - return false + return nil } - insertItem(item, at: idx + 1) - return true + let insertionIndex = idx + 1 + insertItem(item, at: insertionIndex) + return UInt(insertionIndex) } } From 51589a4e0248256ff38a4ca3169f8db849195a4e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 07:23:50 -0800 Subject: [PATCH 620/702] macos: move TerminalTabColor to its own file --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Terminal/TerminalController.swift | 6 +- .../Terminal/TerminalRestorable.swift | 2 +- .../Features/Terminal/TerminalTabColor.swift | 110 +++++++++++++++ .../Window Styles/TerminalWindow.swift | 131 ++---------------- 5 files changed, 126 insertions(+), 124 deletions(-) create mode 100644 macos/Sources/Features/Terminal/TerminalTabColor.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 31e812f0c..eb5d706c3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ Features/Terminal/ErrorView.swift, Features/Terminal/TerminalController.swift, Features/Terminal/TerminalRestorable.swift, + Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 1b2a31928..7941ae22e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -55,7 +55,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr private(set) var derivedConfig: DerivedConfig /// The accent color that should be rendered for this tab. - var tabColor: TerminalWindow.TabColor = .none { + var tabColor: TerminalTabColor = .none { didSet { guard tabColor != oldValue else { return } if let terminalWindow = window as? TerminalWindow { @@ -863,7 +863,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let focusedSurface: UUID? let tabIndex: Int? weak var tabGroup: NSWindowTabGroup? - let tabColor: TerminalWindow.TabColor + let tabColor: TerminalTabColor } convenience init(_ ghostty: Ghostty.App, @@ -1196,7 +1196,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } - func setTabColor(_ color: TerminalWindow.TabColor) { + func setTabColor(_ color: TerminalTabColor) { tabColor = color } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 852cad581..c527a01c1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -97,7 +97,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } - c.tabColor = TerminalWindow.TabColor(rawValue: state.tabColorRawValue) ?? .none + c.tabColor = TerminalTabColor(rawValue: state.tabColorRawValue) ?? .none // Setup our restored state on the controller // Find the focused surface in surfaceTree diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift new file mode 100644 index 000000000..3d2b9c447 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -0,0 +1,110 @@ +import AppKit + +enum TerminalTabColor: Int, CaseIterable { + case none + case blue + case purple + case pink + case red + case orange + case yellow + case green + case teal + case graphite + + static let paletteRows: [[TerminalTabColor]] = [ + [.none, .blue, .purple, .pink, .red], + [.orange, .yellow, .green, .teal, .graphite], + ] + + var localizedName: String { + switch self { + case .none: + return "None" + case .blue: + return "Blue" + case .purple: + return "Purple" + case .pink: + return "Pink" + case .red: + return "Red" + case .orange: + return "Orange" + case .yellow: + return "Yellow" + case .green: + return "Green" + case .teal: + return "Teal" + case .graphite: + return "Graphite" + } + } + + var displayColor: NSColor? { + switch self { + case .none: + return nil + case .blue: + return .systemBlue + case .purple: + return .systemPurple + case .pink: + return .systemPink + case .red: + return .systemRed + case .orange: + return .systemOrange + case .yellow: + return .systemYellow + case .green: + return .systemGreen + case .teal: + if #available(macOS 13.0, *) { + return .systemMint + } else { + return .systemTeal + } + case .graphite: + return .systemGray + } + } + + func swatchImage(selected: Bool) -> NSImage { + let size = NSSize(width: 18, height: 18) + return NSImage(size: size, flipped: false) { rect in + let circleRect = rect.insetBy(dx: 1, dy: 1) + let circlePath = NSBezierPath(ovalIn: circleRect) + + if let fillColor = self.displayColor { + fillColor.setFill() + circlePath.fill() + } else { + NSColor.clear.setFill() + circlePath.fill() + NSColor.quaternaryLabelColor.setStroke() + circlePath.lineWidth = 1 + circlePath.stroke() + } + + if self == .none { + let slash = NSBezierPath() + slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2)) + slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2)) + slash.lineWidth = 1.5 + NSColor.secondaryLabelColor.setStroke() + slash.stroke() + } + + if selected { + let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)) + highlight.lineWidth = 2 + NSColor.controlAccentColor.setStroke() + highlight.stroke() + } + + return true + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 1361bbd1f..9e329b76e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -30,7 +30,7 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() private var tabMenuObserver: NSObjectProtocol? = nil - private var tabColorSelection: TabColor = .none { + private var tabColorSelection: TerminalTabColor = .none { didSet { tabColorIndicator.tabColor = tabColorSelection } } @@ -46,7 +46,7 @@ class TerminalWindow: NSWindow { windowController as? TerminalController } - func display(tabColor: TabColor) { + func display(tabColor: TerminalTabColor) { tabColorSelection = tabColor } @@ -741,119 +741,10 @@ extension TerminalWindow { } -extension TerminalWindow { - enum TabColor: Int, CaseIterable { - case none - case blue - case purple - case pink - case red - case orange - case yellow - case green - case teal - case graphite - static let paletteRows: [[TabColor]] = [ - [.none, .blue, .purple, .pink, .red], - [.orange, .yellow, .green, .teal, .graphite], - ] - - var localizedName: String { - switch self { - case .none: - return NSLocalizedString("None", comment: "Tab color option label") - case .blue: - return NSLocalizedString("Blue", comment: "Tab color option label") - case .purple: - return NSLocalizedString("Purple", comment: "Tab color option label") - case .pink: - return NSLocalizedString("Pink", comment: "Tab color option label") - case .red: - return NSLocalizedString("Red", comment: "Tab color option label") - case .orange: - return NSLocalizedString("Orange", comment: "Tab color option label") - case .yellow: - return NSLocalizedString("Yellow", comment: "Tab color option label") - case .green: - return NSLocalizedString("Green", comment: "Tab color option label") - case .teal: - return NSLocalizedString("Teal", comment: "Tab color option label") - case .graphite: - return NSLocalizedString("Graphite", comment: "Tab color option label") - } - } - - var displayColor: NSColor? { - switch self { - case .none: - return nil - case .blue: - return .systemBlue - case .purple: - return .systemPurple - case .pink: - return .systemPink - case .red: - return .systemRed - case .orange: - return .systemOrange - case .yellow: - return .systemYellow - case .green: - return .systemGreen - case .teal: - if #available(macOS 13.0, *) { - return .systemMint - } else { - return .systemTeal - } - case .graphite: - return .systemGray - } - } - - func swatchImage(selected: Bool) -> NSImage { - let size = NSSize(width: 18, height: 18) - return NSImage(size: size, flipped: false) { rect in - let circleRect = rect.insetBy(dx: 1, dy: 1) - let circlePath = NSBezierPath(ovalIn: circleRect) - - if let fillColor = self.displayColor { - fillColor.setFill() - circlePath.fill() - } else { - NSColor.clear.setFill() - circlePath.fill() - NSColor.quaternaryLabelColor.setStroke() - circlePath.lineWidth = 1 - circlePath.stroke() - } - - if self == .none { - let slash = NSBezierPath() - slash.move(to: NSPoint(x: circleRect.minX + 2, y: circleRect.minY + 2)) - slash.line(to: NSPoint(x: circleRect.maxX - 2, y: circleRect.maxY - 2)) - slash.lineWidth = 1.5 - NSColor.secondaryLabelColor.setStroke() - slash.stroke() - } - - if selected { - let highlight = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)) - highlight.lineWidth = 2 - NSColor.controlAccentColor.setStroke() - highlight.stroke() - } - - return true - } - } - } -} private final class TabColorIndicator: NSView { - var tabColor: TerminalWindow.TabColor = .none { + var tabColor: TerminalTabColor = .none { didSet { updateAppearance() } } @@ -892,12 +783,12 @@ private final class TabColorIndicator: NSView { private final class TabColorPaletteView: NSView { private let stackView = NSStackView() - private var selectedColor: TerminalWindow.TabColor - private let selectionHandler: (TerminalWindow.TabColor) -> Void + private var selectedColor: TerminalTabColor + private let selectionHandler: (TerminalTabColor) -> Void private var buttons: [NSButton] = [] - init(selectedColor: TerminalWindow.TabColor, - selectionHandler: @escaping (TerminalWindow.TabColor) -> Void) { + init(selectedColor: TerminalTabColor, + selectionHandler: @escaping (TerminalTabColor) -> Void) { self.selectedColor = selectedColor self.selectionHandler = selectionHandler super.init(frame: NSRect(origin: .zero, size: NSSize(width: 180, height: 60))) @@ -906,7 +797,7 @@ private final class TabColorPaletteView: NSView { stackView.spacing = 6 addSubview(stackView) - for row in TerminalWindow.TabColor.paletteRows { + for row in TerminalTabColor.paletteRows { let rowStack = NSStackView() rowStack.orientation = .horizontal rowStack.spacing = 6 @@ -937,7 +828,7 @@ private final class TabColorPaletteView: NSView { stackView.frame = bounds.insetBy(dx: 10, dy: 6) } - private func makeButton(for color: TerminalWindow.TabColor) -> NSButton { + private func makeButton(for color: TerminalTabColor) -> NSButton { let button = NSButton() button.translatesAutoresizingMaskIntoConstraints = false button.imagePosition = .imageOnly @@ -960,7 +851,7 @@ private final class TabColorPaletteView: NSView { } @objc private func onSelectColor(_ sender: NSButton) { - guard let color = TerminalWindow.TabColor(rawValue: sender.tag) else { return } + guard let color = TerminalTabColor(rawValue: sender.tag) else { return } selectedColor = color updateButtonImages() selectionHandler(color) @@ -968,7 +859,7 @@ private final class TabColorPaletteView: NSView { private func updateButtonImages() { for button in buttons { - guard let color = TerminalWindow.TabColor(rawValue: button.tag) else { continue } + guard let color = TerminalTabColor(rawValue: button.tag) else { continue } button.image = color.swatchImage(selected: color == selectedColor) } } From 04913905a385e6c836783d7b70aa1cffed908293 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 07:24:46 -0800 Subject: [PATCH 621/702] macos: tab color is codable for restoration --- macos/Sources/Features/Terminal/TerminalRestorable.swift | 8 ++++---- macos/Sources/Features/Terminal/TerminalTabColor.swift | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index c527a01c1..931739987 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -9,13 +9,13 @@ class TerminalRestorableState: Codable { let focusedSurface: String? let surfaceTree: SplitTree let effectiveFullscreenMode: FullscreenMode? - let tabColorRawValue: Int + let tabColor: TerminalTabColor init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode - self.tabColorRawValue = controller.tabColor.rawValue + self.tabColor = controller.tabColor } init?(coder aDecoder: NSCoder) { @@ -33,7 +33,7 @@ class TerminalRestorableState: Codable { self.surfaceTree = v.value.surfaceTree self.focusedSurface = v.value.focusedSurface self.effectiveFullscreenMode = v.value.effectiveFullscreenMode - self.tabColorRawValue = v.value.tabColorRawValue + self.tabColor = v.value.tabColor } func encode(with coder: NSCoder) { @@ -97,7 +97,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } - c.tabColor = TerminalTabColor(rawValue: state.tabColorRawValue) ?? .none + c.tabColor = state.tabColor // Setup our restored state on the controller // Find the focused surface in surfaceTree diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 3d2b9c447..41e85eb7a 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -1,6 +1,6 @@ import AppKit -enum TerminalTabColor: Int, CaseIterable { +enum TerminalTabColor: Int, CaseIterable, Codable { case none case blue case purple From a0089702f18cb2be1ce0c9ab7f358b1a07a4b61e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:18:25 -0800 Subject: [PATCH 622/702] macos: convert tab color view to SwiftUI --- .../Features/Terminal/TerminalTabColor.swift | 72 ++++++++++++ .../Window Styles/TerminalWindow.swift | 110 +++--------------- 2 files changed, 89 insertions(+), 93 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 41e85eb7a..1af6aa10b 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI enum TerminalTabColor: Int, CaseIterable, Codable { case none @@ -108,3 +109,74 @@ enum TerminalTabColor: Int, CaseIterable, Codable { } } } + +// MARK: - Menu View + +/// A SwiftUI view displaying a color palette for tab color selection. +/// Used as a custom view inside an NSMenuItem in the tab context menu. +struct TabColorMenuView: View { + @State private var currentSelection: TerminalTabColor + let onSelect: (TerminalTabColor) -> Void + + init(selectedColor: TerminalTabColor, onSelect: @escaping (TerminalTabColor) -> Void) { + self._currentSelection = State(initialValue: selectedColor) + self.onSelect = onSelect + } + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(TerminalTabColor.paletteRows, id: \.self) { row in + HStack(spacing: 2) { + ForEach(row, id: \.self) { color in + TabColorSwatch( + color: color, + isSelected: color == currentSelection + ) { + currentSelection = color + onSelect(color) + } + } + } + } + } + .padding(.leading, Self.leadingPadding) + .padding(.trailing, 12) + .padding(.top, 4) + .padding(.bottom, 4) + } + + /// Leading padding to align with the menu's icon gutter. + /// macOS 26 introduced icons in menus, requiring additional padding. + private static var leadingPadding: CGFloat { + if #available(macOS 26.0, *) { + return 40 + } else { + return 12 + } + } +} + +/// A single color swatch button in the tab color palette. +private struct TabColorSwatch: View { + let color: TerminalTabColor + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Group { + if color == .none { + Image(systemName: isSelected ? "circle.slash" : "circle") + .foregroundStyle(.secondary) + } else if let displayColor = color.displayColor { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle.fill") + .foregroundStyle(Color(nsColor: displayColor)) + } + } + .font(.system(size: 16)) + .frame(width: 20, height: 20) + } + .buttonStyle(.plain) + .help(color.localizedName) + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 9e329b76e..ff3814b03 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -349,7 +349,7 @@ class TerminalWindow: NSWindow { } removeTabColorSection(from: menu) - insertTabColorSection(into: menu, startingAt: Int(insertionIndex) + 1) + appendTabColorSection(to: menu) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { @@ -383,33 +383,29 @@ class TerminalWindow: NSWindow { } } - private func insertTabColorSection(into menu: NSMenu, startingAt index: Int) { + private func appendTabColorSection(to menu: NSMenu) { guard let terminalController else { return } - var insertionIndex = index - let separator = NSMenuItem.separator() separator.identifier = Self.tabColorSeparatorIdentifier - menu.insertItem(separator, at: insertionIndex) - insertionIndex += 1 + menu.addItem(separator) let headerTitle = NSLocalizedString("Tab Color", comment: "Tab color context menu section title") let headerItem = NSMenuItem() headerItem.identifier = Self.tabColorHeaderIdentifier headerItem.title = headerTitle headerItem.isEnabled = false - menu.insertItem(headerItem, at: insertionIndex) - insertionIndex += 1 + headerItem.setImageIfDesired(systemSymbolName: "eyedropper") + menu.addItem(headerItem) let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier - let paletteView = TabColorPaletteView( + paletteItem.view = makeTabColorPaletteView( selectedColor: tabColorSelection ) { [weak terminalController] color in terminalController?.setTabColor(color) } - paletteItem.view = paletteView - menu.insertItem(paletteItem, at: insertionIndex) + menu.addItem(paletteItem) } // MARK: Tab Key Equivalents @@ -781,86 +777,14 @@ private final class TabColorIndicator: NSView { } } -private final class TabColorPaletteView: NSView { - private let stackView = NSStackView() - private var selectedColor: TerminalTabColor - private let selectionHandler: (TerminalTabColor) -> Void - private var buttons: [NSButton] = [] - - init(selectedColor: TerminalTabColor, - selectionHandler: @escaping (TerminalTabColor) -> Void) { - self.selectedColor = selectedColor - self.selectionHandler = selectionHandler - super.init(frame: NSRect(origin: .zero, size: NSSize(width: 180, height: 60))) - - stackView.orientation = .vertical - stackView.spacing = 6 - addSubview(stackView) - - for row in TerminalTabColor.paletteRows { - let rowStack = NSStackView() - rowStack.orientation = .horizontal - rowStack.spacing = 6 - - for color in row { - let button = makeButton(for: color) - rowStack.addArrangedSubview(button) - buttons.append(button) - } - - stackView.addArrangedSubview(rowStack) - } - - translatesAutoresizingMaskIntoConstraints = true - setFrameSize(intrinsicContentSize) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - NSSize(width: 190, height: 70) - } - - override func layout() { - super.layout() - stackView.frame = bounds.insetBy(dx: 10, dy: 6) - } - - private func makeButton(for color: TerminalTabColor) -> NSButton { - let button = NSButton() - button.translatesAutoresizingMaskIntoConstraints = false - button.imagePosition = .imageOnly - button.imageScaling = .scaleProportionallyUpOrDown - button.image = color.swatchImage(selected: color == selectedColor) - button.setButtonType(.momentaryChange) - button.isBordered = false - button.focusRingType = .none - button.target = self - button.action = #selector(onSelectColor(_:)) - button.tag = color.rawValue - button.toolTip = color.localizedName - - NSLayoutConstraint.activate([ - button.widthAnchor.constraint(equalToConstant: 24), - button.heightAnchor.constraint(equalToConstant: 24) - ]) - - return button - } - - @objc private func onSelectColor(_ sender: NSButton) { - guard let color = TerminalTabColor(rawValue: sender.tag) else { return } - selectedColor = color - updateButtonImages() - selectionHandler(color) - } - - private func updateButtonImages() { - for button in buttons { - guard let color = TerminalTabColor(rawValue: button.tag) else { continue } - button.image = color.swatchImage(selected: color == selectedColor) - } - } +private func makeTabColorPaletteView( + selectedColor: TerminalTabColor, + selectionHandler: @escaping (TerminalTabColor) -> Void +) -> NSView { + let hostingView = NSHostingView(rootView: TabColorMenuView( + selectedColor: selectedColor, + onSelect: selectionHandler + )) + hostingView.frame.size = hostingView.intrinsicContentSize + return hostingView } From f559bccc385acceb803a3edc07aa04146e3378ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:36:46 -0800 Subject: [PATCH 623/702] macos: clean up setting up the tab menu by using an NSMenu extension --- .../Window Styles/TerminalWindow.swift | 37 +++++-------------- .../Helpers/Extensions/NSMenu+Extension.swift | 12 ++++++ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index ff3814b03..d0c0f750e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -330,14 +330,9 @@ class TerminalWindow: NSWindow { item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = targetController item.setImageIfDesired(systemSymbolName: "xmark") - let insertionIndex: UInt - if let idx = menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) { - insertionIndex = idx - } else if let idx = menu.insertItem(item, after: NSSelectorFromString("performClose:")) { - insertionIndex = idx - } else { + if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil, + menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil { menu.addItem(item) - insertionIndex = UInt(menu.items.count - 1) } // Other close items should have the xmark to match Safari on macOS 26 @@ -348,8 +343,7 @@ class TerminalWindow: NSWindow { } } - removeTabColorSection(from: menu) - appendTabColorSection(to: menu) + appendTabColorSection(to: menu, target: targetController) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { @@ -367,33 +361,20 @@ class TerminalWindow: NSWindow { return !selectorNames.isDisjoint(with: tabContextSelectors) } - - private func removeTabColorSection(from menu: NSMenu) { - let identifiers: Set = [ + private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { + menu.removeItems(withIdentifiers: [ Self.tabColorSeparatorIdentifier, Self.tabColorHeaderIdentifier, Self.tabColorPaletteIdentifier - ] - - for (index, item) in menu.items.enumerated().reversed() { - guard let identifier = item.identifier else { continue } - if identifiers.contains(identifier) { - menu.removeItem(at: index) - } - } - } - - private func appendTabColorSection(to menu: NSMenu) { - guard let terminalController else { return } + ]) let separator = NSMenuItem.separator() separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) - let headerTitle = NSLocalizedString("Tab Color", comment: "Tab color context menu section title") let headerItem = NSMenuItem() headerItem.identifier = Self.tabColorHeaderIdentifier - headerItem.title = headerTitle + headerItem.title = "Tab Color" headerItem.isEnabled = false headerItem.setImageIfDesired(systemSymbolName: "eyedropper") menu.addItem(headerItem) @@ -402,8 +383,8 @@ class TerminalWindow: NSWindow { paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( selectedColor: tabColorSelection - ) { [weak terminalController] color in - terminalController?.setTabColor(color) + ) { [weak target] color in + target?.setTabColor(color) } menu.addItem(paletteItem) } diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift index 0166047c0..82c0a3a41 100644 --- a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -27,4 +27,16 @@ extension NSMenu { insertItem(item, at: insertionIndex) return UInt(insertionIndex) } + + /// Removes all menu items whose identifier is in the given set. + /// + /// - Parameter identifiers: The set of identifiers to match for removal. + func removeItems(withIdentifiers identifiers: Set) { + for (index, item) in items.enumerated().reversed() { + guard let identifier = item.identifier else { continue } + if identifiers.contains(identifier) { + removeItem(at: index) + } + } + } } From 1073e89a0dfcf4fe042f6160e6cce93aaed7ad24 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:40:01 -0800 Subject: [PATCH 624/702] macos: move context menu stuff in terminal window down to an ext --- .../Window Styles/TerminalWindow.swift | 164 +++++++++--------- 1 file changed, 84 insertions(+), 80 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d0c0f750e..706165573 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -234,11 +234,6 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") - private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") - private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") - private static let tabColorHeaderIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorHeader") - private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") - func findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. // @@ -314,81 +309,6 @@ class TerminalWindow: NSWindow { } } - private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { - guard isTabContextMenu(menu) else { return } - - // Get the target from an existing menu item. The native tab context menu items - // target the specific window/controller that was right-clicked, not the focused one. - // We need to use that same target so validation and action use the correct tab. - let targetController = menu.items - .first { $0.action == NSSelectorFromString("performClose:") } - .flatMap { $0.target as? NSWindow } - .flatMap { $0.windowController as? TerminalController } - - // Close tabs to the right - let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") - item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = targetController - item.setImageIfDesired(systemSymbolName: "xmark") - if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil, - menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil { - menu.addItem(item) - } - - // Other close items should have the xmark to match Safari on macOS 26 - for menuItem in menu.items { - if menuItem.action == NSSelectorFromString("performClose:") || - menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { - menuItem.setImageIfDesired(systemSymbolName: "xmark") - } - } - - appendTabColorSection(to: menu, target: targetController) - } - - private func isTabContextMenu(_ menu: NSMenu) -> Bool { - guard NSApp.keyWindow === self else { return false } - - // These are the target selectors, at least for macOS 26. - let tabContextSelectors: Set = [ - "performClose:", - "performCloseOtherTabs:", - "moveTabToNewWindow:", - "toggleTabOverview:" - ] - - let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) - return !selectorNames.isDisjoint(with: tabContextSelectors) - } - - private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { - menu.removeItems(withIdentifiers: [ - Self.tabColorSeparatorIdentifier, - Self.tabColorHeaderIdentifier, - Self.tabColorPaletteIdentifier - ]) - - let separator = NSMenuItem.separator() - separator.identifier = Self.tabColorSeparatorIdentifier - menu.addItem(separator) - - let headerItem = NSMenuItem() - headerItem.identifier = Self.tabColorHeaderIdentifier - headerItem.title = "Tab Color" - headerItem.isEnabled = false - headerItem.setImageIfDesired(systemSymbolName: "eyedropper") - menu.addItem(headerItem) - - let paletteItem = NSMenuItem() - paletteItem.identifier = Self.tabColorPaletteIdentifier - paletteItem.view = makeTabColorPaletteView( - selectedColor: tabColorSelection - ) { [weak target] color in - target?.setTabColor(color) - } - menu.addItem(paletteItem) - } - // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -758,6 +678,90 @@ private final class TabColorIndicator: NSView { } } +// MARK: - Tab Context Menu + +extension TerminalWindow { + private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") + private static let tabColorHeaderIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorHeader") + private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") + + func configureTabContextMenuIfNeeded(_ menu: NSMenu) { + guard isTabContextMenu(menu) else { return } + + // Get the target from an existing menu item. The native tab context menu items + // target the specific window/controller that was right-clicked, not the focused one. + // We need to use that same target so validation and action use the correct tab. + let targetController = menu.items + .first { $0.action == NSSelectorFromString("performClose:") } + .flatMap { $0.target as? NSWindow } + .flatMap { $0.windowController as? TerminalController } + + // Close tabs to the right + let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = targetController + item.setImageIfDesired(systemSymbolName: "xmark") + if menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) == nil, + menu.insertItem(item, after: NSSelectorFromString("performClose:")) == nil { + menu.addItem(item) + } + + // Other close items should have the xmark to match Safari on macOS 26 + for menuItem in menu.items { + if menuItem.action == NSSelectorFromString("performClose:") || + menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { + menuItem.setImageIfDesired(systemSymbolName: "xmark") + } + } + + appendTabColorSection(to: menu, target: targetController) + } + + private func isTabContextMenu(_ menu: NSMenu) -> Bool { + guard NSApp.keyWindow === self else { return false } + + // These are the target selectors, at least for macOS 26. + let tabContextSelectors: Set = [ + "performClose:", + "performCloseOtherTabs:", + "moveTabToNewWindow:", + "toggleTabOverview:" + ] + + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) + return !selectorNames.isDisjoint(with: tabContextSelectors) + } + + private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { + menu.removeItems(withIdentifiers: [ + Self.tabColorSeparatorIdentifier, + Self.tabColorHeaderIdentifier, + Self.tabColorPaletteIdentifier + ]) + + let separator = NSMenuItem.separator() + separator.identifier = Self.tabColorSeparatorIdentifier + menu.addItem(separator) + + let headerItem = NSMenuItem() + headerItem.identifier = Self.tabColorHeaderIdentifier + headerItem.title = "Tab Color" + headerItem.isEnabled = false + headerItem.setImageIfDesired(systemSymbolName: "eyedropper") + menu.addItem(headerItem) + + let paletteItem = NSMenuItem() + paletteItem.identifier = Self.tabColorPaletteIdentifier + paletteItem.view = makeTabColorPaletteView( + selectedColor: tabColorSelection + ) { [weak target] color in + target?.setTabColor(color) + } + menu.addItem(paletteItem) + } +} + private func makeTabColorPaletteView( selectedColor: TerminalTabColor, selectionHandler: @escaping (TerminalTabColor) -> Void From c83bf1de75a401b0eacc6417018c1f316dcf2b94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:50:12 -0800 Subject: [PATCH 625/702] macos: simplify terminal controller a bunch --- .../Terminal/TerminalController.swift | 24 ++++--------------- .../Terminal/TerminalRestorable.swift | 5 ++-- .../Window Styles/TerminalWindow.swift | 11 ++++++--- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7941ae22e..a980723ba 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -54,16 +54,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig - /// The accent color that should be rendered for this tab. - var tabColor: TerminalTabColor = .none { - didSet { - guard tabColor != oldValue else { return } - if let terminalWindow = window as? TerminalWindow { - terminalWindow.display(tabColor: tabColor) - } - window?.invalidateRestorableState() - } - } /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] @@ -870,12 +860,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr with undoState: UndoState ) { self.init(ghostty, withSurfaceTree: undoState.surfaceTree) - self.tabColor = undoState.tabColor // Show the window and restore its frame showWindow(nil) if let window { window.setFrame(undoState.frame, display: true) + if let terminalWindow = window as? TerminalWindow { + terminalWindow.tabColor = undoState.tabColor + } // If we have a tab group and index, restore the tab to its original position if let tabGroup = undoState.tabGroup, @@ -912,7 +904,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr focusedSurface: focusedSurface?.id, tabIndex: window.tabGroup?.windows.firstIndex(of: window), tabGroup: window.tabGroup, - tabColor: tabColor) + tabColor: (window as? TerminalWindow)?.tabColor ?? .none) } //MARK: - NSWindowController @@ -954,9 +946,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr delegate: self, )) - if let terminalWindow = window as? TerminalWindow { - terminalWindow.display(tabColor: tabColor) - } // If we have a default size, we want to apply it. if let defaultSize { switch (defaultSize) { @@ -1195,11 +1184,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - - func setTabColor(_ color: TerminalTabColor) { - tabColor = color - } - @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 931739987..ce13f2620 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -15,7 +15,7 @@ class TerminalRestorableState: Codable { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode - self.tabColor = controller.tabColor + self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none } init?(coder aDecoder: NSCoder) { @@ -97,7 +97,8 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { return } - c.tabColor = state.tabColor + // Restore our tab color + (window as? TerminalWindow)?.tabColor = state.tabColor // Setup our restored state on the controller // Find the focused surface in surfaceTree diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 706165573..2828a9c56 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -46,8 +46,13 @@ class TerminalWindow: NSWindow { windowController as? TerminalController } - func display(tabColor: TerminalTabColor) { - tabColorSelection = tabColor + var tabColor: TerminalTabColor { + get { tabColorSelection } + set { + guard tabColorSelection != newValue else { return } + tabColorSelection = newValue + invalidateRestorableState() + } } // MARK: NSWindow Overrides @@ -756,7 +761,7 @@ extension TerminalWindow { paletteItem.view = makeTabColorPaletteView( selectedColor: tabColorSelection ) { [weak target] color in - target?.setTabColor(color) + (target?.window as? TerminalWindow)?.tabColor = color } menu.addItem(paletteItem) } From f71a25a62113b6646ae8ce68dae8382205ef829a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:53:56 -0800 Subject: [PATCH 626/702] macos: make the tab color indicator SwiftUI --- .../Window Styles/TerminalWindow.swift | 75 +++++++------------ 1 file changed, 28 insertions(+), 47 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 2828a9c56..5874f354e 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -25,14 +25,17 @@ class TerminalWindow: NSWindow { private let updateAccessory = NSTitlebarAccessoryViewController() /// Visual indicator that mirrors the selected tab color. - private let tabColorIndicator = TabColorIndicator() + private lazy var tabColorIndicator: NSHostingView = { + let view = NSHostingView(rootView: TabColorIndicatorView(tabColor: tabColor)) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() + + /// Sets up our tab context menu private var tabMenuObserver: NSObjectProtocol? = nil - private var tabColorSelection: TerminalTabColor = .none { - didSet { tabColorIndicator.tabColor = tabColorSelection } - } /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -46,11 +49,12 @@ class TerminalWindow: NSWindow { windowController as? TerminalController } - var tabColor: TerminalTabColor { - get { tabColorSelection } - set { - guard tabColorSelection != newValue else { return } - tabColorSelection = newValue + /// The color assigned to this window's tab. Setting this updates the tab color indicator + /// and marks the window's restorable state as dirty. + var tabColor: TerminalTabColor = .none { + didSet { + guard tabColor != oldValue else { return } + tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor) invalidateRestorableState() } } @@ -146,10 +150,7 @@ class TerminalWindow: NSWindow { // Setup the accessory view for tabs that shows our keyboard shortcuts, // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues // where buttons were not clickable. - tabColorIndicator.translatesAutoresizingMaskIntoConstraints = false - tabColorIndicator.widthAnchor.constraint(equalToConstant: 12).isActive = true - tabColorIndicator.heightAnchor.constraint(equalToConstant: 4).isActive = true - tabColorIndicator.tabColor = tabColorSelection + tabColorIndicator.rootView = TabColorIndicatorView(tabColor: tabColor) let stackView = NSStackView() stackView.orientation = .horizontal @@ -643,42 +644,22 @@ extension TerminalWindow { } +/// A pill-shaped visual indicator displayed in the tab accessory view that shows +/// the user-assigned tab color. When no color is set, the view is hidden. +private struct TabColorIndicatorView: View { + /// The tab color to display. + let tabColor: TerminalTabColor - -private final class TabColorIndicator: NSView { - var tabColor: TerminalTabColor = .none { - didSet { updateAppearance() } - } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - updateAppearance() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layout() { - super.layout() - updateAppearance() - } - - private func updateAppearance() { - guard let layer else { return } - layer.cornerRadius = bounds.height / 2 - + var body: some View { if let color = tabColor.displayColor { - alphaValue = 1 - layer.backgroundColor = color.cgColor - layer.borderWidth = 0 - layer.borderColor = nil + Capsule() + .fill(Color(color)) + .frame(width: 12, height: 4) } else { - alphaValue = 0 - layer.backgroundColor = NSColor.clear.cgColor - layer.borderWidth = 0 - layer.borderColor = nil + Capsule() + .fill(Color.clear) + .frame(width: 12, height: 4) + .hidden() } } } @@ -759,7 +740,7 @@ extension TerminalWindow { let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( - selectedColor: tabColorSelection + selectedColor: tabColor ) { [weak target] color in (target?.window as? TerminalWindow)?.tabColor = color } From 6332fb5c0197daf45c585b4752f85c8e55d86095 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 13:59:06 -0800 Subject: [PATCH 627/702] macos: some cleanup --- .../Sources/Features/Terminal/TerminalTabColor.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 1af6aa10b..9059d3202 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -13,11 +13,6 @@ enum TerminalTabColor: Int, CaseIterable, Codable { case teal case graphite - static let paletteRows: [[TerminalTabColor]] = [ - [.none, .blue, .purple, .pink, .red], - [.orange, .yellow, .green, .teal, .graphite], - ] - var localizedName: String { switch self { case .none: @@ -125,7 +120,7 @@ struct TabColorMenuView: View { var body: some View { VStack(alignment: .leading, spacing: 3) { - ForEach(TerminalTabColor.paletteRows, id: \.self) { row in + ForEach(Self.paletteRows, id: \.self) { row in HStack(spacing: 2) { ForEach(row, id: \.self) { color in TabColorSwatch( @@ -144,6 +139,11 @@ struct TabColorMenuView: View { .padding(.top, 4) .padding(.bottom, 4) } + + static let paletteRows: [[TerminalTabColor]] = [ + [.none, .blue, .purple, .pink, .red], + [.orange, .yellow, .green, .teal, .graphite], + ] /// Leading padding to align with the menu's icon gutter. /// macOS 26 introduced icons in menus, requiring additional padding. From c0deaaba4e9e3bd97eccab33c33008c6734cee97 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 11 Dec 2025 16:50:27 -0500 Subject: [PATCH 628/702] bash: use a shell command for shell integration Prior to #7044, on macOS, our shell-integrated command line would be executed under exec -l, which causes bash to be started as a login shell. This matches the macOS platform norms. The change to direct command execution meant that we'd skip that path, and bash would start as a normal interactive (non-login) shell on macOS. We fixed this in #7253 by adding `--login` to the `bash` direct command on macOS. This avoided some of the overhead of starting an extra process just to get a login shell, but it unfortunately doesn't quite match the bash environment we get when shell integration isn't enabled (namely, $0 doesn't get the login-shell-identifying "-" prefix). Instead, this change implements the approach proposed in #7254, which switches the bash shell integration path to use a .shell command, giving us the same execution environment as the non-shell-integrated command. --- src/termio/shell_integration.zig | 73 +++++--------------------------- 1 file changed, 10 insertions(+), 63 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index c2a637b80..a79e38639 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -259,7 +259,7 @@ fn setupBash( resource_dir: []const u8, env: *EnvMap, ) !?config.Command { - var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 3); + var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2); defer args.deinit(alloc); // Iterator that yields each argument in the original command line. @@ -273,11 +273,6 @@ fn setupBash( } else return null; try args.append(alloc, "--posix"); - // On macOS, we request a login shell to match that platform's norms. - if (comptime builtin.target.os.tag.isDarwin()) { - try args.append(alloc, "--login"); - } - // Stores the list of intercepted command line flags that will be passed // to our shell integration script: --norc --noprofile // We always include at least "1" so the script can differentiate between @@ -357,9 +352,8 @@ fn setupBash( ); try env.put("ENV", integ_dir); - // Since we built up a command line, we don't need to wrap it in - // ANOTHER shell anymore and can do a direct command. - return .{ .direct = try args.toOwnedSlice(alloc) }; + // Join the accumulated arguments to form the final command string. + return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) }; } test "bash" { @@ -373,12 +367,7 @@ test "bash" { const command = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?); } @@ -421,12 +410,7 @@ test "bash: inject flags" { const command = try setupBash(alloc, .{ .shell = "bash --norc" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?); } @@ -437,12 +421,7 @@ test "bash: inject flags" { const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?); } } @@ -459,24 +438,14 @@ test "bash: rcfile" { // bash --rcfile { const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } // bash --init-file { const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, ".", &env); - try testing.expect(command.?.direct.len >= 2); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } + try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } } @@ -538,35 +507,13 @@ test "bash: additional arguments" { // "-" argument separator { const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, ".", &env); - try testing.expect(command.?.direct.len >= 6); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } - - const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2; - try testing.expectEqualStrings("-", command.?.direct[offset + 0]); - try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]); - try testing.expectEqualStrings("file1", command.?.direct[offset + 2]); - try testing.expectEqualStrings("file2", command.?.direct[offset + 3]); + try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell); } // "--" argument separator { const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, ".", &env); - try testing.expect(command.?.direct.len >= 6); - try testing.expectEqualStrings("bash", command.?.direct[0]); - try testing.expectEqualStrings("--posix", command.?.direct[1]); - if (comptime builtin.target.os.tag.isDarwin()) { - try testing.expectEqualStrings("--login", command.?.direct[2]); - } - - const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2; - try testing.expectEqualStrings("--", command.?.direct[offset + 0]); - try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]); - try testing.expectEqualStrings("file1", command.?.direct[offset + 2]); - try testing.expectEqualStrings("file2", command.?.direct[offset + 3]); + try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell); } } From 2331e178351c92363dcb7b100533ffe8aa18ea3d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 14:12:24 -0800 Subject: [PATCH 629/702] macos: change tab color label to circle --- .../Terminal/Window Styles/TerminalWindow.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 5874f354e..3db1b275b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -644,7 +644,7 @@ extension TerminalWindow { } -/// A pill-shaped visual indicator displayed in the tab accessory view that shows +/// A small circle indicator displayed in the tab accessory view that shows /// the user-assigned tab color. When no color is set, the view is hidden. private struct TabColorIndicatorView: View { /// The tab color to display. @@ -652,13 +652,13 @@ private struct TabColorIndicatorView: View { var body: some View { if let color = tabColor.displayColor { - Capsule() + Circle() .fill(Color(color)) - .frame(width: 12, height: 4) + .frame(width: 6, height: 6) } else { - Capsule() + Circle() .fill(Color.clear) - .frame(width: 12, height: 4) + .frame(width: 6, height: 6) .hidden() } } From 89bdee447f64a66806b54ae6b52f2edc04920819 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 14:33:50 -0800 Subject: [PATCH 630/702] macos: selected color in tab color menu should use target's color --- .../Features/Terminal/Window Styles/TerminalWindow.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 3db1b275b..ab90f7072 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -740,7 +740,7 @@ extension TerminalWindow { let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( - selectedColor: tabColor + selectedColor: (target?.window as? TerminalWindow)?.tabColor ?? .none ) { [weak target] color in (target?.window as? TerminalWindow)?.tabColor = color } From 4d757f0f28379f8392370374a8b80dcfa4c44639 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 14:43:21 -0800 Subject: [PATCH 631/702] macos: show tab color as header for menu item so its not grey --- macos/Sources/Features/Terminal/TerminalTabColor.swift | 3 +++ .../Terminal/Window Styles/TerminalWindow.swift | 10 +--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 9059d3202..08d89324c 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -120,6 +120,9 @@ struct TabColorMenuView: View { var body: some View { VStack(alignment: .leading, spacing: 3) { + Text("Tab Color") + .padding(.bottom, 2) + ForEach(Self.paletteRows, id: \.self) { row in HStack(spacing: 2) { ForEach(row, id: \.self) { color in diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index ab90f7072..5bbf9322d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -669,7 +669,7 @@ private struct TabColorIndicatorView: View { extension TerminalWindow { private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") - private static let tabColorHeaderIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorHeader") + private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") func configureTabContextMenuIfNeeded(_ menu: NSMenu) { @@ -722,7 +722,6 @@ extension TerminalWindow { private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { menu.removeItems(withIdentifiers: [ Self.tabColorSeparatorIdentifier, - Self.tabColorHeaderIdentifier, Self.tabColorPaletteIdentifier ]) @@ -730,13 +729,6 @@ extension TerminalWindow { separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) - let headerItem = NSMenuItem() - headerItem.identifier = Self.tabColorHeaderIdentifier - headerItem.title = "Tab Color" - headerItem.isEnabled = false - headerItem.setImageIfDesired(systemSymbolName: "eyedropper") - menu.addItem(headerItem) - let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( From 32033c9e1a66cd08d924a15fd88057fab41b1d8c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:00:13 -0800 Subject: [PATCH 632/702] core: prompt_tab_title binding and apprt action --- include/ghostty.h | 3 ++- src/Surface.zig | 6 ++++++ src/apprt/action.zig | 6 ++++++ src/input/Binding.zig | 6 ++++++ src/input/command.zig | 6 ++++++ 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index cb8646560..416db8bab 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -804,6 +804,7 @@ typedef enum { GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, + GHOSTTY_ACTION_PROMPT_TAB_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, GHOSTTY_ACTION_MOUSE_VISIBILITY, @@ -831,7 +832,7 @@ typedef enum { GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, -} ghostty_action_tag_e; + } ghostty_action_tag_e; typedef union { ghostty_action_split_direction_e new_split; diff --git a/src/Surface.zig b/src/Surface.zig index 9e7ad0b97..b1919e13f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5186,6 +5186,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .prompt_tab_title => return try self.rt_app.performAction( + .{ .surface = self }, + .prompt_tab_title, + {}, + ), + .clear_screen => { // This is a duplicate of some of the logic in termio.clearScreen // but we need to do this here so we can know the answer before diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 365f525f8..f29150f13 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -192,6 +192,11 @@ pub const Action = union(Key) { /// the apprt to prompt. prompt_title, + /// Set the title of the current tab/window to a prompted value. The title + /// set via this prompt overrides any title set by the terminal and persists + /// across focus changes within the tab. It is up to the apprt to prompt. + prompt_tab_title, + /// The current working directory has changed for the target terminal. pwd: Pwd, @@ -347,6 +352,7 @@ pub const Action = union(Key) { desktop_notification, set_title, prompt_title, + prompt_tab_title, pwd, mouse_shape, mouse_visibility, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 66fe03651..e1c636ab7 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -519,6 +519,11 @@ pub const Action = union(enum) { /// version can be found by running `ghostty +version`. prompt_surface_title, + /// Change the title of the current tab/window via a pop-up prompt. The + /// title set via this prompt overrides any title set by the terminal + /// and persists across focus changes within the tab. + prompt_tab_title, + /// Create a new split in the specified direction. /// /// Valid arguments: @@ -1191,6 +1196,7 @@ pub const Action = union(enum) { .reset_font_size, .set_font_size, .prompt_surface_title, + .prompt_tab_title, .clear_screen, .select_all, .scroll_to_top, diff --git a/src/input/command.zig b/src/input/command.zig index b3f9e86b6..120c7e7e0 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -417,6 +417,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Prompt for a new title for the current terminal.", }}, + .prompt_tab_title => comptime &.{.{ + .action = .prompt_tab_title, + .title = "Change Tab Title...", + .description = "Prompt for a new title for the current tab.", + }}, + .new_split => comptime &.{ .{ .action = .{ .new_split = .left }, From e93a4a911f2631d79b8673b8e6d974f44b7b78a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:00:13 -0800 Subject: [PATCH 633/702] macos: implement prompt_tab_title --- .../Terminal/BaseTerminalController.swift | 49 +++++++++++++++++-- macos/Sources/Ghostty/Ghostty.App.swift | 29 +++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1c8e258f7..c60f0ee1d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -81,6 +81,15 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// An override title for the tab/window set by the user via prompt_tab_title. + /// When set, this takes precedence over the computed title from the terminal. + var tabTitleOverride: String? = nil { + didSet { applyTitleToWindow() } + } + + /// The last computed title from the focused surface (without the override). + private var lastComputedTitle: String = "👻" + /// The time that undo/redo operations that contain running ptys are valid for. var undoExpiration: Duration { ghostty.config.undoTimeout @@ -325,6 +334,37 @@ class BaseTerminalController: NSWindowController, self.alert = alert } + /// Prompt the user to change the tab/window title. + func promptTabTitle() { + guard let window else { return } + + let alert = NSAlert() + alert.messageText = "Change Tab Title" + alert.informativeText = "Leave blank to restore the default." + alert.alertStyle = .informational + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) + textField.stringValue = tabTitleOverride ?? window.title + alert.accessoryView = textField + + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + + alert.window.initialFirstResponder = textField + + alert.beginSheetModal(for: window) { [weak self] response in + guard let self else { return } + guard response == .alertFirstButtonReturn else { return } + + let newTitle = textField.stringValue + if newTitle.isEmpty { + self.tabTitleOverride = nil + } else { + self.tabTitleOverride = newTitle + } + } + } + /// Close a surface from a view. func closeSurface( _ view: Ghostty.SurfaceView, @@ -718,10 +758,13 @@ class BaseTerminalController: NSWindowController, } private func titleDidChange(to: String) { + lastComputedTitle = to + applyTitleToWindow() + } + + private func applyTitleToWindow() { guard let window else { return } - - // Set the main window title - window.title = to + window.title = tabTitleOverride ?? lastComputedTitle } func pwdDidChange(to: URL?) { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index f6452e54e..db98d56be 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -525,6 +525,9 @@ extension Ghostty { case GHOSTTY_ACTION_PROMPT_TITLE: return promptTitle(app, target: target) + case GHOSTTY_ACTION_PROMPT_TAB_TITLE: + return promptTabTitle(app, target: target) + case GHOSTTY_ACTION_PWD: pwdChanged(app, target: target, v: action.action.pwd) @@ -1368,6 +1371,32 @@ extension Ghostty { return true } + private static func promptTabTitle( + _ app: ghostty_app_t, + target: ghostty_target_s) -> Bool { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + guard let window = NSApp.mainWindow ?? NSApp.keyWindow, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true + + default: + assertionFailure() + return false + } + } + private static func pwdChanged( _ app: ghostty_app_t, target: ghostty_target_s, From 1f05625d3fe3ae81c0662cca27c5bc4263415a9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 00:06:57 +0000 Subject: [PATCH 634/702] build(deps): bump cachix/install-nix-action from 31.8.4 to 31.9.0 Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.8.4 to 31.9.0. - [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/0b0e072294b088b73964f1d72dfdac0951439dbd...4e002c8ec80594ecd40e759629461e26c8abed15) --- updated-dependencies: - dependency-name: cachix/install-nix-action dependency-version: 31.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 f928ed5a5..d992ba034 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -47,7 +47,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 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 82970a065..c8c0fbf66 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@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index df73198d1..fb6aef87d 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@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 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@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 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 20f674bab..18af9d909 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -84,7 +84,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -127,7 +127,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -160,7 +160,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -194,7 +194,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -237,7 +237,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -273,7 +273,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -302,7 +302,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -335,7 +335,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -381,7 +381,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -600,7 +600,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -642,7 +642,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -690,7 +690,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -725,7 +725,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -789,7 +789,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -816,7 +816,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -844,7 +844,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -871,7 +871,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -898,7 +898,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -925,7 +925,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -952,7 +952,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -986,7 +986,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1013,7 +1013,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1050,7 +1050,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -1138,7 +1138,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + - uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 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 bceb8aef1..dc3ebb2b6 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@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4 + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 From 65cf124e2c8f1dc3578efebb08ea7e8f8ac459d5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:09:08 -0800 Subject: [PATCH 635/702] core: change apprt action to enum value instead of a new one --- include/ghostty.h | 8 ++- macos/Sources/Ghostty/Ghostty.Action.swift | 14 ++++ macos/Sources/Ghostty/Ghostty.App.swift | 79 +++++++++++----------- src/Surface.zig | 6 +- src/apprt/action.zig | 17 ++--- src/apprt/gtk/class/application.zig | 20 ++++-- 6 files changed, 85 insertions(+), 59 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 416db8bab..702a88ecc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -584,6 +584,12 @@ typedef struct { const char* title; } ghostty_action_set_title_s; +// apprt.action.PromptTitle +typedef enum { + GHOSTTY_PROMPT_TITLE_SURFACE, + GHOSTTY_PROMPT_TITLE_TAB, +} ghostty_action_prompt_title_e; + // apprt.action.Pwd.C typedef struct { const char* pwd; @@ -804,7 +810,6 @@ typedef enum { GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, - GHOSTTY_ACTION_PROMPT_TAB_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, GHOSTTY_ACTION_MOUSE_VISIBILITY, @@ -848,6 +853,7 @@ typedef union { ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; + ghostty_action_prompt_title_e prompt_title; ghostty_action_pwd_s pwd; ghostty_action_mouse_shape_e mouse_shape; ghostty_action_mouse_visibility_e mouse_visibility; diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 8fce2199d..9eb7a8e46 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -127,6 +127,20 @@ extension Ghostty.Action { } } } + + enum PromptTitle { + case surface + case tab + + init(_ c: ghostty_action_prompt_title_e) { + switch c { + case GHOSTTY_PROMPT_TITLE_TAB: + self = .tab + default: + self = .surface + } + } + } } // 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 db98d56be..aff3edbc7 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -523,10 +523,7 @@ extension Ghostty { setTitle(app, target: target, v: action.action.set_title) case GHOSTTY_ACTION_PROMPT_TITLE: - return promptTitle(app, target: target) - - case GHOSTTY_ACTION_PROMPT_TAB_TITLE: - return promptTabTitle(app, target: target) + return promptTitle(app, target: target, v: action.action.prompt_title) case GHOSTTY_ACTION_PWD: pwdChanged(app, target: target, v: action.action.pwd) @@ -1353,47 +1350,49 @@ extension Ghostty { private static func promptTitle( _ app: ghostty_app_t, - target: ghostty_target_s) -> Bool { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - Ghostty.logger.warning("set title prompt does nothing with an app target") - return false + target: ghostty_target_s, + v: ghostty_action_prompt_title_e) -> Bool { + let promptTitle = Action.PromptTitle(v) + switch promptTitle { + case .surface: + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set title prompt does nothing with an app target") + return false - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return false } - guard let surfaceView = self.surfaceView(from: surface) else { return false } - surfaceView.promptTitle() + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + surfaceView.promptTitle() + return true - default: - assertionFailure() - } + default: + assertionFailure() + return false + } - return true - } + case .tab: + switch (target.tag) { + case GHOSTTY_TARGET_APP: + guard let window = NSApp.mainWindow ?? NSApp.keyWindow, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true - private static func promptTabTitle( - _ app: ghostty_app_t, - target: ghostty_target_s) -> Bool { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - guard let window = NSApp.mainWindow ?? NSApp.keyWindow, - let controller = window.windowController as? BaseTerminalController - else { return false } - controller.promptTabTitle() - return true + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return false } - guard let surfaceView = self.surfaceView(from: surface) else { return false } - guard let window = surfaceView.window, - let controller = window.windowController as? BaseTerminalController - else { return false } - controller.promptTabTitle() - return true - - default: - assertionFailure() - return false + default: + assertionFailure() + return false + } } } diff --git a/src/Surface.zig b/src/Surface.zig index b1919e13f..8cd8d253b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5183,13 +5183,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .prompt_surface_title => return try self.rt_app.performAction( .{ .surface = self }, .prompt_title, - {}, + .surface, ), .prompt_tab_title => return try self.rt_app.performAction( .{ .surface = self }, - .prompt_tab_title, - {}, + .prompt_title, + .tab, ), .clear_screen => { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index f29150f13..94965d38c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -189,13 +189,9 @@ pub const Action = union(Key) { set_title: SetTitle, /// Set the title of the target to a prompted value. It is up to - /// the apprt to prompt. - prompt_title, - - /// Set the title of the current tab/window to a prompted value. The title - /// set via this prompt overrides any title set by the terminal and persists - /// across focus changes within the tab. It is up to the apprt to prompt. - prompt_tab_title, + /// the apprt to prompt. The value specifies whether to prompt for the + /// surface title or the tab title. + prompt_title: PromptTitle, /// The current working directory has changed for the target terminal. pwd: Pwd, @@ -352,7 +348,6 @@ pub const Action = union(Key) { desktop_notification, set_title, prompt_title, - prompt_tab_title, pwd, mouse_shape, mouse_visibility, @@ -542,6 +537,12 @@ pub const MouseVisibility = enum(c_int) { hidden, }; +/// Whether to prompt for the surface title or tab title. +pub const PromptTitle = enum(c_int) { + surface, + tab, +}; + pub const MouseOverLink = struct { url: [:0]const u8, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 52a9f1a35..47c2972ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -693,7 +693,7 @@ pub const Application = extern struct { .progress_report => return Action.progressReport(target, value), - .prompt_title => return Action.promptTitle(target), + .prompt_title => return Action.promptTitle(target, value), .quit => self.quit(), @@ -2250,12 +2250,18 @@ const Action = struct { }; } - pub fn promptTitle(target: apprt.Target) bool { - switch (target) { - .app => return false, - .surface => |v| { - v.rt_surface.surface.promptTitle(); - return true; + pub fn promptTitle(target: apprt.Target, value: apprt.action.PromptTitle) bool { + switch (value) { + .surface => switch (target) { + .app => return false, + .surface => |v| { + v.rt_surface.surface.promptTitle(); + return true; + }, + }, + .tab => { + // GTK does not yet support tab title prompting + return false; }, } } From 7b48eb5c6296e8dd62066395312c452c09a0b93c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:12:48 -0800 Subject: [PATCH 636/702] macos: add change tab title to menu --- macos/Sources/App/macOS/AppDelegate.swift | 4 +++- macos/Sources/App/macOS/MainMenu.xib | 9 ++++++++- .../Features/Terminal/BaseTerminalController.swift | 4 ++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 192135c15..8baee3d89 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -68,6 +68,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuChangeTitle: NSMenuItem? + @IBOutlet private var menuChangeTabTitle: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuCommandPalette: NSMenuItem? @@ -541,7 +542,7 @@ class AppDelegate: NSObject, self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") - self.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line") + self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") @@ -609,6 +610,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) + syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 3e1084cd7..d009b9c62 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -16,6 +16,7 @@ + @@ -315,7 +316,13 @@ - + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index c60f0ee1d..05e3c8142 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1060,6 +1060,10 @@ class BaseTerminalController: NSWindowController, window.performClose(sender) } + @IBAction func changeTabTitle(_ sender: Any) { + promptTabTitle() + } + @IBAction func splitRight(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) From 65c5e72d3e17dbdfc2a45beb152e86bc301c6ac7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:22:22 -0800 Subject: [PATCH 637/702] macos: add tab title change to tab context menu --- .../Terminal/Window Styles/TerminalWindow.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 5bbf9322d..d04d7001c 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -668,6 +668,7 @@ private struct TabColorIndicatorView: View { extension TerminalWindow { private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.changeTitleMenuItem") private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") @@ -701,7 +702,7 @@ extension TerminalWindow { } } - appendTabColorSection(to: menu, target: targetController) + appendTabModifierSection(to: menu, target: targetController) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { @@ -719,9 +720,10 @@ extension TerminalWindow { return !selectorNames.isDisjoint(with: tabContextSelectors) } - private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { + private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) { menu.removeItems(withIdentifiers: [ Self.tabColorSeparatorIdentifier, + Self.changeTitleMenuItemIdentifier, Self.tabColorPaletteIdentifier ]) @@ -729,6 +731,13 @@ extension TerminalWindow { separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) + // Change Title... + let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") + changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier + changeTitleItem.target = target + changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line") + menu.addItem(changeTitleItem) + let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( From 6105344c31569ac6442c21dbaf97e48098f8c9e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:28:10 -0800 Subject: [PATCH 638/702] macos: add change tab title to right click menu --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 3 ++- src/input/command.zig | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e86df4454..130df6f44 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1417,8 +1417,9 @@ extension Ghostty { item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") menu.addItem(.separator()) - item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") + item = menu.addItem(withTitle: "Change Terminal Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") return menu } diff --git a/src/input/command.zig b/src/input/command.zig index 120c7e7e0..639fc6e39 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -413,7 +413,7 @@ fn actionCommands(action: Action.Key) []const Command { .prompt_surface_title => comptime &.{.{ .action = .prompt_surface_title, - .title = "Change Title...", + .title = "Change Terminal Title...", .description = "Prompt for a new title for the current terminal.", }}, From 50bbced0c94e586fda68cab1a7299c77947fd9fe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Dec 2025 16:40:09 -0800 Subject: [PATCH 639/702] macos: add title override to restorable state --- .../Features/Terminal/BaseTerminalController.swift | 10 +++++----- .../Sources/Features/Terminal/TerminalRestorable.swift | 8 +++++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 05e3c8142..6336f0f55 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -83,7 +83,7 @@ class BaseTerminalController: NSWindowController, /// An override title for the tab/window set by the user via prompt_tab_title. /// When set, this takes precedence over the computed title from the terminal. - var tabTitleOverride: String? = nil { + var titleOverride: String? = nil { didSet { applyTitleToWindow() } } @@ -344,7 +344,7 @@ class BaseTerminalController: NSWindowController, alert.alertStyle = .informational let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) - textField.stringValue = tabTitleOverride ?? window.title + textField.stringValue = titleOverride ?? window.title alert.accessoryView = textField alert.addButton(withTitle: "OK") @@ -358,9 +358,9 @@ class BaseTerminalController: NSWindowController, let newTitle = textField.stringValue if newTitle.isEmpty { - self.tabTitleOverride = nil + self.titleOverride = nil } else { - self.tabTitleOverride = newTitle + self.titleOverride = newTitle } } } @@ -764,7 +764,7 @@ class BaseTerminalController: NSWindowController, private func applyTitleToWindow() { guard let window else { return } - window.title = tabTitleOverride ?? lastComputedTitle + window.title = titleOverride ?? lastComputedTitle } func pwdDidChange(to: URL?) { diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index ce13f2620..425f7ffb1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,18 +4,20 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 6 + static let version: Int = 7 let focusedSurface: String? let surfaceTree: SplitTree let effectiveFullscreenMode: FullscreenMode? let tabColor: TerminalTabColor + let titleOverride: String? init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none + self.titleOverride = controller.titleOverride } init?(coder aDecoder: NSCoder) { @@ -34,6 +36,7 @@ class TerminalRestorableState: Codable { self.focusedSurface = v.value.focusedSurface self.effectiveFullscreenMode = v.value.effectiveFullscreenMode self.tabColor = v.value.tabColor + self.titleOverride = v.value.titleOverride } func encode(with coder: NSCoder) { @@ -100,6 +103,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // Restore our tab color (window as? TerminalWindow)?.tabColor = state.tabColor + // Restore the tab title override + c.titleOverride = state.titleOverride + // Setup our restored state on the controller // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { From 65539d0d54faef71d49afc23a7b6fd0a875d2bcb Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:21:17 +0800 Subject: [PATCH 640/702] CONTRIBUTING: limit AI assistance to code only I think at this point all moderators and helpers can agree with me in that LLM-generated responses are a blight upon this Earth. Also probably worth putting in a clause against AI-generated assets (cf. the Commit Goods situation) --- CONTRIBUTING.md | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4285f42f..a5f9213c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,38 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -As a small exception, trivial tab-completion doesn't need to be disclosed, -so long as it is limited to single keywords or short phrases. + +**We currently restrict AI assistance to code changes only.** +No AI-generated media, e.g. artwork, icons, videos and other assets is +allowed, as it goes against the methodology and ethos behind Ghostty. +While AI-assisted code can help with productive prototyping, creative +inspiration and even automated bugfinding, we have currently found zero +benefit to AI-generated assets. Instead, we are far more interested and +invested in funding professional work done by human designers and artists. +If you intend to submit AI-generated assets to Ghostty, sorry, +**we are not interested**. + +Likewise, all community interactions, including all comments on issues and +discussions and all PR titles and descriptions **must be composed by a human**. +Community moderators and Ghostty maintainers reserve the right to mark +AI-generated responses as spam or disruptive content, and ban users who have +been repeatedly caught relying entirely on LLMs during interactions. + +> [!NOTE] +> If your English isn't the best and you are currently relying on an LLM to +> translate your responses, don't fret — usually we maintainers will be able +> to understand your messages well enough. We'd like to encourage real humans +> to interact with each other more, and the positive impact of genuine, +> responsive yet imperfect human interaction more than makes up for any +> language barrier. +> +> Please write your responses yourself, to the best of your ability. +> We greatly appreciate it. Thank you. ❤️ + +Minor exceptions to this policy include trivial AI-generated tab completion +functionality, as it usually does not impact the quality of the code and +do not need to be disclosed, and commit titles and messages, which are often +generated by AI coding agents. The submitter must have also tested the pull request on all impacted platforms, and it's **highly discouraged** to code for an unfamiliar platform @@ -32,11 +62,6 @@ with AI assistance alone: if you only have a macOS machine, do **not** ask AI to write the equivalent GTK code, and vice versa — someone else with more expertise will eventually get to it and do it for you. -Even though using AI to generate responses on a PR is allowed when properly -disclosed, **we do not encourage you to do so**. Often, the positive impact -of genuine, responsive human interaction more than makes up for any language -barrier. ❤️ - An example disclosure: > This PR was written primarily by Claude Code. From 5e049e1b3af15db4878104b87eb7646caa1fd356 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:46:05 +0800 Subject: [PATCH 641/702] CONTRIBUTING: AI-assisted != AI-generated --- CONTRIBUTING.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5f9213c0..75aa42676 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,14 +17,22 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well. > [!IMPORTANT] > -> If you are using **any kind of AI assistance** to contribute to Ghostty, -> it must be disclosed in the pull request. +> The Ghostty project allows AI-**assisted** _code contributions_, which +> must be properly disclosed in the pull request. If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -**We currently restrict AI assistance to code changes only.** +**Note that AI _assistance_ does not equal AI _generation_**. We require +a significant amount of human accountability, involvement and interaction +even within AI-assisted contributions. Contributors are required to be able +to understand the AI-assisted output, and be able to reason with it and +answer critical questions about it. Should a PR see no visible human +accountability and involvement, or it is so broken that it requires significant +rework to be acceptable, **we reserve the right to close it without hesitation**. + +**In addition, we currently restrict AI assistance to code changes only.** No AI-generated media, e.g. artwork, icons, videos and other assets is allowed, as it goes against the methodology and ethos behind Ghostty. While AI-assisted code can help with productive prototyping, creative @@ -32,7 +40,7 @@ inspiration and even automated bugfinding, we have currently found zero benefit to AI-generated assets. Instead, we are far more interested and invested in funding professional work done by human designers and artists. If you intend to submit AI-generated assets to Ghostty, sorry, -**we are not interested**. +we are not interested. Likewise, all community interactions, including all comments on issues and discussions and all PR titles and descriptions **must be composed by a human**. @@ -85,13 +93,6 @@ work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! -When using AI assistance, we expect a fairly high level of accountability -and responsibility from contributors, and expect them to understand the code -that is produced and be able to answer critical questions about it. It -isn't a maintainers job to review a PR so broken that it requires -significant rework to be acceptable, and we **reserve the right to close -these PRs without hesitation**. - Please be respectful to maintainers and disclose AI assistance. ## Quick Guide From 8a1bb215c13e27f16e46d74bf59a48fc730d9b1b Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:54:22 +0800 Subject: [PATCH 642/702] CONTRIBUTING: further clarifications --- CONTRIBUTING.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75aa42676..d5fb606b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,10 +27,10 @@ which AI assistance was used (e.g. docs only vs. code generation). **Note that AI _assistance_ does not equal AI _generation_**. We require a significant amount of human accountability, involvement and interaction even within AI-assisted contributions. Contributors are required to be able -to understand the AI-assisted output, and be able to reason with it and -answer critical questions about it. Should a PR see no visible human -accountability and involvement, or it is so broken that it requires significant -rework to be acceptable, **we reserve the right to close it without hesitation**. +to understand the AI-assisted output, reason with it and answer critical +questions about it. Should a PR see no visible human accountability and +involvement, or it is so broken that it requires significant rework to be +acceptable, **we reserve the right to close it without hesitation**. **In addition, we currently restrict AI assistance to code changes only.** No AI-generated media, e.g. artwork, icons, videos and other assets is @@ -57,6 +57,9 @@ been repeatedly caught relying entirely on LLMs during interactions. > language barrier. > > Please write your responses yourself, to the best of your ability. +> If you do feel the need to polish your sentences, however, please use +> dedicated translation software rather than an LLM. +> > We greatly appreciate it. Thank you. ❤️ Minor exceptions to this policy include trivial AI-generated tab completion From 315c8852a8e4746dd352486486abf8ab982ad87d Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 12 Dec 2025 18:58:52 +0800 Subject: [PATCH 643/702] CONTRIBUTING: reorganize paragraphs --- CONTRIBUTING.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5fb606b4..8b8c4d7f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,13 +24,20 @@ If you are using any kind of AI assistance while contributing to Ghostty, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). -**Note that AI _assistance_ does not equal AI _generation_**. We require -a significant amount of human accountability, involvement and interaction -even within AI-assisted contributions. Contributors are required to be able -to understand the AI-assisted output, reason with it and answer critical -questions about it. Should a PR see no visible human accountability and -involvement, or it is so broken that it requires significant rework to be -acceptable, **we reserve the right to close it without hesitation**. +The submitter must have also tested the pull request on all impacted +platforms, and it's **highly discouraged** to code for an unfamiliar platform +with AI assistance alone: if you only have a macOS machine, do **not** ask AI +to write the equivalent GTK code, and vice versa — someone else with more +expertise will eventually get to it and do it for you. + +> [!WARNING] +> **Note that AI _assistance_ does not equal AI _generation_**. We require +> a significant amount of human accountability, involvement and interaction +> even within AI-assisted contributions. Contributors are required to be able +> to understand the AI-assisted output, reason with it and answer critical +> questions about it. Should a PR see no visible human accountability and +> involvement, or it is so broken that it requires significant rework to be +> acceptable, **we reserve the right to close it without hesitation**. **In addition, we currently restrict AI assistance to code changes only.** No AI-generated media, e.g. artwork, icons, videos and other assets is @@ -67,12 +74,6 @@ functionality, as it usually does not impact the quality of the code and do not need to be disclosed, and commit titles and messages, which are often generated by AI coding agents. -The submitter must have also tested the pull request on all impacted -platforms, and it's **highly discouraged** to code for an unfamiliar platform -with AI assistance alone: if you only have a macOS machine, do **not** ask AI -to write the equivalent GTK code, and vice versa — someone else with more -expertise will eventually get to it and do it for you. - An example disclosure: > This PR was written primarily by Claude Code. From 04fecd7c07fccad423ab1c33324a1997e142b6e2 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 11 Dec 2025 21:02:42 -0500 Subject: [PATCH 644/702] os/shell: introduce ShellCommandBuilder This builder is an efficient way to construct space-separated shell command strings. We use it in setupBash to avoid using an intermediate array of arguments to construct our bash command line. --- src/os/shell.zig | 77 ++++++++++++++++++++++++++++++++ src/termio/shell_integration.zig | 20 ++++----- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/os/shell.zig b/src/os/shell.zig index 9fce3e385..fe8f1b2fd 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -1,7 +1,84 @@ const std = @import("std"); const testing = std.testing; +const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; +/// Builder for constructing space-separated shell command strings. +/// Uses a caller-provided allocator (typically with stackFallback). +pub const ShellCommandBuilder = struct { + buffer: std.Io.Writer.Allocating, + + pub fn init(allocator: Allocator) ShellCommandBuilder { + return .{ .buffer = .init(allocator) }; + } + + pub fn deinit(self: *ShellCommandBuilder) void { + self.buffer.deinit(); + } + + /// Append an argument to the command with automatic space separation. + pub fn appendArg(self: *ShellCommandBuilder, arg: []const u8) (Allocator.Error || Writer.Error)!void { + if (arg.len == 0) return; + if (self.buffer.written().len > 0) { + try self.buffer.writer.writeByte(' '); + } + try self.buffer.writer.writeAll(arg); + } + + /// Get the final null-terminated command string, transferring ownership to caller. + /// Calling deinit() after this is safe but unnecessary. + pub fn toOwnedSlice(self: *ShellCommandBuilder) Allocator.Error![:0]const u8 { + return try self.buffer.toOwnedSliceSentinel(0); + } +}; + +test ShellCommandBuilder { + // Empty command + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try testing.expectEqualStrings("", cmd.buffer.written()); + } + + // Single arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // Multiple args + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + try cmd.appendArg("-l"); + try testing.expectEqualStrings("bash --posix -l", cmd.buffer.written()); + } + + // Empty arg + { + var cmd = ShellCommandBuilder.init(testing.allocator); + defer cmd.deinit(); + try cmd.appendArg("bash"); + try cmd.appendArg(""); + try testing.expectEqualStrings("bash", cmd.buffer.written()); + } + + // toOwnedSlice + { + var cmd = ShellCommandBuilder.init(testing.allocator); + try cmd.appendArg("bash"); + try cmd.appendArg("--posix"); + const result = try cmd.toOwnedSlice(); + defer testing.allocator.free(result); + try testing.expectEqualStrings("bash --posix", result); + try testing.expectEqual(@as(u8, 0), result[result.len]); + } +} + /// Writer that escapes characters that shells treat specially to reduce the /// risk of injection attacks or other such weirdness. Specifically excludes /// linefeeds so that they can be used to delineate lists of file paths. diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index a79e38639..128b345ea 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -259,8 +259,9 @@ fn setupBash( resource_dir: []const u8, env: *EnvMap, ) !?config.Command { - var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 2); - defer args.deinit(alloc); + var stack_fallback = std.heap.stackFallback(4096, alloc); + var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get()); + defer cmd.deinit(); // Iterator that yields each argument in the original command line. // This will allocate once proportionate to the command line length. @@ -269,9 +270,9 @@ fn setupBash( // Start accumulating arguments with the executable and initial flags. if (iter.next()) |exe| { - try args.append(alloc, try alloc.dupeZ(u8, exe)); + try cmd.appendArg(exe); } else return null; - try args.append(alloc, "--posix"); + try cmd.appendArg("--posix"); // Stores the list of intercepted command line flags that will be passed // to our shell integration script: --norc --noprofile @@ -304,17 +305,17 @@ fn setupBash( if (std.mem.indexOfScalar(u8, arg, 'c') != null) { return null; } - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { // All remaining arguments should be passed directly to the shell // command. We shouldn't perform any further option processing. - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); while (iter.next()) |remaining_arg| { - try args.append(alloc, try alloc.dupeZ(u8, remaining_arg)); + try cmd.appendArg(remaining_arg); } break; } else { - try args.append(alloc, try alloc.dupeZ(u8, arg)); + try cmd.appendArg(arg); } } try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]); @@ -352,8 +353,7 @@ fn setupBash( ); try env.put("ENV", integ_dir); - // Join the accumulated arguments to form the final command string. - return .{ .shell = try std.mem.joinZ(alloc, " ", args.items) }; + return .{ .shell = try cmd.toOwnedSlice() }; } test "bash" { From 12bb2f3f4775fe1f203e7e0ec4c93ebc7c51062f Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk Date: Fri, 10 Oct 2025 12:30:55 -0600 Subject: [PATCH 645/702] feat: add readonly surface mode --- include/ghostty.h | 1 + src/Surface.zig | 31 ++++++++++++++++++++++++++++- src/apprt/action.zig | 6 ++++++ src/apprt/gtk/class/application.zig | 4 ++++ src/input/Binding.zig | 11 ++++++++++ src/input/command.zig | 6 ++++++ 6 files changed, 58 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 702a88ecc..cd716e38f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -797,6 +797,7 @@ typedef enum { GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, + GHOSTTY_ACTION_TOGGLE_READONLY, GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_RESET_WINDOW_SIZE, diff --git a/src/Surface.zig b/src/Surface.zig index 8cd8d253b..951ef14ef 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -145,6 +145,12 @@ focused: bool = true, /// Used to determine whether to continuously scroll. selection_scroll_active: bool = false, +/// True if the surface is in read-only mode. When read-only, no input +/// is sent to the PTY but terminal-level operations like selections, +/// scrolling, and copy/paste keybinds still work. Warn before quit is +/// always enabled in this state. +readonly: bool = false, + /// Used to send notifications that long running commands have finished. /// Requires that shell integration be active. Should represent a nanosecond /// precision timestamp. It does not necessarily need to correspond to the @@ -871,6 +877,9 @@ pub fn deactivateInspector(self: *Surface) void { /// True if the surface requires confirmation to quit. This should be called /// by apprt to determine if the surface should confirm before quitting. pub fn needsConfirmQuit(self: *Surface) bool { + // If the surface is in read-only mode, always require confirmation + if (self.readonly) return true; + // If the child has exited, then our process is certainly not alive. // We check this first to avoid the locking overhead below. if (self.child_exited) return false; @@ -2559,6 +2568,12 @@ pub fn keyCallback( if (insp_ev) |*ev| ev else null, )) |v| return v; + // If the surface is in read-only mode, we consume the key event here + // without sending it to the PTY. + if (self.readonly) { + return .consumed; + } + // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { self.renderer_state.mutex.lock(); @@ -3267,7 +3282,9 @@ pub fn scrollCallback( // we convert to cursor keys. This only happens if we're: // (1) alt screen (2) no explicit mouse reporting and (3) alt // scroll mode enabled. - if (self.io.terminal.screens.active_key == .alternate and + // Additionally, we don't send cursor keys if the surface is in read-only mode. + if (!self.readonly and + self.io.terminal.screens.active_key == .alternate and self.io.terminal.flags.mouse_event == .none and self.io.terminal.modes.get(.mouse_alternate_scroll)) { @@ -3393,6 +3410,9 @@ fn mouseReport( assert(self.config.mouse_reporting); assert(self.io.terminal.flags.mouse_event != .none); + // If the surface is in read-only mode, do not send mouse reports to the PTY + if (self.readonly) return; + // Depending on the event, we may do nothing at all. switch (self.io.terminal.flags.mouse_event) { .none => unreachable, // checked by assert above @@ -5383,6 +5403,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_readonly => { + self.readonly = !self.readonly; + return try self.rt_app.performAction( + .{ .surface = self }, + .toggle_readonly, + {}, + ); + }, + .reset_window_size => return try self.rt_app.performAction( .{ .surface = self }, .reset_window_size, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 94965d38c..83e2f5011 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -139,6 +139,11 @@ pub const Action = union(Key) { /// to take up the entire window. toggle_split_zoom, + /// Toggle whether the surface is in read-only mode. When read-only, + /// no input is sent to the PTY but terminal-level operations like + /// selections, scrolling, and copy/paste keybinds still work. + toggle_readonly, + /// Present the target terminal whether its a tab, split, or window. present_terminal, @@ -335,6 +340,7 @@ pub const Action = union(Key) { resize_split, equalize_splits, toggle_split_zoom, + toggle_readonly, present_terminal, size_limit, reset_window_size, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 47c2972ac..bbf408e02 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -724,6 +724,10 @@ pub const Application = extern struct { .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), .toggle_split_zoom => return Action.toggleSplitZoom(target), + .toggle_readonly => { + // The readonly state is managed in Surface.zig. + return true; + }, .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index e1c636ab7..d368c48b2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -552,6 +552,16 @@ pub const Action = union(enum) { /// reflect this by displaying an icon indicating the zoomed state. toggle_split_zoom, + /// Toggle read-only mode for the current surface. + /// + /// When a surface is in read-only mode: + /// - No input is sent to the PTY (mouse events, key encoding) + /// - Input can still be used at the terminal level to make selections, + /// copy/paste (keybinds), scroll, etc. + /// - Warn before quit is always enabled in this state even if an active + /// process is not running + toggle_readonly, + /// Resize the current split in the specified direction and amount in /// pixels. The two arguments should be joined with a comma (`,`), /// like in `resize_split:up,10`. @@ -1241,6 +1251,7 @@ pub const Action = union(enum) { .new_split, .goto_split, .toggle_split_zoom, + .toggle_readonly, .resize_split, .equalize_splits, .inspector, diff --git a/src/input/command.zig b/src/input/command.zig index 639fc6e39..ce218718f 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -485,6 +485,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle the zoom state of the current split.", }}, + .toggle_readonly => comptime &.{.{ + .action = .toggle_readonly, + .title = "Toggle Read-Only Mode", + .description = "Toggle read-only mode for the current surface.", + }}, + .equalize_splits => comptime &.{.{ .action = .equalize_splits, .title = "Equalize Splits", From 547bcd261dcbd25bfab99d3fb00c2f93af994605 Mon Sep 17 00:00:00 2001 From: Matthew Hrehirchuk Date: Tue, 21 Oct 2025 09:57:14 -0600 Subject: [PATCH 646/702] fix: removed apprt action for toggle_readonly --- include/ghostty.h | 1 - src/Surface.zig | 6 +----- src/apprt/action.zig | 6 ------ src/apprt/gtk/class/application.zig | 4 ---- 4 files changed, 1 insertion(+), 16 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index cd716e38f..702a88ecc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -797,7 +797,6 @@ typedef enum { GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, - GHOSTTY_ACTION_TOGGLE_READONLY, GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_RESET_WINDOW_SIZE, diff --git a/src/Surface.zig b/src/Surface.zig index 951ef14ef..7bfdad665 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5405,11 +5405,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle_readonly => { self.readonly = !self.readonly; - return try self.rt_app.performAction( - .{ .surface = self }, - .toggle_readonly, - {}, - ); + return true; }, .reset_window_size => return try self.rt_app.performAction( diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 83e2f5011..94965d38c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -139,11 +139,6 @@ pub const Action = union(Key) { /// to take up the entire window. toggle_split_zoom, - /// Toggle whether the surface is in read-only mode. When read-only, - /// no input is sent to the PTY but terminal-level operations like - /// selections, scrolling, and copy/paste keybinds still work. - toggle_readonly, - /// Present the target terminal whether its a tab, split, or window. present_terminal, @@ -340,7 +335,6 @@ pub const Action = union(Key) { resize_split, equalize_splits, toggle_split_zoom, - toggle_readonly, present_terminal, size_limit, reset_window_size, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index bbf408e02..47c2972ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -724,10 +724,6 @@ pub const Application = extern struct { .toggle_window_decorations => return Action.toggleWindowDecorations(target), .toggle_command_palette => return Action.toggleCommandPalette(target), .toggle_split_zoom => return Action.toggleSplitZoom(target), - .toggle_readonly => { - // The readonly state is managed in Surface.zig. - return true; - }, .show_on_screen_keyboard => return Action.showOnScreenKeyboard(target), .command_finished => return Action.commandFinished(target, value), From b58ac983cfeecded082c7f51fe9149062952907e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 07:29:42 -0800 Subject: [PATCH 647/702] docs changes --- src/Surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index e4ba605f6..1ea7a7201 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -147,7 +147,7 @@ selection_scroll_active: bool = false, /// True if the surface is in read-only mode. When read-only, no input /// is sent to the PTY but terminal-level operations like selections, -/// scrolling, and copy/paste keybinds still work. Warn before quit is +/// (native) scrolling, and copy keybinds still work. Warn before quit is /// always enabled in this state. readonly: bool = false, From 2d9c83dbb7ee50471f8326f3687651d2a944c350 Mon Sep 17 00:00:00 2001 From: Michael Bommarito Date: Fri, 12 Dec 2025 13:36:36 -0500 Subject: [PATCH 648/702] fix: bash shell integration use-after-free bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ShellCommandBuilder uses a stackFallback allocator, which means toOwnedSlice() may return memory allocated on the stack. When setupBash() returns, this stack memory becomes invalid, causing a use-after-free. This manifested as garbage data in the shell command string, often appearing as errors like "/bin/sh: 1: ically: not found" (where "ically" was part of nearby memory, likely from the comment "automatically"). The fix copies the command string to the arena allocator before returning, ensuring the memory remains valid for the lifetime of the command. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/termio/shell_integration.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 128b345ea..71492230e 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -353,7 +353,11 @@ fn setupBash( ); try env.put("ENV", integ_dir); - return .{ .shell = try cmd.toOwnedSlice() }; + // Get the command string from the builder, then copy it to the arena + // allocator. The stackFallback allocator's memory becomes invalid after + // this function returns, so we must copy to the arena. + const cmd_str = try cmd.toOwnedSlice(); + return .{ .shell = try alloc.dupeZ(u8, cmd_str) }; } test "bash" { From 29fdb541d56f980afa53b6ea3ef7c8985317e1de Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 12:00:20 -0800 Subject: [PATCH 649/702] make all IO message queueing go through queueIo so we can intercept --- src/Surface.zig | 85 ++++++++++++++++++++++++------------------- src/termio/Termio.zig | 5 ++- 2 files changed, 51 insertions(+), 39 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1ea7a7201..819972509 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -818,6 +818,15 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { }; } +/// Queue a message for the IO thread. +fn queueIo( + self: *Surface, + msg: termio.Message, + mutex: termio.Termio.MutexState, +) void { + self.io.queueMessage(msg, mutex); +} + /// 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. @@ -849,7 +858,7 @@ pub fn activateInspector(self: *Surface) !void { // Notify our components we have an inspector active _ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = true }, .unlocked); + self.queueIo(.{ .inspector = true }, .unlocked); } /// Deactivate the inspector and stop collecting any information. @@ -866,7 +875,7 @@ pub fn deactivateInspector(self: *Surface) void { // Notify our components we have deactivated inspector _ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); - self.io.queueMessage(.{ .inspector = false }, .unlocked); + self.queueIo(.{ .inspector = false }, .unlocked); // Deinit the inspector insp.deinit(); @@ -938,7 +947,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { // We always use an allocating message because we don't know // the length of the title and this isn't a performance critical // path. - self.io.queueMessage(.{ + self.queueIo(.{ .write_alloc = .{ .alloc = self.alloc, .data = data, @@ -1130,7 +1139,7 @@ fn selectionScrollTick(self: *Surface) !void { // If our screen changed while this is happening, we stop our // selection scroll. if (self.mouse.left_click_screen != t.screens.active_key) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -1362,7 +1371,7 @@ fn reportColorScheme(self: *Surface, force: bool) void { .dark => "\x1B[?997;1n", }; - self.io.queueMessage(.{ .write_stable = output }, .unlocked); + self.queueIo(.{ .write_stable = output }, .unlocked); } fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { @@ -1735,7 +1744,7 @@ pub fn updateConfig( errdefer termio_config_ptr.deinit(); _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); - self.io.queueMessage(.{ + self.queueIo(.{ .change_config = .{ .alloc = self.alloc, .ptr = termio_config_ptr, @@ -2301,7 +2310,7 @@ fn setCellSize(self: *Surface, size: rendererpkg.CellSize) !void { self.balancePaddingIfNeeded(); // Notify the terminal - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); // Update our terminal default size if necessary. self.recomputeInitialSize() catch |err| { @@ -2404,7 +2413,7 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void { } // Mail the IO thread - self.io.queueMessage(.{ .resize = self.size }, .unlocked); + self.queueIo(.{ .resize = self.size }, .unlocked); } /// Recalculate the balanced padding if needed. @@ -2686,7 +2695,7 @@ pub fn keyCallback( } errdefer write_req.deinit(); - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -2915,7 +2924,7 @@ fn endKeySequence( if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { - self.io.queueMessage(switch (write_req) { + self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, .alloc => |v| .{ .write_alloc = v }, @@ -3141,7 +3150,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { self.renderer_state.mutex.lock(); self.io.terminal.flags.focused = focused; self.renderer_state.mutex.unlock(); - self.io.queueMessage(.{ .focused = focused }, .unlocked); + self.queueIo(.{ .focused = focused }, .unlocked); } } @@ -3307,7 +3316,7 @@ pub fn scrollCallback( }; }; for (0..y.magnitude()) |_| { - self.io.queueMessage(.{ .write_stable = seq }, .locked); + self.queueIo(.{ .write_stable = seq }, .locked); } } @@ -3532,7 +3541,7 @@ fn mouseReport( data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1; // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = 6, } }, .locked); @@ -3555,7 +3564,7 @@ fn mouseReport( i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(i), } }, .locked); @@ -3576,7 +3585,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3593,7 +3602,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3622,7 +3631,7 @@ fn mouseReport( }); // Ask our IO thread to write the data - self.io.queueMessage(.{ .write_small = .{ + self.queueIo(.{ .write_small = .{ .data = data, .len = @intCast(resp.len), } }, .locked); @@ -3774,7 +3783,7 @@ pub fn mouseButtonCallback( // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .unlocked, ); @@ -4131,7 +4140,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B"; }; for (0..@abs(path.y)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } if (path.x != 0) { @@ -4141,7 +4150,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C"; }; for (0..@abs(path.x)) |_| { - self.io.queueMessage(.{ .write_stable = arrow }, .locked); + self.queueIo(.{ .write_stable = arrow }, .locked); } } } @@ -4414,7 +4423,7 @@ pub fn cursorPosCallback( // Stop selection scrolling when inside the viewport within a 1px buffer // for fullscreen windows, but only when selection scrolling is active. if (pos.y >= 1 and self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = false }, .locked, ); @@ -4514,7 +4523,7 @@ pub fn cursorPosCallback( if ((pos.y <= 1 or pos.y > max_y - 1) and !self.selection_scroll_active) { - self.io.queueMessage( + self.queueIo( .{ .selection_scroll = true }, .locked, ); @@ -4890,7 +4899,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}), else => unreachable, }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, full_data, ), .unlocked); @@ -4917,7 +4926,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); return true; }; - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, text, ), .unlocked); @@ -4950,9 +4959,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; if (normal) { - self.io.queueMessage(.{ .write_stable = ck.normal }, .unlocked); + self.queueIo(.{ .write_stable = ck.normal }, .unlocked); } else { - self.io.queueMessage(.{ .write_stable = ck.application }, .unlocked); + self.queueIo(.{ .write_stable = ck.application }, .unlocked); } }, @@ -5225,19 +5234,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (self.io.terminal.screens.active_key == .alternate) return false; } - self.io.queueMessage(.{ + self.queueIo(.{ .clear_screen = .{ .history = true }, }, .unlocked); }, .scroll_to_top => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .top = {} }, }, .unlocked); }, .scroll_to_bottom => { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .bottom = {} }, }, .unlocked); }, @@ -5267,14 +5276,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_up => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = -1 * rows }, }, .unlocked); }, .scroll_page_down => { const rows: isize = @intCast(self.size.grid().rows); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = rows }, }, .unlocked); }, @@ -5282,19 +5291,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_page_fractional => |fraction| { const rows: f32 = @floatFromInt(self.size.grid().rows); const delta: isize = @intFromFloat(@trunc(fraction * rows)); - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = delta }, }, .unlocked); }, .scroll_page_lines => |lines| { - self.io.queueMessage(.{ + self.queueIo(.{ .scroll_viewport = .{ .delta = lines }, }, .unlocked); }, .jump_to_prompt => |delta| { - self.io.queueMessage(.{ + self.queueIo(.{ .jump_to_prompt = @intCast(delta), }, .unlocked); }, @@ -5514,7 +5523,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }; }, - .io => self.io.queueMessage(.{ .crash = {} }, .unlocked), + .io => self.queueIo(.{ .crash = {} }, .unlocked), }, .adjust_selection => |direction| { @@ -5712,7 +5721,7 @@ fn writeScreenFile( }, .url = path, }), - .paste => self.io.queueMessage(try termio.Message.writeReq( + .paste => self.queueIo(try termio.Message.writeReq( self.alloc, path, ), .unlocked), @@ -5852,7 +5861,7 @@ fn completeClipboardPaste( }; for (vecs) |vec| if (vec.len > 0) { - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, vec, ), .unlocked); @@ -5898,7 +5907,7 @@ fn completeClipboardReadOSC52( const encoded = enc.encode(buf[prefix.len..], data); assert(encoded.len == size); - self.io.queueMessage(try termio.Message.writeReq( + self.queueIo(try termio.Message.writeReq( self.alloc, buf, ), .unlocked); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 53df00433..7263418a7 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -22,6 +22,9 @@ const configpkg = @import("../config.zig"); const log = std.log.scoped(.io_exec); +/// Mutex state argument for queueMessage. +pub const MutexState = enum { locked, unlocked }; + /// Allocator alloc: Allocator, @@ -380,7 +383,7 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void { pub fn queueMessage( self: *Termio, msg: termio.Message, - mutex: enum { locked, unlocked }, + mutex: MutexState, ) void { self.mailbox.send(msg, switch (mutex) { .locked => self.renderer_state.mutex, From 6dd9a74e6e2318ca313638e96c8d3cd4df41bfd6 Mon Sep 17 00:00:00 2001 From: Michael Hazan Date: Fri, 12 Dec 2025 22:56:06 +0200 Subject: [PATCH 650/702] fix(docs): `window-decoration` is now `none` instead of `false` --- src/config/Config.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 20256e951..1deb3e532 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1329,7 +1329,7 @@ maximize: bool = false, /// new windows, not just the first one. /// /// On macOS, this setting does not work if window-decoration is set to -/// "false", because native fullscreen on macOS requires window decorations +/// "none", because native fullscreen on macOS requires window decorations /// to be set. fullscreen: bool = false, @@ -2825,7 +2825,7 @@ keybind: Keybinds = .{}, /// also known as the traffic lights, that allow you to close, miniaturize, and /// zoom the window. /// -/// This setting has no effect when `window-decoration = false` or +/// This setting has no effect when `window-decoration = none` or /// `macos-titlebar-style = hidden`, as the window buttons are always hidden in /// these modes. /// @@ -2866,7 +2866,7 @@ keybind: Keybinds = .{}, /// macOS 14 does not have this issue and any other macOS version has not /// been tested. /// -/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`, +/// The "hidden" style hides the titlebar. Unlike `window-decoration = none`, /// however, it does not remove the frame from the window or cause it to have /// squared corners. Changing to or from this option at run-time may affect /// existing windows in buggy ways. @@ -3205,7 +3205,7 @@ else /// manager's simple titlebar. The behavior of this option will vary with your /// window manager. /// -/// This option does nothing when `window-decoration` is false or when running +/// This option does nothing when `window-decoration` is none or when running /// under macOS. @"gtk-titlebar": bool = true, From 0bf3642939122bcc0beea45929f4d5d4ea14335a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:07:43 -0800 Subject: [PATCH 651/702] core: manage read-only through queueIo --- src/Surface.zig | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 819972509..19dc086dd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -819,11 +819,26 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { } /// Queue a message for the IO thread. +/// +/// We centralize all our logic into this spot so we can intercept +/// messages for example in readonly mode. fn queueIo( self: *Surface, msg: termio.Message, mutex: termio.Termio.MutexState, ) void { + // In readonly mode, we don't allow any writes through to the pty. + if (self.readonly) { + switch (msg) { + .write_small, + .write_stable, + .write_alloc, + => return, + + else => {}, + } + } + self.io.queueMessage(msg, mutex); } @@ -3291,9 +3306,7 @@ pub fn scrollCallback( // we convert to cursor keys. This only happens if we're: // (1) alt screen (2) no explicit mouse reporting and (3) alt // scroll mode enabled. - // Additionally, we don't send cursor keys if the surface is in read-only mode. - if (!self.readonly and - self.io.terminal.screens.active_key == .alternate and + if (self.io.terminal.screens.active_key == .alternate and self.io.terminal.flags.mouse_event == .none and self.io.terminal.modes.get(.mouse_alternate_scroll)) { @@ -3402,10 +3415,9 @@ pub fn contentScaleCallback(self: *Surface, content_scale: apprt.ContentScale) ! const MouseReportAction = enum { press, release, motion }; /// Returns true if mouse reporting is enabled both in the config and -/// the terminal state, and the surface is not in read-only mode. +/// the terminal state. fn isMouseReporting(self: *const Surface) bool { - return !self.readonly and - self.config.mouse_reporting and + return self.config.mouse_reporting and self.io.terminal.flags.mouse_event != .none; } @@ -3420,9 +3432,6 @@ fn mouseReport( assert(self.config.mouse_reporting); assert(self.io.terminal.flags.mouse_event != .none); - // Callers must verify the surface is not in read-only mode - assert(!self.readonly); - // Depending on the event, we may do nothing at all. switch (self.io.terminal.flags.mouse_event) { .none => unreachable, // checked by assert above From dc7bc3014e1ea4033f07af372e86f34d400182bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:13:53 -0800 Subject: [PATCH 652/702] add apprt action to notify apprt of surface readonly state --- include/ghostty.h | 8 ++++++++ src/Surface.zig | 5 +++++ src/apprt/action.zig | 9 +++++++++ src/apprt/gtk/class/application.zig | 1 + 4 files changed, 23 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 702a88ecc..a75fdc245 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -573,6 +573,12 @@ typedef enum { GHOSTTY_QUIT_TIMER_STOP, } ghostty_action_quit_timer_e; +// apprt.action.Readonly +typedef enum { + GHOSTTY_READONLY_OFF, + GHOSTTY_READONLY_ON, +} ghostty_action_readonly_e; + // apprt.action.DesktopNotification.C typedef struct { const char* title; @@ -837,6 +843,7 @@ typedef enum { GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, + GHOSTTY_ACTION_READONLY, } ghostty_action_tag_e; typedef union { @@ -874,6 +881,7 @@ typedef union { ghostty_action_start_search_s start_search; ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; + ghostty_action_readonly_e readonly; } ghostty_action_u; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index 19dc086dd..45b629865 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5424,6 +5424,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle_readonly => { self.readonly = !self.readonly; + _ = try self.rt_app.performAction( + .{ .surface = self }, + .readonly, + if (self.readonly) .on else .off, + ); return true; }, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 94965d38c..608081a46 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -314,6 +314,9 @@ pub const Action = union(Key) { /// The currently selected search match index (1-based). search_selected: SearchSelected, + /// The readonly state of the surface has changed. + readonly: Readonly, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -375,6 +378,7 @@ pub const Action = union(Key) { end_search, search_total, search_selected, + readonly, }; /// Sync with: ghostty_action_u @@ -532,6 +536,11 @@ pub const QuitTimer = enum(c_int) { stop, }; +pub const Readonly = enum(c_int) { + off, + on, +}; + pub const MouseVisibility = enum(c_int) { visible, hidden, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 47c2972ac..efca498b4 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -746,6 +746,7 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .readonly, => { log.warn("unimplemented action={}", .{action}); return false; From ec2638b3c6e3ceb870e459380fa0f91a46a392a2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:41:32 -0800 Subject: [PATCH 653/702] macos: readonly badge --- macos/Sources/Ghostty/Ghostty.App.swift | 28 +++++++++++ macos/Sources/Ghostty/Package.swift | 4 ++ macos/Sources/Ghostty/SurfaceView.swift | 47 +++++++++++++++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 13 +++++ macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 ++ 5 files changed, 95 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index aff3edbc7..4788a4376 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -588,6 +588,9 @@ extension Ghostty { case GHOSTTY_ACTION_RING_BELL: ringBell(app, target: target) + case GHOSTTY_ACTION_READONLY: + setReadonly(app, target: target, v: action.action.readonly) + case GHOSTTY_ACTION_CHECK_FOR_UPDATES: checkForUpdates(app) @@ -1010,6 +1013,31 @@ extension Ghostty { } } + private static func setReadonly( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_readonly_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set readonly 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 } + NotificationCenter.default.post( + name: .ghosttyDidChangeReadonly, + object: surfaceView, + userInfo: [ + SwiftUI.Notification.Name.ReadonlyKey: v == GHOSTTY_READONLY_ON, + ] + ) + + default: + assertionFailure() + } + } + private static func moveTab( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 4b3eb60aa..258857e8e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -391,6 +391,10 @@ extension Notification.Name { /// Ring the bell static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing") + + /// Readonly mode changed + static let ghosttyDidChangeReadonly = Notification.Name("com.mitchellh.ghostty.didChangeReadonly") + static let ReadonlyKey = ghosttyDidChangeReadonly.rawValue + ".readonly" static let ghosttyCommandPaletteDidToggle = Notification.Name("com.mitchellh.ghostty.commandPaletteDidToggle") /// Toggle maximize of current window diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index ba678db59..c027162ab 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -104,6 +104,11 @@ extension Ghostty { } .ghosttySurfaceView(surfaceView) + // Readonly indicator badge + if surfaceView.readonly { + ReadonlyBadge() + } + // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { @@ -757,6 +762,48 @@ extension Ghostty { } } + // MARK: Readonly Badge + + /// A badge overlay that indicates a surface is in readonly mode. + /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. + struct ReadonlyBadge: View { + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) + + var body: some View { + VStack { + HStack { + Spacer() + + HStack(spacing: 5) { + Image(systemName: "eye.fill") + .font(.system(size: 12)) + Text("Read-only") + .font(.system(size: 12, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badgeBackground) + .foregroundStyle(badgeColor) + } + .padding(8) + + Spacer() + } + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Read-only terminal") + } + + private var badgeBackground: some View { + RoundedRectangle(cornerRadius: 6) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.orange.opacity(0.6), lineWidth: 1.5) + ) + } + } + #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 130df6f44..d8670e644 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -123,6 +123,9 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published private(set) var bell: Bool = false + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil @@ -333,6 +336,11 @@ extension Ghostty { selector: #selector(ghosttyBellDidRing(_:)), name: .ghosttyBellDidRing, object: self) + center.addObserver( + self, + selector: #selector(ghosttyDidChangeReadonly(_:)), + name: .ghosttyDidChangeReadonly, + object: self) center.addObserver( self, selector: #selector(windowDidChangeScreen), @@ -703,6 +711,11 @@ extension Ghostty { bell = true } + @objc private func ghosttyDidChangeReadonly(_ notification: SwiftUI.Notification) { + guard let value = notification.userInfo?[SwiftUI.Notification.Name.ReadonlyKey] as? Bool else { return } + readonly = value + } + @objc private func windowDidChangeScreen(notification: SwiftUI.Notification) { guard let window = self.window else { return } guard let object = notification.object as? NSWindow, window == object else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 09c41c0b5..568a93314 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -43,6 +43,9 @@ extension Ghostty { // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil + + /// True when the surface is in readonly mode. + @Published private(set) var readonly: Bool = false // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. From ceb1b5e587c7a769f33ca8e0d208ce3067cb2947 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:50:20 -0800 Subject: [PATCH 654/702] macos: add a read-only menu item in View --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/App/macOS/MainMenu.xib | 7 +++++++ macos/Sources/Ghostty/SurfaceView_AppKit.swift | 12 ++++++++++++ 3 files changed, 20 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8baee3d89..e10547bbc 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -69,6 +69,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuChangeTitle: NSMenuItem? @IBOutlet private var menuChangeTabTitle: NSMenuItem? + @IBOutlet private var menuReadonly: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuCommandPalette: NSMenuItem? diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index d009b9c62..a321061dd 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -47,6 +47,7 @@ + @@ -328,6 +329,12 @@ + + + + + + diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d8670e644..853a6d51c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1512,6 +1512,14 @@ extension Ghostty { } } + @IBAction func toggleReadonly(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "toggle_readonly" + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT) @@ -1988,6 +1996,10 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { case #selector(findHide): return searchState != nil + case #selector(toggleReadonly): + item.state = readonly ? .on : .off + return true + default: return true } From 173d8efd90536afc53316cbc00f3628dae3fd3df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 13:55:02 -0800 Subject: [PATCH 655/702] macos: add to context menu --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/Ghostty/SurfaceView_AppKit.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index e10547bbc..043d85e1e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -545,6 +545,7 @@ class AppDelegate: NSObject, self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 853a6d51c..d26545ebc 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1429,6 +1429,9 @@ extension Ghostty { item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise") item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") + item = menu.addItem(withTitle: "Terminal Read-only", action: #selector(toggleReadonly(_:)), keyEquivalent: "") + item.setImageIfDesired(systemSymbolName: "eye.fill") + item.state = readonly ? .on : .off menu.addItem(.separator()) item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") From 22b8809858088d8760c5601e4ec1658d6be964d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:01:35 -0800 Subject: [PATCH 656/702] macos: add a popover to the readonly badge with info --- macos/Sources/Ghostty/SurfaceView.swift | 54 ++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index c027162ab..3bdcaafe6 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -106,7 +106,9 @@ extension Ghostty { // Readonly indicator badge if surfaceView.readonly { - ReadonlyBadge() + ReadonlyBadge { + surfaceView.toggleReadonly(nil) + } } // Progress report @@ -767,6 +769,10 @@ extension Ghostty { /// A badge overlay that indicates a surface is in readonly mode. /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. struct ReadonlyBadge: View { + let onDisable: () -> Void + + @State private var showingPopover = false + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) var body: some View { @@ -784,12 +790,18 @@ extension Ghostty { .padding(.vertical, 4) .background(badgeBackground) .foregroundStyle(badgeColor) + .onTapGesture { + showingPopover = true + } + .backport.pointerStyle(.link) + .popover(isPresented: $showingPopover, arrowEdge: .bottom) { + ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover) + } } .padding(8) Spacer() } - .allowsHitTesting(false) .accessibilityElement(children: .ignore) .accessibilityLabel("Read-only terminal") } @@ -803,6 +815,44 @@ extension Ghostty { ) } } + + struct ReadonlyPopoverView: View { + let onDisable: () -> Void + @Binding var isPresented: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "eye.fill") + .foregroundColor(.orange) + .font(.system(size: 13)) + Text("Read-Only Mode") + .font(.system(size: 13, weight: .semibold)) + } + + Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + + Button("Disable") { + onDisable() + isPresented = false + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(16) + .frame(width: 280) + } + } #if canImport(AppKit) /// When changing the split state, or going full screen (native or non), the terminal view From ddaf307cf7a6304b4376fb98e94e614369c46f1d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:05:46 -0800 Subject: [PATCH 657/702] macos: more strict detection for tab context menu We were accidentally modifying the "View" menu. --- .../Features/Terminal/Window Styles/TerminalWindow.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index d04d7001c..160473328 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -708,8 +708,8 @@ extension TerminalWindow { private func isTabContextMenu(_ menu: NSMenu) -> Bool { guard NSApp.keyWindow === self else { return false } - // These are the target selectors, at least for macOS 26. - let tabContextSelectors: Set = [ + // These selectors must all exist for it to be a tab context menu. + let requiredSelectors: Set = [ "performClose:", "performCloseOtherTabs:", "moveTabToNewWindow:", @@ -717,7 +717,7 @@ extension TerminalWindow { ] let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) - return !selectorNames.isDisjoint(with: tabContextSelectors) + return requiredSelectors.isSubset(of: selectorNames) } private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) { From 43b4ed5bc0c20d6a39d20260a924c308f065d43e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:12:02 -0800 Subject: [PATCH 658/702] macos: only show readonly badge on AppKit --- macos/Sources/Ghostty/SurfaceView.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 3bdcaafe6..eaf935df9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -103,14 +103,7 @@ extension Ghostty { } } .ghosttySurfaceView(surfaceView) - - // Readonly indicator badge - if surfaceView.readonly { - ReadonlyBadge { - surfaceView.toggleReadonly(nil) - } - } - + .allowsHitTesting(false) // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { @@ -123,6 +116,13 @@ extension Ghostty { } #if canImport(AppKit) + // Readonly indicator badge + if surfaceView.readonly { + ReadonlyBadge { + surfaceView.toggleReadonly(nil) + } + } + // If we are in the middle of a key sequence, then we show a visual element. We only // support this on macOS currently although in theory we can support mobile with keyboards! if !surfaceView.keySequence.isEmpty { From 19e0864688e0ce53d030d7d66eb474e9ccba816e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:14:14 -0800 Subject: [PATCH 659/702] macos: unintended change --- macos/Sources/Ghostty/SurfaceView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index eaf935df9..82232dd89 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -103,7 +103,7 @@ extension Ghostty { } } .ghosttySurfaceView(surfaceView) - .allowsHitTesting(false) + // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { From 182cb35bae0f7dc45cb6f98374a6babf42e73401 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Dec 2025 14:15:43 -0800 Subject: [PATCH 660/702] core: remove readonly check --- src/Surface.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 45b629865..a3b306fef 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2592,12 +2592,6 @@ pub fn keyCallback( if (insp_ev) |*ev| ev else null, )) |v| return v; - // If the surface is in read-only mode, we consume the key event here - // without sending it to the PTY. - if (self.readonly) { - return .consumed; - } - // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { self.renderer_state.mutex.lock(); From 4a04efaff1e1ece5a78131c09221ae3700392e06 Mon Sep 17 00:00:00 2001 From: definfo Date: Sat, 13 Dec 2025 16:55:41 +0800 Subject: [PATCH 661/702] fix: explicitly allow preservation for TERMINFO in shell-integration Due to security issues, `sudo` implementations may not preserve environment variables unless appended with `--preserve-env=list`. Signed-off-by: definfo --- src/shell-integration/bash/ghostty.bash | 2 +- src/shell-integration/elvish/lib/ghostty-integration.elv | 2 +- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 2 +- src/shell-integration/zsh/ghostty-integration | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index e910a9885..799d0cff6 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -103,7 +103,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then builtin command sudo "$@"; else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo --preserve-env=TERMINFO "$@"; fi } fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 33473c8b0..e4b449ae5 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -97,7 +97,7 @@ if (not (has-value $arg =)) { break } } - if (not $sudoedit) { set args = [ TERMINFO=$E:TERMINFO $@args ] } + if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] } (external sudo) $@args } diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 47af9be98..580e27f45 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -90,7 +90,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if test "$sudo_has_sudoedit_flags" = "yes" command sudo $argv else - command sudo TERMINFO="$TERMINFO" $argv + command sudo --preserve-env=TERMINFO $argv end end end diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 7ff43efd9..c87630c92 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -255,7 +255,7 @@ _ghostty_deferred_init() { if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then builtin command sudo "$@"; else - builtin command sudo TERMINFO="$TERMINFO" "$@"; + builtin command sudo --preserve-env=TERMINFO "$@"; fi } fi From c5d6b951e99dcff378c9b49f9f5fb56ab2874ec5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 07:06:06 -0800 Subject: [PATCH 662/702] input: shift+backspace in Kitty with only disambiguate should do CSIu Fixes #9868 (shift+backspace part only) --- src/input/key_encode.zig | 45 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index b63de6f6d..736df58a0 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -178,7 +178,7 @@ fn kitty( // Quote ("report all" mode): // Note that all keys are reported as escape codes, including Enter, // Tab, Backspace etc. - if (effective_mods.empty()) { + if (binding_mods.empty()) { switch (event.key) { .enter => return try writer.writeByte('\r'), .tab => return try writer.writeByte('\t'), @@ -1311,7 +1311,48 @@ test "kitty: enter, backspace, tab" { try testing.expectEqualStrings("\x1b[9;1:3u", writer.buffered()); } } -// + +test "kitty: shift+backspace emits CSI u" { + // Backspace with shift modifier should emit CSI u sequence, not raw 0x7F. + // This is important for programs that want to distinguish shift+backspace. + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[127;2u", writer.buffered()); +} + +test "kitty: shift+enter emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .enter, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[13;2u", writer.buffered()); +} + +test "kitty: shift+tab emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .tab, + .mods = .{ .shift = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[9;2u", writer.buffered()); +} + test "kitty: enter with all flags" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); From 1c1ef99fb1d7cdab5af3d058fc2ff51867eab26a Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Tue, 21 Oct 2025 22:13:42 +0200 Subject: [PATCH 663/702] Window switching initial --- include/ghostty.h | 6 +++++ src/Surface.zig | 11 +++++++++ src/apprt/action.zig | 11 +++++++++ src/apprt/gtk/class/application.zig | 36 +++++++++++++++++++++++++++++ src/input/Binding.zig | 10 ++++++++ src/input/command.zig | 14 +++++++++++ 6 files changed, 88 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index a75fdc245..82ac392e2 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -512,6 +512,12 @@ typedef enum { GHOSTTY_GOTO_SPLIT_RIGHT, } ghostty_action_goto_split_e; +// apprt.action.GotoWindow +typedef enum { + GHOSTTY_GOTO_WINDOW_PREVIOUS, + GHOSTTY_GOTO_WINDOW_NEXT, +} ghostty_action_goto_window_e; + // apprt.action.ResizeSplit.Direction typedef enum { GHOSTTY_RESIZE_SPLIT_UP, diff --git a/src/Surface.zig b/src/Surface.zig index a3b306fef..69a390c2d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5390,6 +5390,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, ), + .goto_window => |direction| return try self.rt_app.performAction( + .{ .surface = self }, + .goto_window, + switch (direction) { + inline else => |tag| @field( + apprt.action.GotoWindow, + @tagName(tag), + ), + }, + ), + .resize_split => |value| return try self.rt_app.performAction( .{ .surface = self }, .resize_split, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 608081a46..4bb590eee 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -129,6 +129,9 @@ pub const Action = union(Key) { /// Jump to a specific split. goto_split: GotoSplit, + /// Jump to next/previous window. + goto_window: GotoWindow, + /// Resize the split in the given direction. resize_split: ResizeSplit, @@ -335,6 +338,7 @@ pub const Action = union(Key) { move_tab, goto_tab, goto_split, + goto_window, resize_split, equalize_splits, toggle_split_zoom, @@ -474,6 +478,13 @@ pub const GotoSplit = enum(c_int) { right, }; +// This is made extern (c_int) to make interop easier with our embedded +// runtime. The small size cost doesn't make a difference in our union. +pub const GotoWindow = enum(c_int) { + previous, + next, +}; + /// The amount to resize the split by and the direction to resize it in. pub const ResizeSplit = extern struct { amount: u16, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index efca498b4..e53201c96 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -659,6 +659,8 @@ pub const Application = extern struct { .goto_split => return Action.gotoSplit(target, value), + .goto_window => return Action.gotoWindow(value), + .goto_tab => return Action.gotoTab(target, value), .initial_size => return Action.initialSize(target, value), @@ -2014,6 +2016,40 @@ const Action = struct { } } + pub fn gotoWindow( + direction: apprt.action.GotoWindow, + ) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + const node = glist.findCustom(null, findActiveWindow); + + // Check based on direction if we are at beginning or end of window list to loop around + // else just go to next/previous window + switch(direction) { + .next => { + const next_node = node.f_next orelse glist; + + const window: *gtk.Window = @ptrCast(@alignCast(next_node.f_data orelse return false)); + gtk.Window.present(window); + return true; + }, + .previous => { + const prev_node = node.f_prev orelse last: { + var current = glist; + while (current.f_next) |next| { + current = next; + } + break :last current; + }; + const window: *gtk.Window = @ptrCast(@alignCast(prev_node.f_data orelse return false)); + gtk.Window.present(window); + return true; + }, + } + return false; + } + pub fn initialSize( target: apprt.Target, value: apprt.action.InitialSize, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d368c48b2..0a927b85f 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -545,6 +545,10 @@ pub const Action = union(enum) { /// (`previous` and `next`). goto_split: SplitFocusDirection, + + /// Focus on either the previous window or the next one ('previous', 'next') + goto_window: WindowDirection, + /// Zoom in or out of the current split. /// /// When a split is zoomed into, it will take up the entire space in @@ -931,6 +935,11 @@ pub const Action = union(enum) { right, }; + pub const WindowDirection = enum { + previous, + next, + }; + pub const SplitResizeParameter = struct { SplitResizeDirection, u16, @@ -1250,6 +1259,7 @@ pub const Action = union(enum) { .toggle_tab_overview, .new_split, .goto_split, + .goto_window, .toggle_split_zoom, .toggle_readonly, .resize_split, diff --git a/src/input/command.zig b/src/input/command.zig index ce218718f..037b5317c 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -479,6 +479,20 @@ fn actionCommands(action: Action.Key) []const Command { }, }, + .goto_window => comptime &.{ + .{ + .action = .{ .goto_window = .previous }, + .title = "Focus Window: Previous", + .description = "Focus the previous window, if any.", + }, + .{ + .action = .{ .goto_window = .previous }, + .title = "Focus Window: Next", + .description = "Focus the next window, if any.", + }, + }, + + .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", From 3000136e6113c1c2f4b47f604803aaa6f76ca1a6 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Thu, 23 Oct 2025 22:30:27 +0200 Subject: [PATCH 664/702] Changed switching previous/next to have no duplication --- src/apprt/gtk/class/application.zig | 65 +++++++++++++++-------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index e53201c96..ed2044c4e 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2016,37 +2016,40 @@ const Action = struct { } } - pub fn gotoWindow( - direction: apprt.action.GotoWindow, - ) bool { - const glist = gtk.Window.listToplevels(); - defer glist.free(); - - const node = glist.findCustom(null, findActiveWindow); - - // Check based on direction if we are at beginning or end of window list to loop around - // else just go to next/previous window - switch(direction) { - .next => { - const next_node = node.f_next orelse glist; - - const window: *gtk.Window = @ptrCast(@alignCast(next_node.f_data orelse return false)); - gtk.Window.present(window); - return true; - }, - .previous => { - const prev_node = node.f_prev orelse last: { - var current = glist; - while (current.f_next) |next| { - current = next; - } - break :last current; - }; - const window: *gtk.Window = @ptrCast(@alignCast(prev_node.f_data orelse return false)); - gtk.Window.present(window); - return true; - }, - } + pub fn gotoWindow( + direction: apprt.action.GotoWindow, + ) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + const node = glist.findCustom(null, findActiveWindow); + + const target_node = switch (direction) { + .next => node.f_next orelse glist, + .previous => node.f_prev orelse last: { + var current = glist; + while (current.f_next) |next| { + current = next; + } + break :last current; + }, + }; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); + gtk.Window.present(gtk_window); + + const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); + var value = std.mem.zeroes(gobject.Value); + defer value.unset(); + _ = value.init(gobject.ext.typeFor(?*Surface)); + ghostty_window.as(gobject.Object).getProperty("active-surface", &value); + + const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); + if (surface) |s| { + s.grabFocus(); + return true; + } + + log.warn("window has no active surface, cannot grab focus", .{}); return false; } From 55ae4430b9fbf0c4556c07eb0f39649bbcc658ab Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Thu, 23 Oct 2025 23:33:27 +0200 Subject: [PATCH 665/702] Formatting --- src/apprt/action.zig | 2 +- src/apprt/gtk/class/application.zig | 68 ++++++++++++++--------------- src/input/Binding.zig | 3 +- src/input/command.zig | 1 - 4 files changed, 36 insertions(+), 38 deletions(-) diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 4bb590eee..af1c22552 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -129,7 +129,7 @@ pub const Action = union(Key) { /// Jump to a specific split. goto_split: GotoSplit, - /// Jump to next/previous window. + /// Jump to next/previous window. goto_window: GotoWindow, /// Resize the split in the given direction. diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index ed2044c4e..331fff4e9 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2016,40 +2016,40 @@ const Action = struct { } } - pub fn gotoWindow( - direction: apprt.action.GotoWindow, - ) bool { - const glist = gtk.Window.listToplevels(); - defer glist.free(); - - const node = glist.findCustom(null, findActiveWindow); - - const target_node = switch (direction) { - .next => node.f_next orelse glist, - .previous => node.f_prev orelse last: { - var current = glist; - while (current.f_next) |next| { - current = next; - } - break :last current; - }, - }; - const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); - gtk.Window.present(gtk_window); - - const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); - var value = std.mem.zeroes(gobject.Value); - defer value.unset(); - _ = value.init(gobject.ext.typeFor(?*Surface)); - ghostty_window.as(gobject.Object).getProperty("active-surface", &value); - - const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); - if (surface) |s| { - s.grabFocus(); - return true; - } - - log.warn("window has no active surface, cannot grab focus", .{}); + pub fn gotoWindow( + direction: apprt.action.GotoWindow, + ) bool { + const glist = gtk.Window.listToplevels(); + defer glist.free(); + + const node = glist.findCustom(null, findActiveWindow); + + const target_node = switch (direction) { + .next => node.f_next orelse glist, + .previous => node.f_prev orelse last: { + var current = glist; + while (current.f_next) |next| { + current = next; + } + break :last current; + }, + }; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); + gtk.Window.present(gtk_window); + + const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); + var value = std.mem.zeroes(gobject.Value); + defer value.unset(); + _ = value.init(gobject.ext.typeFor(?*Surface)); + ghostty_window.as(gobject.Object).getProperty("active-surface", &value); + + const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); + if (surface) |s| { + s.grabFocus(); + return true; + } + + log.warn("window has no active surface, cannot grab focus", .{}); return false; } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0a927b85f..a3284c718 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -545,7 +545,6 @@ pub const Action = union(enum) { /// (`previous` and `next`). goto_split: SplitFocusDirection, - /// Focus on either the previous window or the next one ('previous', 'next') goto_window: WindowDirection, @@ -936,7 +935,7 @@ pub const Action = union(enum) { }; pub const WindowDirection = enum { - previous, + previous, next, }; diff --git a/src/input/command.zig b/src/input/command.zig index 037b5317c..deb6e8412 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -492,7 +492,6 @@ fn actionCommands(action: Action.Key) []const Command { }, }, - .toggle_split_zoom => comptime &.{.{ .action = .toggle_split_zoom, .title = "Toggle Split Zoom", From afbcfa9e3d4771cf3127cf4ce9ec7b19cec957da Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 09:45:37 +0200 Subject: [PATCH 666/702] Added GOTO_WINDOW to actions --- src/input/Binding.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a3284c718..31672bc1a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -546,7 +546,7 @@ pub const Action = union(enum) { goto_split: SplitFocusDirection, /// Focus on either the previous window or the next one ('previous', 'next') - goto_window: WindowDirection, + goto_window: GotoWindow, /// Zoom in or out of the current split. /// @@ -934,7 +934,7 @@ pub const Action = union(enum) { right, }; - pub const WindowDirection = enum { + pub const GotoWindow = enum { previous, next, }; From 4f02e6c0965567ec8820732c8541fdfe1137ca59 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 14:10:20 +0200 Subject: [PATCH 667/702] Wrong action typo fix --- src/input/command.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/command.zig b/src/input/command.zig index deb6e8412..a377effa2 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -486,7 +486,7 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Focus the previous window, if any.", }, .{ - .action = .{ .goto_window = .previous }, + .action = .{ .goto_window = .next }, .title = "Focus Window: Next", .description = "Focus the next window, if any.", }, From b344c978d01651ac4efabb3ca185024843bc7150 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 15:26:17 +0200 Subject: [PATCH 668/702] Added GOTO_WINDOW to actions enum --- include/ghostty.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/ghostty.h b/include/ghostty.h index 82ac392e2..514e52c77 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -806,6 +806,7 @@ typedef enum { GHOSTTY_ACTION_MOVE_TAB, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, + GHOSTTY_ACTION_GOTO_WINDOW, GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, From 6b8a7e1dd14bcd24b70cfced6976aed64afae194 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 15:32:43 +0200 Subject: [PATCH 669/702] Replaced direction switch, direclty handling next and previous now --- src/Surface.zig | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 69a390c2d..4ff25992a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5394,10 +5394,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .surface = self }, .goto_window, switch (direction) { - inline else => |tag| @field( - apprt.action.GotoWindow, - @tagName(tag), - ), + .next => apprt.action.GotoWindow.next, + .previous => apprt.action.GotoWindow.previous, }, ), From 6230d134e18942cef23ad4821036b70b3c7d0bc3 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 19:22:44 +0200 Subject: [PATCH 670/702] Type-safe rework --- src/apprt/gtk/class/application.zig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 331fff4e9..0769a26df 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2034,18 +2034,18 @@ const Action = struct { break :last current; }, }; - const gtk_window: *gtk.Window = @ptrCast(@alignCast(target_node.f_data orelse return false)); + const data = target_node.f_data orelse return false; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); gtk.Window.present(gtk_window); - const ghostty_window: *Window = @ptrCast(@alignCast(gtk_window)); - var value = std.mem.zeroes(gobject.Value); - defer value.unset(); - _ = value.init(gobject.ext.typeFor(?*Surface)); - ghostty_window.as(gobject.Object).getProperty("active-surface", &value); + const ghostty_window = gobject.ext.cast(Window, gtk_window) orelse return false; + + var surface: ?*gobject.Object = null; + ghostty_window.as(gobject.Object).get("active-surface", &surface, @as(?*anyopaque, null)); - const surface: ?*Surface = @ptrCast(@alignCast(value.getObject())); if (surface) |s| { - s.grabFocus(); + const surface_obj = gobject.ext.cast(Surface, s) orelse return false; + surface_obj.grabFocus(); return true; } From 7e0dc09873095546362b6c951da0420b4da3f6dc Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Fri, 24 Oct 2025 20:04:50 +0200 Subject: [PATCH 671/702] Just using decl literals --- src/Surface.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 4ff25992a..19c2662c1 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5394,8 +5394,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .surface = self }, .goto_window, switch (direction) { - .next => apprt.action.GotoWindow.next, - .previous => apprt.action.GotoWindow.previous, + .previous => .previous, + .next => .next, }, ), From bb246b2e0c9dc3139c68684f37c0caf826c6b3e6 Mon Sep 17 00:00:00 2001 From: Max Bretschneider Date: Tue, 28 Oct 2025 19:44:43 +0100 Subject: [PATCH 672/702] Added null handling for findCustom --- src/apprt/gtk/class/application.zig | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 0769a26df..5b264fcce 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2022,18 +2022,19 @@ const Action = struct { const glist = gtk.Window.listToplevels(); defer glist.free(); - const node = glist.findCustom(null, findActiveWindow); + const node = @as(?*glib.List, glist.findCustom(null, findActiveWindow)); - const target_node = switch (direction) { - .next => node.f_next orelse glist, - .previous => node.f_prev orelse last: { + const target_node = if (node) |n| switch (direction) { + .next => n.f_next orelse glist, + .previous => n.f_prev orelse last: { var current = glist; while (current.f_next) |next| { current = next; } break :last current; }, - }; + } else glist; + const data = target_node.f_data orelse return false; const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); gtk.Window.present(gtk_window); From 4c2fb7ae0ebbbe28fd21f2d5fc4ee96bea168af7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 13:51:16 -0800 Subject: [PATCH 673/702] Update mirror for direct deps --- build.zig.zon | 12 ++-- build.zig.zon.bak | 124 ++++++++++++++++++++++++++++++++++++++ build.zig.zon.json | 12 ++-- build.zig.zon.nix | 12 ++-- build.zig.zon.txt | 12 ++-- flatpak/zig-packages.json | 12 ++-- 6 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 build.zig.zon.bak diff --git a/build.zig.zon b/build.zig.zon index 191ae7fa9..79c8c69c3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -15,13 +15,13 @@ }, .vaxis = .{ // rockorager/libvaxis - .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", .lazy = true, }, .z2d = .{ // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + .url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", .lazy = true, }, @@ -39,7 +39,7 @@ }, .uucode = .{ // jacobsandlund/uucode - .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", }, .zig_wayland = .{ @@ -50,14 +50,14 @@ }, .zf = .{ // natecraddock/zf - .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", .lazy = true, }, .gobject = .{ // https://github.com/ghostty-org/zig-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", .lazy = true, }, @@ -116,7 +116,7 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, diff --git a/build.zig.zon.bak b/build.zig.zon.bak new file mode 100644 index 000000000..191ae7fa9 --- /dev/null +++ b/build.zig.zon.bak @@ -0,0 +1,124 @@ +.{ + .name = .ghostty, + .version = "1.3.0-dev", + .paths = .{""}, + .fingerprint = 0x64407a2a0b4147e5, + .minimum_zig_version = "0.15.2", + .dependencies = .{ + // Zig libs + + .libxev = .{ + // mitchellh/libxev + .url = "https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz", + .hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs", + .lazy = true, + }, + .vaxis = .{ + // rockorager/libvaxis + .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", + .lazy = true, + }, + .z2d = .{ + // vancluever/z2d + .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", + .lazy = true, + }, + .zig_objc = .{ + // mitchellh/zig-objc + .url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz", + .hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK", + .lazy = true, + }, + .zig_js = .{ + // mitchellh/zig-js + .url = "https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz", + .hash = "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi", + .lazy = true, + }, + .uucode = .{ + // 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 + .url = "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz", + .hash = "wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe", + .lazy = true, + }, + .zf = .{ + // natecraddock/zf + .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", + .lazy = true, + }, + .gobject = .{ + // https://github.com/ghostty-org/zig-gobject based on zig_gobject + // Temporary until we generate them at build time automatically. + .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", + .lazy = true, + }, + + // C libs + .cimgui = .{ .path = "./pkg/cimgui", .lazy = true }, + .fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true }, + .freetype = .{ .path = "./pkg/freetype", .lazy = true }, + .gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true }, + .harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true }, + .highway = .{ .path = "./pkg/highway", .lazy = true }, + .libintl = .{ .path = "./pkg/libintl", .lazy = true }, + .libpng = .{ .path = "./pkg/libpng", .lazy = true }, + .macos = .{ .path = "./pkg/macos", .lazy = true }, + .oniguruma = .{ .path = "./pkg/oniguruma", .lazy = true }, + .opengl = .{ .path = "./pkg/opengl", .lazy = true }, + .sentry = .{ .path = "./pkg/sentry", .lazy = true }, + .simdutf = .{ .path = "./pkg/simdutf", .lazy = true }, + .utfcpp = .{ .path = "./pkg/utfcpp", .lazy = true }, + .wuffs = .{ .path = "./pkg/wuffs", .lazy = true }, + .zlib = .{ .path = "./pkg/zlib", .lazy = true }, + + // Shader translation + .glslang = .{ .path = "./pkg/glslang", .lazy = true }, + .spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true }, + + // Wayland + .wayland = .{ + .url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", + .hash = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t", + .lazy = true, + }, + .wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", + .hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S", + .lazy = true, + }, + .plasma_wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz", + .hash = "N-V-__8AAKYZBAB-CFHBKs3u4JkeiT4BMvyHu3Y5aaWF3Bbs", + .lazy = true, + }, + + // Fonts + .jetbrains_mono = .{ + .url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz", + .hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x", + .lazy = true, + }, + .nerd_fonts_symbols_only = .{ + .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", + .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", + .lazy = true, + }, + + // Other + .apple_sdk = .{ .path = "./pkg/apple-sdk" }, + .iterm2_themes = .{ + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + .lazy = true, + }, + }, +} diff --git a/build.zig.zon.json b/build.zig.zon.json index e4171834d..cd807e67a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -26,7 +26,7 @@ }, "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-": { "name": "gobject", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", "hash": "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { @@ -51,7 +51,7 @@ }, "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { @@ -116,12 +116,12 @@ }, "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E": { "name": "uucode", - "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", "hash": "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", - "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", "hash": "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY=" }, "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t": { @@ -141,12 +141,12 @@ }, "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T": { "name": "z2d", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", "hash": "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA=" }, "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": { "name": "zf", - "url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", "hash": "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg=" }, "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c0f923145..e95b26960 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -126,7 +126,7 @@ in name = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst"; + url = "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst"; hash = "sha256-2b1DBvAIHY5LhItq3+q9L6tJgi7itnnrSAHc7fXWDEg="; }; } @@ -166,7 +166,7 @@ in name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; + url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz"; hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } @@ -270,7 +270,7 @@ in name = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E"; path = fetchZigArtifact { name = "uucode"; - url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; + url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; hash = "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4="; }; } @@ -278,7 +278,7 @@ in name = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS"; path = fetchZigArtifact { name = "vaxis"; - url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz"; + url = "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz"; hash = "sha256-LnIzK8icW1Qexua9SHaeHz+3V8QAbz0a+UC1T5sIjvY="; }; } @@ -310,7 +310,7 @@ in name = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T"; path = fetchZigArtifact { name = "z2d"; - url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz"; + url = "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz"; hash = "sha256-+QqCRoXwrFA1/l+oWvYVyAVebGQitAFQNhi9U3EVrxA="; }; } @@ -318,7 +318,7 @@ in name = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh"; path = fetchZigArtifact { name = "zf"; - url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz"; + url = "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz"; hash = "sha256-OwFdkorwTp4mJyvBXrTbtNmp1GnrbSkKDdrmc7d8RWg="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index ceeb3aa3d..33a90a906 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,10 +6,12 @@ https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz +https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz +https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz @@ -19,17 +21,15 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz +https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz +https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz +https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz +https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst 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-20251201-150531-bfb3ee1/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 a6d431c8e..ddb6075b7 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,7 +31,7 @@ }, { "type": "archive", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst", "dest": "vendor/p/gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", "sha256": "d9bd4306f0081d8e4b848b6adfeabd2fab49822ee2b679eb4801dcedf5d60c48" }, @@ -61,7 +61,7 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", + "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, @@ -139,13 +139,13 @@ }, { "type": "archive", - "url": "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", "dest": "vendor/p/uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", "sha256": "4b3a581a1806e01e8bb9ff1e4f7e6c28ba1b0b18e6c04ba8d53c24d237aef60e" }, { "type": "archive", - "url": "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", + "url": "https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", "dest": "vendor/p/vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", "sha256": "2e72332bc89c5b541ec6e6bd48769e1f3fb757c4006f3d1af940b54f9b088ef6" }, @@ -169,13 +169,13 @@ }, { "type": "archive", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T.tar.gz", "dest": "vendor/p/z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", "sha256": "f90a824685f0ac5035fe5fa85af615c8055e6c6422b401503618bd537115af10" }, { "type": "archive", - "url": "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", + "url": "https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", "dest": "vendor/p/zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", "sha256": "3b015d928af04e9e26272bc15eb4dbb4d9a9d469eb6d290a0ddae673b77c4568" }, From dfb94cd55d404f81d34708130cf423dae6cc37b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 14:17:55 -0800 Subject: [PATCH 674/702] apprt/gtk: clean up gotoWindow --- src/apprt/gtk/class/application.zig | 83 +++++++++++++++++++---------- src/apprt/gtk/class/window.zig | 2 +- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 5b264fcce..d404304d0 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -2016,44 +2016,69 @@ const Action = struct { } } - pub fn gotoWindow( - direction: apprt.action.GotoWindow, - ) bool { + pub fn gotoWindow(direction: apprt.action.GotoWindow) bool { const glist = gtk.Window.listToplevels(); defer glist.free(); - const node = @as(?*glib.List, glist.findCustom(null, findActiveWindow)); + // The window we're starting from is typically our active window. + const starting: *glib.List = @as(?*glib.List, glist.findCustom( + null, + findActiveWindow, + )) orelse glist; - const target_node = if (node) |n| switch (direction) { - .next => n.f_next orelse glist, - .previous => n.f_prev orelse last: { - var current = glist; - while (current.f_next) |next| { - current = next; - } - break :last current; - }, - } else glist; - - const data = target_node.f_data orelse return false; - const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); - gtk.Window.present(gtk_window); - - const ghostty_window = gobject.ext.cast(Window, gtk_window) orelse return false; - - var surface: ?*gobject.Object = null; - ghostty_window.as(gobject.Object).get("active-surface", &surface, @as(?*anyopaque, null)); - - if (surface) |s| { - const surface_obj = gobject.ext.cast(Surface, s) orelse return false; - surface_obj.grabFocus(); - return true; + // Go forward or backwards in the list until we find a valid + // window that is visible. + var current_: ?*glib.List = starting; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; + } + + // If we reached here, we didn't find a valid window to focus. + // Wrap around. + current_ = switch (direction) { + .next => glist, + .previous => last: { + var end: *glib.List = glist; + while (end.f_next) |next| end = next; + break :last end; + }, + }; + while (current_) |node| : (current_ = switch (direction) { + .next => node.f_next, + .previous => node.f_prev, + }) { + if (current_ == starting) break; + const data = node.f_data orelse continue; + const gtk_window: *gtk.Window = @ptrCast(@alignCast(data)); + if (gotoWindowMaybe(gtk_window)) return true; } - log.warn("window has no active surface, cannot grab focus", .{}); return false; } + fn gotoWindowMaybe(gtk_window: *gtk.Window) bool { + // If it is already active skip it. + if (gtk_window.isActive() != 0) return false; + // If it is hidden, skip it. + if (gtk_window.as(gtk.Widget).isVisible() == 0) return false; + // If it isn't a Ghostty window, skip it. + const window = gobject.ext.cast( + Window, + gtk_window, + ) orelse return false; + + // Focus our active surface + const surface = window.getActiveSurface() orelse return false; + gtk.Window.present(gtk_window); + surface.grabFocus(); + return true; + } + pub fn initialSize( target: apprt.Target, value: apprt.action.InitialSize, diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index c691b84a6..77fd2eea5 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -793,7 +793,7 @@ pub const Window = extern struct { /// Get the currently active surface. See the "active-surface" property. /// This does not ref the value. - fn getActiveSurface(self: *Self) ?*Surface { + pub fn getActiveSurface(self: *Self) ?*Surface { const tab = self.getSelectedTab() orelse return null; return tab.getActiveSurface(); } From 1a117c46e03f863a383dc9833f9950cf95bf59e4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 14:29:09 -0800 Subject: [PATCH 675/702] macos: fix missing goto_window union entry --- include/ghostty.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/ghostty.h b/include/ghostty.h index 514e52c77..b0395b89e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -859,6 +859,7 @@ typedef union { ghostty_action_move_tab_s move_tab; ghostty_action_goto_tab_e goto_tab; ghostty_action_goto_split_e goto_split; + ghostty_action_goto_window_e goto_window; ghostty_action_resize_split_s resize_split; ghostty_action_size_limit_s size_limit; ghostty_action_initial_size_s initial_size; From 05ee9ae733f216408045d1f0d1a806412508be81 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 13 Dec 2025 14:33:15 -0800 Subject: [PATCH 676/702] macos: implement goto_window:next/previousu --- macos/Sources/Ghostty/Ghostty.App.swift | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4788a4376..2cd0a362a 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -501,6 +501,9 @@ extension Ghostty { case GHOSTTY_ACTION_GOTO_SPLIT: return gotoSplit(app, target: target, direction: action.action.goto_split) + case GHOSTTY_ACTION_GOTO_WINDOW: + return gotoWindow(app, target: target, direction: action.action.goto_window) + case GHOSTTY_ACTION_RESIZE_SPLIT: resizeSplit(app, target: target, resize: action.action.resize_split) @@ -1149,6 +1152,64 @@ extension Ghostty { } } + private static func gotoWindow( + _ app: ghostty_app_t, + target: ghostty_target_s, + direction: ghostty_action_goto_window_e + ) -> Bool { + // Collect candidate windows: visible terminal windows that are either + // standalone or the currently selected tab in their tab group. This + // treats each native tab group as a single "window" for navigation + // purposes, since goto_tab handles per-tab navigation. + let candidates: [NSWindow] = NSApplication.shared.windows.filter { window in + guard window.windowController is BaseTerminalController else { return false } + guard window.isVisible, !window.isMiniaturized else { return false } + // For native tabs, only include the selected tab in each group + if let group = window.tabGroup, group.selectedWindow !== window { + return false + } + return true + } + + // Need at least two windows to navigate between + guard candidates.count > 1 else { return false } + + // Find starting index from the current key/main window + let startIndex = candidates.firstIndex(where: { $0.isKeyWindow }) + ?? candidates.firstIndex(where: { $0.isMainWindow }) + ?? 0 + + let step: Int + switch direction { + case GHOSTTY_GOTO_WINDOW_NEXT: + step = 1 + case GHOSTTY_GOTO_WINDOW_PREVIOUS: + step = -1 + default: + return false + } + + // Iterate with wrap-around until we find a valid window or return to start + let count = candidates.count + var index = (startIndex + step + count) % count + + while index != startIndex { + let candidate = candidates[index] + if candidate.isVisible, !candidate.isMiniaturized { + candidate.makeKeyAndOrderFront(nil) + // Also focus the terminal surface within the window + if let controller = candidate.windowController as? BaseTerminalController, + let surface = controller.focusedSurface { + Ghostty.moveFocus(to: surface) + } + return true + } + index = (index + step + count) % count + } + + return false + } + private static func resizeSplit( _ app: ghostty_app_t, target: ghostty_target_s, From 3d5d170f8b81be29316395507cc977d44ec6851c Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 14 Dec 2025 00:15:58 +0000 Subject: [PATCH 677/702] deps: Update iTerm2 color schemes --- build.zig.zon | 2 +- build.zig.zon.json | 2 +- build.zig.zon.nix | 2 +- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 79c8c69c3..271428778 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,7 +116,7 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index cd807e67a..c9a64ca5f 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -51,7 +51,7 @@ }, "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index e95b26960..43a8efe46 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -166,7 +166,7 @@ in name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz"; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 33a90a906..24a2978d6 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -11,7 +11,6 @@ https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz -https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz https://deps.files.ghostty.org/libxml2-2.11.5.tar.gz @@ -33,3 +32,4 @@ 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/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index ddb6075b7..21f79ec04 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,7 +61,7 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/iterm2_themes-release-20251201-150531-bfb3ee1.tgz", + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, From 786dc9343876a28bd76f9979c59f574202b6be81 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 14 Dec 2025 16:24:50 -0500 Subject: [PATCH 678/702] macos: populate the sparkle:channel element This makes the update channel name available alongside the version, data, etc., which we can use in our update view (on the Released line). --- dist/macos/update_appcast_tag.py | 2 ++ dist/macos/update_appcast_tip.py | 2 ++ .../Sources/Features/Update/UpdatePopoverView.swift | 12 ++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/dist/macos/update_appcast_tag.py b/dist/macos/update_appcast_tag.py index 2cb20dd5d..8c2ee8314 100644 --- a/dist/macos/update_appcast_tag.py +++ b/dist/macos/update_appcast_tag.py @@ -77,6 +77,8 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) +elem = ET.SubElement(item, "sparkle:channel") +elem.text = "stable" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/dist/macos/update_appcast_tip.py b/dist/macos/update_appcast_tip.py index ff1fb4be5..1876f0a17 100644 --- a/dist/macos/update_appcast_tip.py +++ b/dist/macos/update_appcast_tip.py @@ -75,6 +75,8 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) +elem = ET.SubElement(item, "sparkle:channel") +elem.text = "tip" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 87d76f801..2c56e5f4e 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -125,7 +125,15 @@ fileprivate struct UpdateAvailableView: View { let dismiss: DismissAction private let labelWidth: CGFloat = 60 - + + private func releaseDateString(date: Date, channel: String?) -> String { + let dateString = date.formatted(date: .abbreviated, time: .omitted) + if let channel, !channel.isEmpty { + return "\(dateString) (\(channel))" + } + return dateString + } + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { @@ -157,7 +165,7 @@ fileprivate struct UpdateAvailableView: View { Text("Released:") .foregroundColor(.secondary) .frame(width: labelWidth, alignment: .trailing) - Text(date.formatted(date: .abbreviated, time: .omitted)) + Text(releaseDateString(date: date, channel: update.appcastItem.channel)) } .font(.system(size: 11)) } From 1fdc0c0b9f84f95abda54cffc8af1780fa6928ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 14 Dec 2025 13:58:02 -0800 Subject: [PATCH 679/702] terminal: CSI S compatiblity improvements Fixes #9905 This fixes a major compatibility issues with the CSI S sequence: When our top margin is at the top (row 0) without left/right margins, we should be creating scrollback. Previously, we were only deleting. --- src/terminal/Terminal.zig | 195 ++++++++++++++++++++++++++++--- src/terminal/stream_readonly.zig | 2 +- src/termio/stream_handler.zig | 2 +- 3 files changed, 182 insertions(+), 17 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6c9db6a8d..3d00abf74 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1219,7 +1219,7 @@ pub fn index(self: *Terminal) !void { // this check. !self.screens.active.blankCell().isZero()) { - self.scrollUp(1); + try self.scrollUp(1); return; } @@ -1398,7 +1398,7 @@ pub fn scrollDown(self: *Terminal, count: usize) void { /// The new lines are created according to the current SGR state. /// /// Does not change the (absolute) cursor position. -pub fn scrollUp(self: *Terminal, count: usize) void { +pub fn scrollUp(self: *Terminal, count: usize) !void { // Preserve our x/y to restore. const old_x = self.screens.active.cursor.x; const old_y = self.screens.active.cursor.y; @@ -1408,6 +1408,32 @@ pub fn scrollUp(self: *Terminal, count: usize) void { self.screens.active.cursor.pending_wrap = old_wrap; } + // If our scroll region is at the top and we have no left/right + // margins then we move the scrolled out text into the scrollback. + if (self.scrolling_region.top == 0 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) + { + // Scrolling dirties the images because it updates their placements pins. + if (comptime build_options.kitty_graphics) { + self.screens.active.kitty_images.dirty = true; + } + + // Clamp count to the scroll region height. + const region_height = self.scrolling_region.bottom + 1; + const adjusted_count = @min(count, region_height); + + // TODO: Create an optimized version that can scroll N times + // This isn't critical because in most cases, scrollUp is used + // with count=1, but it's still a big optimization opportunity. + + // Move our cursor to the bottom of the scroll region so we can + // use the cursorScrollAbove function to create scrollback + self.screens.active.cursorAbsolute(0, self.scrolling_region.bottom); + for (0..adjusted_count) |_| try self.screens.active.cursorScrollAbove(); + return; + } + // Move to the top of the scroll region self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); self.deleteLines(count); @@ -5635,14 +5661,16 @@ test "Terminal: scrollUp simple" { t.setCursorPos(2, 2); const cursor = t.screens.active.cursor; - t.clearDirty(); - t.scrollUp(1); + const viewport_before = t.screens.active.pages.getTopLeft(.viewport); + try t.scrollUp(1); try testing.expectEqual(cursor.x, t.screens.active.cursor.x); try testing.expectEqual(cursor.y, t.screens.active.cursor.y); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); - try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + // Viewport should have moved. Our entire page should've scrolled! + // The viewport moving will cause our render state to make the full + // frame as dirty. + const viewport_after = t.screens.active.pages.getTopLeft(.viewport); + try testing.expect(!viewport_before.eql(viewport_after)); { const str = try t.plainString(testing.allocator); @@ -5666,7 +5694,7 @@ test "Terminal: scrollUp moves hyperlink" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5717,7 +5745,7 @@ test "Terminal: scrollUp clears hyperlink" { try t.linefeed(); try t.printString("GHI"); t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5755,7 +5783,7 @@ test "Terminal: scrollUp top/bottom scroll region" { t.setCursorPos(1, 1); t.clearDirty(); - t.scrollUp(1); + try t.scrollUp(1); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5787,7 +5815,7 @@ test "Terminal: scrollUp left/right scroll region" { const cursor = t.screens.active.cursor; t.clearDirty(); - t.scrollUp(1); + try t.scrollUp(1); try testing.expectEqual(cursor.x, t.screens.active.cursor.x); try testing.expectEqual(cursor.y, t.screens.active.cursor.y); @@ -5819,7 +5847,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); - t.scrollUp(1); + try t.scrollUp(1); { const str = try t.plainString(testing.allocator); @@ -5919,7 +5947,7 @@ test "Terminal: scrollUp preserves pending wrap" { try t.print('B'); t.setCursorPos(3, 5); try t.print('C'); - t.scrollUp(1); + try t.scrollUp(1); try t.print('X'); { @@ -5940,7 +5968,7 @@ test "Terminal: scrollUp full top/bottom region" { t.setTopAndBottomMargin(2, 5); t.clearDirty(); - t.scrollUp(4); + try t.scrollUp(4); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5966,7 +5994,7 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { t.setLeftAndRightMargin(2, 4); t.clearDirty(); - t.scrollUp(4); + try t.scrollUp(4); // This is dirty because the cursor moves from this row try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); @@ -5982,6 +6010,143 @@ test "Terminal: scrollUp full top/bottomleft/right scroll region" { } } +test "Terminal: scrollUp creates scrollback in primary screen" { + // When in primary screen with full-width scroll region at top, + // scrollUp (CSI S) should push lines into scrollback like xterm. + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 10 }); + defer t.deinit(alloc); + + // Fill the screen with content + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEE"); + + t.clearDirty(); + + // Scroll up by 1, which should push "AAAAA" into scrollback + try t.scrollUp(1); + + // The cursor row (new empty row) should be dirty + try testing.expect(t.screens.active.cursor.page_row.dirty); + + // The active screen should now show BBBBB through EEEEE plus one blank line + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } + + // Now scroll to the top to see scrollback - AAAAA should be there + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // Should see AAAAA in scrollback + try testing.expectEqualStrings("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero" { + // When max_scrollback is 0, scrollUp should still work but not retain history + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + + try t.scrollUp(1); + + // Active screen should show scrolled content + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } + + // Scroll to top - should be same as active since no scrollback + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and top margin" { + // When max_scrollback is 0 and top margin is set, should use deleteLines path + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + + // Set top margin (not at row 0) + t.setTopAndBottomMargin(2, 5); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // First row preserved, rest scrolled + try testing.expectEqualStrings("AAAAA\nCCCCC\nDDDDD", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and left/right margin" { + // When max_scrollback is 0 with left/right margins, uses deleteLines path + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("AAAAABBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCCDDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEEFFFFF"); + + // Set left/right margins (columns 2-6, 1-indexed = indices 1-5) + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 6); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // cols 1-5 scroll, col 0 and cols 6+ preserved + try testing.expectEqualStrings("ACCCCDBBBB\nCEEEEFDDDD\nE FFFF", str); + } +} + test "Terminal: scrollDown simple" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 3b088e2b7..c33dba1bb 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -100,7 +100,7 @@ pub const Handler = struct { .insert_lines => self.terminal.insertLines(value), .insert_blanks => self.terminal.insertBlanks(value), .delete_lines => self.terminal.deleteLines(value), - .scroll_up => self.terminal.scrollUp(value), + .scroll_up => try self.terminal.scrollUp(value), .scroll_down => self.terminal.scrollDown(value), .horizontal_tab => try self.horizontalTab(value), .horizontal_tab_back => try self.horizontalTabBack(value), diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index eabfd6a4b..182770339 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -246,7 +246,7 @@ pub const StreamHandler = struct { .insert_lines => self.terminal.insertLines(value), .insert_blanks => self.terminal.insertBlanks(value), .delete_lines => self.terminal.deleteLines(value), - .scroll_up => self.terminal.scrollUp(value), + .scroll_up => try self.terminal.scrollUp(value), .scroll_down => self.terminal.scrollDown(value), .tab_clear_current => self.terminal.tabClear(.current), .tab_clear_all => self.terminal.tabClear(.all), From bbda6c35e3c3acd5f87a26a0ba3e7c5f5efeb74f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:05:17 +0000 Subject: [PATCH 680/702] build(deps): bump actions/download-artifact from 6.0.0 to 7.0.0 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/018cc2cf5baa6db3ef3c5f8a56943fffe632ef53...37930b1c2abaa49bbe596cd826c3c89aef350131) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release-tag.yml | 10 +++++----- .github/workflows/snap.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c8c0fbf66..a25b8659d 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -286,7 +286,7 @@ jobs: curl -sL https://sentry.io/get-cli/ | bash - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos @@ -309,7 +309,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos @@ -357,17 +357,17 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} steps: - name: Download macOS Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: macos - name: Download Sparkle Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: sparkle - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: source-tarball diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 641bbcca6..6fc7e0fb4 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -26,7 +26,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: run-id: ${{ inputs.source-run-id }} artifact-ids: ${{ inputs.source-artifact-id }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18af9d909..d959fd6b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1075,7 +1075,7 @@ jobs: uses: namespacelabs/nscloud-setup-buildx-action@91c2e6537780e3b092cb8476406be99a8f91bd5e # v0.0.20 - name: Download Source Tarball Artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: source-tarball From 7e5683ebfd780808347d0e3c76ae95dfe0375983 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 00:05:24 +0000 Subject: [PATCH 681/702] build(deps): bump actions/upload-artifact from 5.0.0 to 6.0.0 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5.0.0 to 6.0.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/330a01c490aca151604b8cf639adc76d48f6c5d4...b7c566a772e6b6bfb58ed0dc250532a479d7789f) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release-tag.yml | 6 +++--- .github/workflows/test.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index c8c0fbf66..5adb90ea0 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -113,7 +113,7 @@ jobs: nix develop -c minisign -S -m "ghostty-source.tar.gz" -s minisign.key < minisign.password - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: source-tarball path: |- @@ -269,7 +269,7 @@ jobs: zip -9 -r --symlinks ../../../ghostty-macos-universal-dsym.zip Ghostty.app.dSYM/ - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: macos path: |- @@ -340,7 +340,7 @@ jobs: mv appcast_new.xml appcast.xml - name: Upload artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: sparkle path: |- diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18af9d909..9720eb345 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -397,7 +397,7 @@ jobs: - name: Upload artifact id: upload-artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: source-tarball path: |- From b0c053cfb7a69e81da2f0eb9e411db31bd11c1b0 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 14 Dec 2025 19:21:45 -0500 Subject: [PATCH 682/702] zsh: document unsupported system-level ZDOTDIR We rely on temporarily setting ZDOTDIR to our `zsh` resource directory to implement automatic shell integration. Setting ZDOTDIR in a system file like /etc/zshenv overrides our ZDOTDIR value, preventing our shell integration from being loaded. The only way to prevent /etc/zshenv from being run is via the --no-rcs flag. (The --no-globalrcs only applies to system-level files _after_ /etc/zshenv is loaded.) Unfortunately, there doesn't appear to be a way to run a "bootstrap" script (to reimplement the Zsh startup sequence manually, similar to how our bash integration works) and then enter an interactive shell session. https://zsh.sourceforge.io/Doc/Release/Files.html Given all of the above, document this as an unsupported configuration for automatic shell integration and point affected users at our manual shell integration option. --- src/shell-integration/README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 3f8543c68..9c422ef26 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -78,10 +78,16 @@ on the Fish startup process, see the ### Zsh -For `zsh`, Ghostty sets `ZDOTDIR` so that it loads our configuration -from the `zsh` directory. The existing `ZDOTDIR` is retained so that -after loading the Ghostty shell integration the normal Zsh loading -sequence occurs. +Automatic [Zsh](https://www.zsh.org/) integration works by temporarily setting +`ZDOTDIR` to our `zsh` directory. An existing `ZDOTDIR` environment variable +value will be retained and restored after our shell integration scripts are +run. + +However, if `ZDOTDIR` is set in a system-wide file like `/etc/zshenv`, it will +override Ghostty's `ZDOTDIR` value, preventing the shell integration from being +loaded. In this case, the shell integration needs to be loaded manually. + +To load the Zsh shell integration manually: ```zsh if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then From a0a915a06f42b8add7ee1773666d81b877cd9989 Mon Sep 17 00:00:00 2001 From: kadekillary Date: Mon, 15 Dec 2025 06:31:54 -0600 Subject: [PATCH 683/702] refactor(build): simplify dependency detection logic - Removes unnecessary marker constant from build.zig that existed solely to signal build root status - Uses filesystem check (@src().file access) instead of compile-time declaration lookup to detect when ghostty is a dependency - Same behavior with less indirection: file resolves from build root only when ghostty is the main project --- build.zig | 5 ----- src/build/Config.zig | 22 ++++++++-------------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/build.zig b/build.zig index 472c3957a..fa68b91b4 100644 --- a/build.zig +++ b/build.zig @@ -318,8 +318,3 @@ pub fn build(b: *std.Build) !void { try translations_step.addError("cannot update translations when i18n is disabled", .{}); } } - -/// Marker used by Config.zig to detect if ghostty is the build root. -/// This avoids running logic such as Git tag checking when Ghostty -/// is used as a dependency. -pub const _ghostty_build_root = true; diff --git a/src/build/Config.zig b/src/build/Config.zig index 981cd7de5..3a8a4e0c7 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -219,20 +219,14 @@ pub fn init(b: *std.Build, appVersion: []const u8) !Config { else version: { const app_version = try std.SemanticVersion.parse(appVersion); - // Detect if ghostty is being built as a dependency by checking if the - // build root has our marker. When used as a dependency, we skip git - // detection entirely to avoid reading the downstream project's git state. - const is_dependency = !@hasDecl( - @import("root"), - "_ghostty_build_root", - ); - if (is_dependency) { - break :version .{ - .major = app_version.major, - .minor = app_version.minor, - .patch = app_version.patch, - }; - } + // Is ghostty a dependency? If so, skip git detection. + // @src().file won't resolve from b.build_root unless ghostty + // is the project being built. + b.build_root.handle.access(@src().file, .{}) catch break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + }; // If no explicit version is given, we try to detect it from git. const vsn = GitVersion.detect(b) catch |err| switch (err) { From b15f16995c4b198fa5ff4d9627ea8af57a8a2d69 Mon Sep 17 00:00:00 2001 From: James Baumgarten Date: Fri, 25 Jul 2025 22:47:12 -0600 Subject: [PATCH 684/702] Fix i3 window border disappearing after fullscreen toggle When toggling a Ghostty window between fullscreen and windowed mode in the i3 window manager, window borders would disappear and not return. Root cause was that syncAppearance() was updating X11 properties on every call during window transitions, even when values hadn't changed. These redundant property updates interfered with i3's border management. The fix adds caching to syncBlur() and syncDecorations() to only update X11 properties when values actually change, eliminating unnecessary property changes during fullscreen transitions. --- src/apprt/gtk/winproto/x11.zig | 51 ++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 9dc273563..c73d4d482 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -173,6 +173,10 @@ pub const Window = struct { blur_region: Region = .{}, + // Cache last applied values to avoid redundant X11 property updates + last_applied_blur_region: ?Region = null, + last_applied_decoration_hints: ?MotifWMHints = null, + pub fn init( alloc: Allocator, app: *App, @@ -255,19 +259,34 @@ pub const Window = struct { const gtk_widget = self.apprt_window.as(gtk.Widget); const config = if (self.apprt_window.getConfig()) |v| v.get() else return; + // When blur is disabled, remove the property if it was previously set + const blur = config.@"background-blur"; + if (!blur.enabled()) { + if (self.last_applied_blur_region != null) { + try self.deleteProperty(self.app.atoms.kde_blur); + self.last_applied_blur_region = null; + } + return; + } + // Transform surface coordinates to device coordinates. const scale = gtk_widget.getScaleFactor(); self.blur_region.width = gtk_widget.getWidth() * scale; self.blur_region.height = gtk_widget.getHeight() * scale; - const blur = config.@"background-blur"; log.debug("set blur={}, window xid={}, region={}", .{ blur, self.x11_surface.getXid(), self.blur_region, }); - if (blur.enabled()) { + // Only update X11 properties when the blur region actually changes + const region_changed = if (self.last_applied_blur_region) |last| + !std.meta.eql(self.blur_region, last) + else + true; + + if (region_changed) { try self.changeProperty( Region, self.app.atoms.kde_blur, @@ -276,8 +295,7 @@ pub const Window = struct { .{ .mode = .replace }, &self.blur_region, ); - } else { - try self.deleteProperty(self.app.atoms.kde_blur); + self.last_applied_blur_region = self.blur_region; } } @@ -307,14 +325,23 @@ pub const Window = struct { .auto, .client, .none => false, }; - try self.changeProperty( - MotifWMHints, - self.app.atoms.motif_wm_hints, - self.app.atoms.motif_wm_hints, - ._32, - .{ .mode = .replace }, - &hints, - ); + // Only update decoration hints when they actually change + const hints_changed = if (self.last_applied_decoration_hints) |last| + !std.meta.eql(hints, last) + else + true; + + if (hints_changed) { + try self.changeProperty( + MotifWMHints, + self.app.atoms.motif_wm_hints, + self.app.atoms.motif_wm_hints, + ._32, + .{ .mode = .replace }, + &hints, + ); + self.last_applied_decoration_hints = hints; + } } pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { From 47462ccc954e191506efac1f77389166ba1dcee3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 09:38:36 -0800 Subject: [PATCH 686/702] clean up some blurring code --- src/apprt/gtk/winproto/x11.zig | 63 ++++++++++++++++------------------ 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index c73d4d482..1e73c6139 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -173,7 +173,9 @@ pub const Window = struct { blur_region: Region = .{}, - // Cache last applied values to avoid redundant X11 property updates + // Cache last applied values to avoid redundant X11 property updates. + // Redundant property updates seem to cause some visual glitches + // with some window managers: https://github.com/ghostty-org/ghostty/pull/8075 last_applied_blur_region: ?Region = null, last_applied_decoration_hints: ?MotifWMHints = null, @@ -266,6 +268,7 @@ pub const Window = struct { try self.deleteProperty(self.app.atoms.kde_blur); self.last_applied_blur_region = null; } + return; } @@ -274,29 +277,26 @@ pub const Window = struct { self.blur_region.width = gtk_widget.getWidth() * scale; self.blur_region.height = gtk_widget.getHeight() * scale; + // Only update X11 properties when the blur region actually changes + if (self.last_applied_blur_region) |last| { + if (std.meta.eql(self.blur_region, last)) return; + } + log.debug("set blur={}, window xid={}, region={}", .{ blur, self.x11_surface.getXid(), self.blur_region, }); - // Only update X11 properties when the blur region actually changes - const region_changed = if (self.last_applied_blur_region) |last| - !std.meta.eql(self.blur_region, last) - else - true; - - if (region_changed) { - try self.changeProperty( - Region, - self.app.atoms.kde_blur, - c.XA_CARDINAL, - ._32, - .{ .mode = .replace }, - &self.blur_region, - ); - self.last_applied_blur_region = self.blur_region; - } + try self.changeProperty( + Region, + self.app.atoms.kde_blur, + c.XA_CARDINAL, + ._32, + .{ .mode = .replace }, + &self.blur_region, + ); + self.last_applied_blur_region = self.blur_region; } fn syncDecorations(self: *Window) !void { @@ -326,22 +326,19 @@ pub const Window = struct { }; // Only update decoration hints when they actually change - const hints_changed = if (self.last_applied_decoration_hints) |last| - !std.meta.eql(hints, last) - else - true; - - if (hints_changed) { - try self.changeProperty( - MotifWMHints, - self.app.atoms.motif_wm_hints, - self.app.atoms.motif_wm_hints, - ._32, - .{ .mode = .replace }, - &hints, - ); - self.last_applied_decoration_hints = hints; + if (self.last_applied_decoration_hints) |last| { + if (std.meta.eql(hints, last)) return; } + + try self.changeProperty( + MotifWMHints, + self.app.atoms.motif_wm_hints, + self.app.atoms.motif_wm_hints, + ._32, + .{ .mode = .replace }, + &hints, + ); + self.last_applied_decoration_hints = hints; } pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { From 07578d5e3f12e4fe20c899b1472a21bc768671dc Mon Sep 17 00:00:00 2001 From: Uzair Aftab Date: Mon, 15 Dec 2025 18:59:34 +0100 Subject: [PATCH 687/702] nix: replace deprecated system with stdenv.hostPlatform.system --- nix/devShell.nix | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nix/devShell.nix b/nix/devShell.nix index 4aaf4ef5c..d37107133 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -70,7 +70,6 @@ wayland-scanner, wayland-protocols, zon2nix, - system, pkgs, # needed by GTK for loading SVG icons while running from within the # developer shell @@ -100,7 +99,7 @@ in scdoc zig zip - zon2nix.packages.${system}.zon2nix + zon2nix.packages.${stdenv.hostPlatform.system}.zon2nix # For web and wasm stuff nodejs From a02364cbefe0cb718679ec49543c979aa1a134cc Mon Sep 17 00:00:00 2001 From: Justy Null Date: Fri, 19 Sep 2025 22:23:32 -0700 Subject: [PATCH 688/702] feat: add liquid glass background effect support --- .../Window Styles/TerminalWindow.swift | 61 +++++++++++++++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 13 +++- src/config/Config.zig | 25 ++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 160473328..69b4b3f1a 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -44,6 +44,9 @@ class TerminalWindow: NSWindow { true } + /// Glass effect view for liquid glass background when transparency is enabled + private var glassEffectView: NSView? + /// Gets the terminal controller from the window controller. var terminalController: TerminalController? { windowController as? TerminalController @@ -476,6 +479,11 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) + // Add liquid glass behind terminal content + if #available(macOS 26.0, *), derivedConfig.backgroundGlassStyle != "off" { + setupGlassLayer() + } + if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, @@ -484,6 +492,11 @@ class TerminalWindow: NSWindow { } else { isOpaque = true + // Remove liquid glass when not transparent + if #available(macOS 26.0, *) { + removeGlassLayer() + } + let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) self.backgroundColor = backgroundColor.withAlphaComponent(1) } @@ -562,6 +575,51 @@ class TerminalWindow: NSWindow { } } + // MARK: Glass + + @available(macOS 26.0, *) + private func setupGlassLayer() { + guard let contentView = contentView else { return } + + // Remove existing glass effect view + glassEffectView?.removeFromSuperview() + + // Get the window content view (parent of the NSHostingView) + guard let windowContentView = contentView.superview else { return } + + // Create NSGlassEffectView for native glass effect + let effectView = NSGlassEffectView() + + // Map Ghostty config to NSGlassEffectView style + let glassStyle = derivedConfig.backgroundGlassStyle + switch glassStyle { + case "regular": + effectView.style = NSGlassEffectView.Style.regular + case "clear": + effectView.style = NSGlassEffectView.Style.clear + default: + // Should not reach here since we check for "off" before calling setupGlassLayer() + return + } + + effectView.cornerRadius = 18 + effectView.tintColor = preferredBackgroundColor + + effectView.frame = windowContentView.bounds + effectView.autoresizingMask = [.width, .height] + + // Position BELOW the terminal content to act as background + windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView) + glassEffectView = effectView + } + + @available(macOS 26.0, *) + private func removeGlassLayer() { + glassEffectView?.removeFromSuperview() + glassEffectView = nil + } + +>>>>>>> Conflict 4 of 4 ends // MARK: Config struct DerivedConfig { @@ -569,12 +627,14 @@ class TerminalWindow: NSWindow { let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons + let backgroundGlassStyle: String init() { self.title = nil self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible + self.backgroundGlassStyle = "off" } init(_ config: Ghostty.Config) { @@ -582,6 +642,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons + self.backgroundGlassStyle = config.backgroundGlassStyle } } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 2df0a8656..20629c58f 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -261,6 +261,17 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } + var backgroundGlassStyle: String { + let defaultValue = "off" + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "background-glass-style" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + return String(cString: ptr) + } + + var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } @@ -635,7 +646,7 @@ extension Ghostty.Config { static let title = BellFeatures(rawValue: 1 << 3) static let border = BellFeatures(rawValue: 1 << 4) } - + enum MacDockDropBehavior: String { case new_tab = "new-tab" case new_window = "new-window" diff --git a/src/config/Config.zig b/src/config/Config.zig index 1deb3e532..b542bcb1d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -950,6 +950,24 @@ palette: Palette = .{}, /// doing so. @"background-blur": BackgroundBlur = .false, +/// The style of the glass effect when `background-opacity` is less than 1 +/// and the terminal is using a modern glass effect (macOS 26.0+ only). +/// +/// Valid values are: +/// +/// * `off` - No glass effect +/// * `regular` - Standard glass effect with some opacity +/// * `clear` - Highly transparent glass effect +/// +/// This setting only takes effect on macOS 26.0+ when transparency is enabled +/// (`background-opacity` < 1). On older macOS versions or when transparency +/// is disabled, this setting has no effect. +/// +/// The default value is `off`. +/// +/// Available since: 1.2.2 +@"background-glass-style": BackgroundGlassStyle = .off, + /// The opacity level (opposite of transparency) of an unfocused split. /// Unfocused splits by default are slightly faded out to make it easier to see /// which split is focused. To disable this feature, set this value to 1. @@ -8383,6 +8401,13 @@ pub const BackgroundBlur = union(enum) { } }; +/// See background-glass-style +pub const BackgroundGlassStyle = enum { + off, + regular, + clear, +}; + /// See window-decoration pub const WindowDecoration = enum(c_int) { auto, From d40af61960b41652d11e45429cb41b42f40b50a9 Mon Sep 17 00:00:00 2001 From: Justy Null Date: Sat, 20 Sep 2025 11:44:04 -0700 Subject: [PATCH 689/702] refactor: migrate background glass effect to new macos-background-style config --- .../Window Styles/TerminalWindow.swift | 38 ++++++++----- macos/Sources/Ghostty/Ghostty.Config.swift | 10 ++-- macos/Sources/Ghostty/Package.swift | 17 ++++-- src/config/Config.zig | 54 ++++++++++--------- 4 files changed, 70 insertions(+), 49 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 69b4b3f1a..51f4d5b1c 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -480,11 +480,9 @@ class TerminalWindow: NSWindow { backgroundColor = .white.withAlphaComponent(0.001) // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.backgroundGlassStyle != "off" { + if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .blur { setupGlassLayer() - } - - if let appDelegate = NSApp.delegate as? AppDelegate { + } else if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -576,7 +574,7 @@ class TerminalWindow: NSWindow { } // MARK: Glass - + @available(macOS 26.0, *) private func setupGlassLayer() { guard let contentView = contentView else { return } @@ -591,18 +589,18 @@ class TerminalWindow: NSWindow { let effectView = NSGlassEffectView() // Map Ghostty config to NSGlassEffectView style - let glassStyle = derivedConfig.backgroundGlassStyle - switch glassStyle { - case "regular": + let backgroundStyle = derivedConfig.macosBackgroundStyle + switch backgroundStyle { + case .regularGlass: effectView.style = NSGlassEffectView.Style.regular - case "clear": + case .clearGlass: effectView.style = NSGlassEffectView.Style.clear default: - // Should not reach here since we check for "off" before calling setupGlassLayer() + // Should not reach here since we check for "default" before calling setupGlassLayer() return } - effectView.cornerRadius = 18 + effectView.cornerRadius = derivedConfig.windowCornerRadius effectView.tintColor = preferredBackgroundColor effectView.frame = windowContentView.bounds @@ -627,14 +625,16 @@ class TerminalWindow: NSWindow { let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons - let backgroundGlassStyle: String + let macosBackgroundStyle: Ghostty.MacBackgroundStyle + let windowCornerRadius: CGFloat init() { self.title = nil self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible - self.backgroundGlassStyle = "off" + self.macosBackgroundStyle = .blur + self.windowCornerRadius = 16 } init(_ config: Ghostty.Config) { @@ -642,7 +642,17 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons - self.backgroundGlassStyle = config.backgroundGlassStyle + self.macosBackgroundStyle = config.macosBackgroundStyle + + // Set corner radius based on macos-titlebar-style + // Native, transparent, and hidden styles use 16pt radius + // Tabs style uses 20pt radius + switch config.macosTitlebarStyle { + case "tabs": + self.windowCornerRadius = 20 + default: + self.windowCornerRadius = 16 + } } } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 20629c58f..1488b0790 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -261,17 +261,17 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } - var backgroundGlassStyle: String { - let defaultValue = "off" + var macosBackgroundStyle: MacBackgroundStyle { + let defaultValue = MacBackgroundStyle.blur guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil - let key = "background-glass-style" + let key = "macos-background-style" guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } guard let ptr = v else { return defaultValue } - return String(cString: ptr) + let str = String(cString: ptr) + return MacBackgroundStyle(rawValue: str) ?? defaultValue } - var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 258857e8e..e769b814e 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -56,7 +56,7 @@ extension Ghostty { case app case zig_run } - + /// Returns the mechanism that launched the app. This is based on an env var so /// its up to the env var being set in the correct circumstance. static var launchSource: LaunchSource { @@ -65,7 +65,7 @@ extension Ghostty { // source. If its unset we assume we're in a CLI environment. return .cli } - + // If the env var is set but its unknown then we default back to the app. return LaunchSource(rawValue: envValue) ?? .app } @@ -76,17 +76,17 @@ extension Ghostty { extension Ghostty { class AllocatedString { private let cString: ghostty_string_s - + init(_ c: ghostty_string_s) { self.cString = c } - + var string: String { guard let ptr = cString.ptr else { return "" } let data = Data(bytes: ptr, count: Int(cString.len)) return String(data: data, encoding: .utf8) ?? "" } - + deinit { ghostty_string_free(cString) } @@ -352,6 +352,13 @@ extension Ghostty { case hidden } + /// Enum for the macos-background-style config option + enum MacBackgroundStyle: String { + case blur + case regularGlass = "regular-glass" + case clearGlass = "clear-glass" + } + /// Enum for auto-update-channel config option enum AutoUpdateChannel: String { case tip diff --git a/src/config/Config.zig b/src/config/Config.zig index b542bcb1d..287efa89d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -950,24 +950,6 @@ palette: Palette = .{}, /// doing so. @"background-blur": BackgroundBlur = .false, -/// The style of the glass effect when `background-opacity` is less than 1 -/// and the terminal is using a modern glass effect (macOS 26.0+ only). -/// -/// Valid values are: -/// -/// * `off` - No glass effect -/// * `regular` - Standard glass effect with some opacity -/// * `clear` - Highly transparent glass effect -/// -/// This setting only takes effect on macOS 26.0+ when transparency is enabled -/// (`background-opacity` < 1). On older macOS versions or when transparency -/// is disabled, this setting has no effect. -/// -/// The default value is `off`. -/// -/// Available since: 1.2.2 -@"background-glass-style": BackgroundGlassStyle = .off, - /// The opacity level (opposite of transparency) of an unfocused split. /// Unfocused splits by default are slightly faded out to make it easier to see /// which split is focused. To disable this feature, set this value to 1. @@ -3124,6 +3106,28 @@ keybind: Keybinds = .{}, /// Available since: 1.2.0 @"macos-shortcuts": MacShortcuts = .ask, +/// The background style for macOS windows when `background-opacity` is less than 1. +/// This controls the visual effect applied behind the terminal background. +/// +/// Valid values are: +/// +/// * `blur` - Uses the standard background behavior. The `background-blur` +/// configuration will control whether blur is applied (available on all macOS versions) +/// * `regular-glass` - Standard glass effect with some opacity (macOS 26.0+ only) +/// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) +/// +/// The `blur` option does not force any blur effect - it simply respects the +/// `background-blur` configuration. The glass options override `background-blur` +/// and apply their own visual effects. +/// +/// On macOS versions prior to 26.0, only `blur` has an effect. The glass +/// options will fall back to `blur` behavior on older versions. +/// +/// The default value is `blur`. +/// +/// Available since: 1.2.2 +@"macos-background-style": MacBackgroundStyle = .blur, + /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// /// This makes it so that resource management can be done on a per-surface @@ -7708,6 +7712,13 @@ pub const MacShortcuts = enum { ask, }; +/// See macos-background-style +pub const MacBackgroundStyle = enum { + blur, + @"regular-glass", + @"clear-glass", +}; + /// See gtk-single-instance pub const GtkSingleInstance = enum { false, @@ -8401,13 +8412,6 @@ pub const BackgroundBlur = union(enum) { } }; -/// See background-glass-style -pub const BackgroundGlassStyle = enum { - off, - regular, - clear, -}; - /// See window-decoration pub const WindowDecoration = enum(c_int) { auto, From 45aceace726656e49e145c2f0fa504eb97e80e2e Mon Sep 17 00:00:00 2001 From: Justy Null Date: Sat, 20 Sep 2025 16:05:05 -0700 Subject: [PATCH 690/702] fix: disable renderer background when macOS effects are enabled --- .../Window Styles/TerminalWindow.swift | 4 ++-- macos/Sources/Ghostty/Ghostty.Config.swift | 2 +- macos/Sources/Ghostty/Package.swift | 2 +- src/config/Config.zig | 15 +++---------- src/renderer/generic.zig | 22 +++++++++++++++++-- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 51f4d5b1c..07deb6ded 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -480,7 +480,7 @@ class TerminalWindow: NSWindow { backgroundColor = .white.withAlphaComponent(0.001) // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .blur { + if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .defaultStyle { setupGlassLayer() } else if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( @@ -633,7 +633,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible - self.macosBackgroundStyle = .blur + self.macosBackgroundStyle = .defaultStyle self.windowCornerRadius = 16 } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 1488b0790..4a80f2af8 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -262,7 +262,7 @@ extension Ghostty { } var macosBackgroundStyle: MacBackgroundStyle { - let defaultValue = MacBackgroundStyle.blur + let defaultValue = MacBackgroundStyle.defaultStyle guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-background-style" diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index e769b814e..4279cc012 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -354,7 +354,7 @@ extension Ghostty { /// Enum for the macos-background-style config option enum MacBackgroundStyle: String { - case blur + case defaultStyle = "default" case regularGlass = "regular-glass" case clearGlass = "clear-glass" } diff --git a/src/config/Config.zig b/src/config/Config.zig index 287efa89d..09731b13d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3111,22 +3111,13 @@ keybind: Keybinds = .{}, /// /// Valid values are: /// -/// * `blur` - Uses the standard background behavior. The `background-blur` +/// * `default` - Uses the standard background behavior. The `background-blur` /// configuration will control whether blur is applied (available on all macOS versions) /// * `regular-glass` - Standard glass effect with some opacity (macOS 26.0+ only) /// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) /// -/// The `blur` option does not force any blur effect - it simply respects the -/// `background-blur` configuration. The glass options override `background-blur` -/// and apply their own visual effects. -/// -/// On macOS versions prior to 26.0, only `blur` has an effect. The glass -/// options will fall back to `blur` behavior on older versions. -/// -/// The default value is `blur`. -/// /// Available since: 1.2.2 -@"macos-background-style": MacBackgroundStyle = .blur, +@"macos-background-style": MacBackgroundStyle = .default, /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// @@ -7714,7 +7705,7 @@ pub const MacShortcuts = enum { /// See macos-background-style pub const MacBackgroundStyle = enum { - blur, + default, @"regular-glass", @"clear-glass", }; diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 8c55da602..013761f1e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -561,6 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { vsync: bool, colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, + macos_background_style: configpkg.Config.MacBackgroundStyle, pub fn init( alloc_gpa: Allocator, @@ -633,6 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", + .macos_background_style = config.@"macos-background-style", .arena = arena, }; } @@ -644,6 +646,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } }; + /// Determines if the terminal background should be disabled based on platform and config. + /// On macOS, when background effects are enabled (background style != default), the effect + /// layer handles the background rendering instead of the terminal renderer. + fn shouldDisableBackground(config: DerivedConfig) bool { + return switch (builtin.os.tag) { + .macos => config.macos_background_style != .default, + else => false, + }; + } + pub fn init(alloc: Allocator, options: renderer.Options) !Self { // Initialize our graphics API wrapper, this will prepare the // surface provided by the apprt and set up any API-specific @@ -716,7 +728,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, - @intFromFloat(@round(options.config.background_opacity * 255.0)), + if (shouldDisableBackground(options.config)) + 0 + else + @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ .cursor_wide = false, @@ -1293,7 +1308,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 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)), + if (shouldDisableBackground(self.config)) + 0 + else + @intFromFloat(@round(self.config.background_opacity * 255.0)), }; } } From d5c378cd6bb8541b7e6d914e1fc7900a257171f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 12 Oct 2025 13:18:15 -0700 Subject: [PATCH 691/702] minor style tweaks --- .../Terminal/Window Styles/TerminalWindow.swift | 5 +++-- src/config/Config.zig | 13 ++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 07deb6ded..444ce28bd 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -573,6 +573,7 @@ class TerminalWindow: NSWindow { } } +#if compiler(>=6.2) // MARK: Glass @available(macOS 26.0, *) @@ -616,8 +617,8 @@ class TerminalWindow: NSWindow { glassEffectView?.removeFromSuperview() glassEffectView = nil } - ->>>>>>> Conflict 4 of 4 ends +#endif // compiler(>=6.2) + // MARK: Config struct DerivedConfig { diff --git a/src/config/Config.zig b/src/config/Config.zig index 09731b13d..0e7b51c4f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3106,17 +3106,20 @@ keybind: Keybinds = .{}, /// Available since: 1.2.0 @"macos-shortcuts": MacShortcuts = .ask, -/// The background style for macOS windows when `background-opacity` is less than 1. -/// This controls the visual effect applied behind the terminal background. +/// The background style for macOS windows when `background-opacity` is less +/// than 1. This controls the visual effect applied behind the terminal +/// background. /// /// Valid values are: /// /// * `default` - Uses the standard background behavior. The `background-blur` -/// configuration will control whether blur is applied (available on all macOS versions) -/// * `regular-glass` - Standard glass effect with some opacity (macOS 26.0+ only) +/// configuration will control whether blur is applied (available on +/// all macOS versions) +/// * `regular-glass` - Standard glass effect with some opacity (macOS +/// 26.0+ only) /// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) /// -/// Available since: 1.2.2 +/// Available since: 1.3.0 @"macos-background-style": MacBackgroundStyle = .default, /// Put every surface (tab, split, window) into a dedicated Linux cgroup. From 42493de0989d1a2c27f5c27deb471c6e08d66ad6 Mon Sep 17 00:00:00 2001 From: Justy Null Date: Fri, 17 Oct 2025 18:39:11 -0700 Subject: [PATCH 692/702] fix: make titlebar transparent when using glass background style --- .../Terminal/Window Styles/TerminalWindow.swift | 3 +++ .../TransparentTitlebarTerminalWindow.swift | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 444ce28bd..6105cac53 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -627,6 +627,7 @@ class TerminalWindow: NSWindow { let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons let macosBackgroundStyle: Ghostty.MacBackgroundStyle + let macosTitlebarStyle: String let windowCornerRadius: CGFloat init() { @@ -635,6 +636,7 @@ class TerminalWindow: NSWindow { self.backgroundOpacity = 1 self.macosWindowButtons = .visible self.macosBackgroundStyle = .defaultStyle + self.macosTitlebarStyle = "transparent" self.windowCornerRadius = 16 } @@ -644,6 +646,7 @@ class TerminalWindow: NSWindow { self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons self.macosBackgroundStyle = config.macosBackgroundStyle + self.macosTitlebarStyle = config.macosTitlebarStyle // Set corner radius based on macos-titlebar-style // Native, transparent, and hidden styles use 16pt radius diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 08d56c83d..eea1956fc 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -88,7 +88,17 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // color of the titlebar in native fullscreen view. if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") { titlebarView.wantsLayer = true - titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor + + // For glass background styles, use a transparent titlebar to let the glass effect show through + // Only apply this for transparent and tabs titlebar styles + let isGlassStyle = derivedConfig.macosBackgroundStyle == .regularGlass || + derivedConfig.macosBackgroundStyle == .clearGlass + let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || + derivedConfig.macosTitlebarStyle == "tabs" + + titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar) + ? NSColor.clear.cgColor + : preferredBackgroundColor?.cgColor } // In all cases, we have to hide the background view since this has multiple subviews From bb2307116662bdf778906f48260524482fba2312 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 10:29:21 -0800 Subject: [PATCH 693/702] config: change macos-background-style to be enums on background-blur --- macos/Sources/Ghostty/Ghostty.Config.swift | 52 +++++++++++++++++++-- src/config/Config.zig | 54 ++++++++++++++++++---- src/config/c_get.zig | 18 ++++++-- src/renderer/generic.zig | 5 +- 4 files changed, 109 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 4a80f2af8..5a622d19c 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -413,12 +413,12 @@ extension Ghostty { return v; } - var backgroundBlurRadius: Int { - guard let config = self.config else { return 1 } - var v: Int = 0 + var backgroundBlur: BackgroundBlur { + guard let config = self.config else { return .disabled } + var v: Int16 = 0 let key = "background-blur" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) - return v; + return BackgroundBlur(fromCValue: v) } var unfocusedSplitOpacity: Double { @@ -637,6 +637,50 @@ extension Ghostty.Config { case download } + /// Background blur configuration that maps from the C API values. + /// Positive values represent blur radius, special negative values + /// represent macOS-specific glass effects. + enum BackgroundBlur: Equatable { + case disabled + case radius(Int) + case macosGlassRegular + case macosGlassClear + + init(fromCValue value: Int16) { + switch value { + case 0: + self = .disabled + case -1: + self = .macosGlassRegular + case -2: + self = .macosGlassClear + default: + self = .radius(Int(value)) + } + } + + var isEnabled: Bool { + switch self { + case .disabled: + return false + default: + return true + } + } + + /// Returns the blur radius if applicable, nil for glass effects. + var radius: Int? { + switch self { + case .disabled: + return nil + case .radius(let r): + return r + case .macosGlassRegular, .macosGlassClear: + return nil + } + } + } + struct BellFeatures: OptionSet { let rawValue: CUnsignedInt diff --git a/src/config/Config.zig b/src/config/Config.zig index 0e7b51c4f..4a3810901 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -8338,6 +8338,8 @@ pub const AutoUpdate = enum { pub const BackgroundBlur = union(enum) { false, true, + @"macos-glass-regular", + @"macos-glass-clear", radius: u8, pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void { @@ -8347,14 +8349,35 @@ pub const BackgroundBlur = union(enum) { return; }; - self.* = if (cli.args.parseBool(input_)) |b| - if (b) .true else .false - else |_| - .{ .radius = std.fmt.parseInt( - u8, - input_, - 0, - ) catch return error.InvalidValue }; + // Try to parse normal bools + if (cli.args.parseBool(input_)) |b| { + self.* = if (b) .true else .false; + return; + } else |_| {} + + // Try to parse enums + if (std.meta.stringToEnum( + std.meta.Tag(BackgroundBlur), + input_, + )) |v| switch (v) { + inline else => |tag| tag: { + // We can only parse void types + const info = std.meta.fieldInfo(BackgroundBlur, tag); + if (info.type != void) break :tag; + self.* = @unionInit( + BackgroundBlur, + @tagName(tag), + {}, + ); + return; + }, + }; + + self.* = .{ .radius = std.fmt.parseInt( + u8, + input_, + 0, + ) catch return error.InvalidValue }; } pub fn enabled(self: BackgroundBlur) bool { @@ -8365,11 +8388,16 @@ pub const BackgroundBlur = union(enum) { }; } - pub fn cval(self: BackgroundBlur) u8 { + pub fn cval(self: BackgroundBlur) i16 { return switch (self) { .false => 0, .true => 20, .radius => |v| v, + // I hate sentinel values like this but this is only for + // our macOS application currently. We can switch to a proper + // tagged union if we ever need to. + .@"macos-glass-regular" => -1, + .@"macos-glass-clear" => -2, }; } @@ -8381,6 +8409,8 @@ pub const BackgroundBlur = union(enum) { .false => try formatter.formatEntry(bool, false), .true => try formatter.formatEntry(bool, true), .radius => |v| try formatter.formatEntry(u8, v), + .@"macos-glass-regular" => try formatter.formatEntry([]const u8, "macos-glass-regular"), + .@"macos-glass-clear" => try formatter.formatEntry([]const u8, "macos-glass-clear"), } } @@ -8400,6 +8430,12 @@ pub const BackgroundBlur = union(enum) { try v.parseCLI("42"); try testing.expectEqual(42, v.radius); + try v.parseCLI("macos-glass-regular"); + try testing.expectEqual(.@"macos-glass-regular", v); + + try v.parseCLI("macos-glass-clear"); + try testing.expectEqual(.@"macos-glass-clear", v); + try testing.expectError(error.InvalidValue, v.parseCLI("")); try testing.expectError(error.InvalidValue, v.parseCLI("aaaa")); try testing.expectError(error.InvalidValue, v.parseCLI("420")); diff --git a/src/config/c_get.zig b/src/config/c_get.zig index f235f596a..0f8f897a2 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -193,20 +193,32 @@ test "c_get: background-blur" { { c.@"background-blur" = .false; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(0, cval); } { c.@"background-blur" = .true; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(20, cval); } { c.@"background-blur" = .{ .radius = 42 }; - var cval: u8 = undefined; + var cval: i16 = undefined; try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(42, cval); } + { + c.@"background-blur" = .@"macos-glass-regular"; + var cval: i16 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(-1, cval); + } + { + c.@"background-blur" = .@"macos-glass-clear"; + var cval: i16 = undefined; + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); + try testing.expectEqual(-2, cval); + } } diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 013761f1e..e3db3cd93 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -728,10 +728,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, - if (shouldDisableBackground(options.config)) - 0 - else - @intFromFloat(@round(options.config.background_opacity * 255.0)), + @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ .cursor_wide = false, From a6ddf03a2ee5aec49bb4a4488e3061d8bd737839 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 10:48:20 -0800 Subject: [PATCH 694/702] remove the macos-background-style config --- .../Window Styles/TerminalWindow.swift | 17 +++++----- .../TransparentTitlebarTerminalWindow.swift | 3 +- macos/Sources/Ghostty/Ghostty.Config.swift | 21 ++++++------ macos/Sources/Ghostty/Package.swift | 7 ---- src/config/Config.zig | 29 ++++------------ src/renderer/generic.zig | 33 ++++++++++--------- 6 files changed, 42 insertions(+), 68 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 6105cac53..7066f7bd6 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -480,7 +480,7 @@ class TerminalWindow: NSWindow { backgroundColor = .white.withAlphaComponent(0.001) // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.macosBackgroundStyle != .defaultStyle { + if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { setupGlassLayer() } else if let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( @@ -590,14 +590,13 @@ class TerminalWindow: NSWindow { let effectView = NSGlassEffectView() // Map Ghostty config to NSGlassEffectView style - let backgroundStyle = derivedConfig.macosBackgroundStyle - switch backgroundStyle { - case .regularGlass: + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: effectView.style = NSGlassEffectView.Style.regular - case .clearGlass: + case .macosGlassClear: effectView.style = NSGlassEffectView.Style.clear default: - // Should not reach here since we check for "default" before calling setupGlassLayer() + // Should not reach here since we check for glass style before calling setupGlassLayer() return } @@ -623,10 +622,10 @@ class TerminalWindow: NSWindow { struct DerivedConfig { let title: String? + let backgroundBlur: Ghostty.Config.BackgroundBlur let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons - let macosBackgroundStyle: Ghostty.MacBackgroundStyle let macosTitlebarStyle: String let windowCornerRadius: CGFloat @@ -635,7 +634,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor.windowBackgroundColor self.backgroundOpacity = 1 self.macosWindowButtons = .visible - self.macosBackgroundStyle = .defaultStyle + self.backgroundBlur = .disabled self.macosTitlebarStyle = "transparent" self.windowCornerRadius = 16 } @@ -645,7 +644,7 @@ class TerminalWindow: NSWindow { self.backgroundColor = NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity self.macosWindowButtons = config.macosWindowButtons - self.macosBackgroundStyle = config.macosBackgroundStyle + self.backgroundBlur = config.backgroundBlur self.macosTitlebarStyle = config.macosTitlebarStyle // Set corner radius based on macos-titlebar-style diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index eea1956fc..57b889b82 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -91,8 +91,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // For glass background styles, use a transparent titlebar to let the glass effect show through // Only apply this for transparent and tabs titlebar styles - let isGlassStyle = derivedConfig.macosBackgroundStyle == .regularGlass || - derivedConfig.macosBackgroundStyle == .clearGlass + let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || derivedConfig.macosTitlebarStyle == "tabs" diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 5a622d19c..47826a104 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -261,17 +261,6 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } - var macosBackgroundStyle: MacBackgroundStyle { - let defaultValue = MacBackgroundStyle.defaultStyle - guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil - let key = "macos-background-style" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } - guard let ptr = v else { return defaultValue } - let str = String(cString: ptr) - return MacBackgroundStyle(rawValue: str) ?? defaultValue - } - var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } @@ -668,6 +657,16 @@ extension Ghostty.Config { } } + /// Returns true if this is a macOS glass style (regular or clear). + var isGlassStyle: Bool { + switch self { + case .macosGlassRegular, .macosGlassClear: + return true + default: + return false + } + } + /// Returns the blur radius if applicable, nil for glass effects. var radius: Int? { switch self { diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 4279cc012..b834ea31f 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -352,13 +352,6 @@ extension Ghostty { case hidden } - /// Enum for the macos-background-style config option - enum MacBackgroundStyle: String { - case defaultStyle = "default" - case regularGlass = "regular-glass" - case clearGlass = "clear-glass" - } - /// Enum for auto-update-channel config option enum AutoUpdateChannel: String { case tip diff --git a/src/config/Config.zig b/src/config/Config.zig index 4a3810901..18224a3cd 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -927,6 +927,12 @@ palette: Palette = .{}, /// reasonable for a good looking blur. Higher blur intensities may /// cause strange rendering and performance issues. /// +/// On macOS 26.0 and later, there are additional special values that +/// can be set to use the native macOS glass effects: +/// +/// * `macos-glass-regular` - Standard glass effect with some opacity +/// * `macos-glass-clear` - Highly transparent glass effect +/// /// Supported on macOS and on some Linux desktop environments, including: /// /// * KDE Plasma (Wayland and X11) @@ -3106,22 +3112,6 @@ keybind: Keybinds = .{}, /// Available since: 1.2.0 @"macos-shortcuts": MacShortcuts = .ask, -/// The background style for macOS windows when `background-opacity` is less -/// than 1. This controls the visual effect applied behind the terminal -/// background. -/// -/// Valid values are: -/// -/// * `default` - Uses the standard background behavior. The `background-blur` -/// configuration will control whether blur is applied (available on -/// all macOS versions) -/// * `regular-glass` - Standard glass effect with some opacity (macOS -/// 26.0+ only) -/// * `clear-glass` - Highly transparent glass effect (macOS 26.0+ only) -/// -/// Available since: 1.3.0 -@"macos-background-style": MacBackgroundStyle = .default, - /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// /// This makes it so that resource management can be done on a per-surface @@ -7706,13 +7696,6 @@ pub const MacShortcuts = enum { ask, }; -/// See macos-background-style -pub const MacBackgroundStyle = enum { - default, - @"regular-glass", - @"clear-glass", -}; - /// See gtk-single-instance pub const GtkSingleInstance = enum { false, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index e3db3cd93..39eec7b43 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -561,7 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { vsync: bool, colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, - macos_background_style: configpkg.Config.MacBackgroundStyle, + background_blur: configpkg.Config.BackgroundBlur, pub fn init( alloc_gpa: Allocator, @@ -634,7 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", - .macos_background_style = config.@"macos-background-style", + .background_blur = config.@"background-blur", .arena = arena, }; } @@ -646,16 +646,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } }; - /// Determines if the terminal background should be disabled based on platform and config. - /// On macOS, when background effects are enabled (background style != default), the effect - /// layer handles the background rendering instead of the terminal renderer. - fn shouldDisableBackground(config: DerivedConfig) bool { - return switch (builtin.os.tag) { - .macos => config.macos_background_style != .default, - else => false, - }; - } - pub fn init(alloc: Allocator, options: renderer.Options) !Self { // Initialize our graphics API wrapper, this will prepare the // surface provided by the apprt and set up any API-specific @@ -728,6 +718,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { options.config.background.r, options.config.background.g, options.config.background.b, + // Note that if we're on macOS with glass effects + // we'll disable background opacity but we handle + // that in updateFrame. @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ @@ -1305,10 +1298,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.colors.background.r, self.terminal_state.colors.background.g, self.terminal_state.colors.background.b, - if (shouldDisableBackground(self.config)) - 0 - else - @intFromFloat(@round(self.config.background_opacity * 255.0)), + @intFromFloat(@round(self.config.background_opacity * 255.0)), + }; + + // If we're on macOS and have glass styles, we remove + // the background opacity because the glass effect handles + // it. + if (comptime builtin.os.tag == .macos) switch (self.config.background_blur) { + .@"macos-glass-regular", + .@"macos-glass-clear", + => self.uniforms.bg_color[3] = 0, + + else => {}, }; } } From 8482e0777db9f675641c5567d562f5f4d43b5fc4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 10:58:35 -0800 Subject: [PATCH 695/702] macos: remove glass view on syncAppearance with blur --- .../Window Styles/TerminalWindow.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 7066f7bd6..0c0ac0646 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -483,6 +483,11 @@ class TerminalWindow: NSWindow { if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { setupGlassLayer() } else if let appDelegate = NSApp.delegate as? AppDelegate { + // If we had a prior glass layer we should remove it + if #available(macOS 26.0, *) { + removeGlassLayer() + } + ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -578,12 +583,11 @@ class TerminalWindow: NSWindow { @available(macOS 26.0, *) private func setupGlassLayer() { - guard let contentView = contentView else { return } - // Remove existing glass effect view - glassEffectView?.removeFromSuperview() - + removeGlassLayer() + // Get the window content view (parent of the NSHostingView) + guard let contentView else { return } guard let windowContentView = contentView.superview else { return } // Create NSGlassEffectView for native glass effect @@ -596,13 +600,13 @@ class TerminalWindow: NSWindow { case .macosGlassClear: effectView.style = NSGlassEffectView.Style.clear default: - // Should not reach here since we check for glass style before calling setupGlassLayer() - return + // Should not reach here since we check for glass style before calling + // setupGlassLayer() + assertionFailure() } effectView.cornerRadius = derivedConfig.windowCornerRadius effectView.tintColor = preferredBackgroundColor - effectView.frame = windowContentView.bounds effectView.autoresizingMask = [.width, .height] From 4e10f27be4fbd1d0c8c2dc84dd9bc3deab339e0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 11:00:53 -0800 Subject: [PATCH 696/702] config: macos blur settings enable blur on non-Mac --- src/config/Config.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 18224a3cd..409e35516 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -933,6 +933,9 @@ palette: Palette = .{}, /// * `macos-glass-regular` - Standard glass effect with some opacity /// * `macos-glass-clear` - Highly transparent glass effect /// +/// If the macOS values are set, then this implies `background-blur = true` +/// on non-macOS platforms. +/// /// Supported on macOS and on some Linux desktop environments, including: /// /// * KDE Plasma (Wayland and X11) @@ -8368,6 +8371,11 @@ pub const BackgroundBlur = union(enum) { .false => false, .true => true, .radius => |v| v > 0, + + // We treat these as true because they both imply some blur! + // This has the effect of making the standard blur happen on + // Linux. + .@"macos-glass-regular", .@"macos-glass-clear" => true, }; } From f8c03bb6f6ff7cf71c7e04077059173859496cd2 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 21 Sep 2025 00:21:14 -0500 Subject: [PATCH 697/702] logging: document GHOSTTY_LOG and make it more flexible --- HACKING.md | 30 ++++++++++++++++++ src/Surface.zig | 3 ++ src/apprt/gtk/class/application.zig | 6 +++- src/build/GhosttyXcodebuild.zig | 2 +- src/build/mdgen/ghostty_1_footer.md | 13 ++++++++ src/build/mdgen/ghostty_5_header.md | 39 +++++++++++++++++++---- src/cli/args.zig | 2 +- src/global.zig | 23 ++++++-------- src/main_ghostty.zig | 49 ++++++++++++++++------------- 9 files changed, 124 insertions(+), 43 deletions(-) diff --git a/HACKING.md b/HACKING.md index 0a4bbef20..bde50ec99 100644 --- a/HACKING.md +++ b/HACKING.md @@ -93,6 +93,36 @@ produced. > may ask you to fix it and close the issue. It isn't a maintainers job to > review a PR so broken that it requires significant rework to be acceptable. +## Logging + +Ghostty can write logs to a number of destinations. On all platforms, logging to +`stderr` is available. Depending on the platform and how Ghostty was launched, +logs sent to `stderr` may be stored by the system and made available for later +retrieval. + +On Linux if Ghostty is launched by the default `systemd` user service, you can use +`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. + +On macOS logging to the macOS unified log is available and enabled by default. +Use the system `log` CLI to view Ghostty's logs: `sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. + +Ghostty's logging can be configured in two ways. The first is by what +optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug` +optimizations debug logs will be output to `stderr`. If Ghostty is compiled with +any other optimization the debug logs will not be output to `stderr`. + +Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used +to control which destinations receive logs. Ghostty currently defines two +destinations: + +- `stderr` - logging to `stderr`. +- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. + ## Linting ### Prettier diff --git a/src/Surface.zig b/src/Surface.zig index 19c2662c1..96aaf84d8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -607,6 +607,9 @@ pub fn init( }; errdefer env.deinit(); + // don't leak GHOSTTY_LOG to any subprocesses + env.remove("GHOSTTY_LOG"); + // Initialize our IO backend var io_exec = try termio.Exec.init(alloc, .{ .command = command, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index d404304d0..c951cc6ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -8,6 +8,8 @@ const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const build_config = @import("../../../build_config.zig"); +const state = &@import("../../../global.zig").state; const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const cgroup = @import("../cgroup.zig"); @@ -2677,7 +2679,9 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { /// disable it. @"vulkan-disable": bool = false, } = .{ - .opengl = config.@"gtk-opengl-debug", + // `gtk-opengl-debug` dumps logs directly to stderr so both must be true + // to enable OpenGL debugging. + .opengl = state.logging.stderr and config.@"gtk-opengl-debug", }; var gdk_disable: struct { diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 27691d744..5ca4c5e9a 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -151,7 +151,7 @@ pub fn init( // This overrides our default behavior and forces logs to show // up on stderr (in addition to the centralized macOS log). - open.setEnvironmentVariable("GHOSTTY_LOG", "1"); + open.setEnvironmentVariable("GHOSTTY_LOG", "stderr,macos"); // Configure how we're launching open.setEnvironmentVariable("GHOSTTY_MAC_LAUNCH_SOURCE", "zig_run"); diff --git a/src/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md index 88aa16273..a63a85fd4 100644 --- a/src/build/mdgen/ghostty_1_footer.md +++ b/src/build/mdgen/ghostty_1_footer.md @@ -37,6 +37,19 @@ precedence over the XDG environment locations. : **WINDOWS ONLY:** alternate location to search for configuration files. +**GHOSTTY_LOG** + +: The `GHOSTTY_LOG` environment variable can be used to control which +destinations receive logs. Ghostty currently defines two destinations: + +: - `stderr` - logging to `stderr`. +: - `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +: Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. + # BUGS See GitHub issues: diff --git a/src/build/mdgen/ghostty_5_header.md b/src/build/mdgen/ghostty_5_header.md index b9d4cb751..2b12f546a 100644 --- a/src/build/mdgen/ghostty_5_header.md +++ b/src/build/mdgen/ghostty_5_header.md @@ -73,16 +73,12 @@ the public config files of many Ghostty users for examples and inspiration. ## Configuration Errors If your configuration file has any errors, Ghostty does its best to ignore -them and move on. Configuration errors currently show up in the log. The log -is written directly to stderr, so it is up to you to figure out how to access -that for your system (for now). On macOS, you can also use the system `log` CLI -utility with `log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'`. +them and move on. Configuration errors will be logged. ## Debugging Configuration You can verify that configuration is being properly loaded by looking at the -debug output of Ghostty. Documentation for how to view the debug output is in -the "building Ghostty" section at the end of the README. +debug output of Ghostty. In the debug output, you should see in the first 20 lines or so messages about loading (or not loading) a configuration file, as well as any errors it may have @@ -93,3 +89,34 @@ will fall back to default values for erroneous keys. You can also view the full configuration Ghostty is loading using `ghostty +show-config` from the command-line. Use the `--help` flag to additional options for that command. + +## Logging + +Ghostty can write logs to a number of destinations. On all platforms, logging to +`stderr` is available. Depending on the platform and how Ghostty was launched, +logs sent to `stderr` may be stored by the system and made available for later +retrieval. + +On Linux if Ghostty is launched by the default `systemd` user service, you can use +`journald` to see Ghostty's logs: `journalctl --user --unit app-com.mitchellh.ghostty.service`. + +On macOS logging to the macOS unified log is available and enabled by default. +--Use the system `log` CLI to view Ghostty's logs: `sudo log stream level debug +--predicate 'subsystem=="com.mitchellh.ghostty"'`. + +Ghostty's logging can be configured in two ways. The first is by what +optimization level Ghostty is compiled with. If Ghostty is compiled with `Debug` +optimizations debug logs will be output to `stderr`. If Ghostty is compiled with +any other optimization the debug logs will not be output to `stderr`. + +Ghostty also checks the `GHOSTTY_LOG` environment variable. It can be used +to control which destinations receive logs. Ghostty currently defines two +destinations: + +- `stderr` - logging to `stderr`. +- `macos` - logging to macOS's unified log (has no effect on non-macOS platforms). + +Combine values with a comma to enable multiple destinations. Prefix a +destination with `no-` to disable it. Enabling and disabling destinations +can be done at the same time. Setting `GHOSTTY_LOG` to `true` will enable all +destinations. Setting `GHOSTTY_LOG` to `false` will disable all destinations. diff --git a/src/cli/args.zig b/src/cli/args.zig index 43a15ca06..bd5060d69 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -604,7 +604,7 @@ pub fn parseAutoStruct( return result; } -fn parsePackedStruct(comptime T: type, v: []const u8) !T { +pub fn parsePackedStruct(comptime T: type, v: []const u8) !T { const info = @typeInfo(T).@"struct"; comptime assert(info.layout == .@"packed"); diff --git a/src/global.zig b/src/global.zig index 8034fabe0..29eaf5f36 100644 --- a/src/global.zig +++ b/src/global.zig @@ -39,9 +39,13 @@ pub const GlobalState = struct { resources_dir: internal_os.ResourcesDir, /// Where logging should go - pub const Logging = union(enum) { - disabled: void, - stderr: void, + pub const Logging = packed struct { + /// Whether to log to stderr. For lib mode we always disable stderr + /// logging by default. Otherwise it's enabled by default. + stderr: bool = build_config.app_runtime != .none, + /// Whether to log to macOS's unified logging. Enabled by default + /// on macOS. + macos: bool = builtin.os.tag.isDarwin(), }; /// Initialize the global state. @@ -61,7 +65,7 @@ pub const GlobalState = struct { .gpa = null, .alloc = undefined, .action = null, - .logging = .{ .stderr = {} }, + .logging = .{}, .rlimits = .{}, .resources_dir = .{}, }; @@ -100,12 +104,7 @@ pub const GlobalState = struct { // If we have an action executing, we disable logging by default // since we write to stderr we don't want logs messing up our // output. - if (self.action != null) self.logging = .{ .disabled = {} }; - - // For lib mode we always disable stderr logging by default. - if (comptime build_config.app_runtime == .none) { - self.logging = .{ .disabled = {} }; - } + if (self.action != null) self.logging.stderr = false; // I don't love the env var name but I don't have it in my heart // to parse CLI args 3 times (once for actions, once for config, @@ -114,9 +113,7 @@ pub const GlobalState = struct { // easy to set. if ((try internal_os.getenv(self.alloc, "GHOSTTY_LOG"))) |v| { defer v.deinit(self.alloc); - if (v.value.len > 0) { - self.logging = .{ .stderr = {} }; - } + self.logging = cli.args.parsePackedStruct(Logging, v.value) catch .{}; } // Setup our signal handlers before logging diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 261e0ad7d..72d602989 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -118,19 +118,17 @@ fn logFn( comptime format: []const u8, args: anytype, ) void { - // Stuff we can do before the lock - const level_txt = comptime level.asText(); - const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; - - // Lock so we are thread-safe - std.debug.lockStdErr(); - defer std.debug.unlockStdErr(); - // On Mac, we use unified logging. To view this: // // sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"' // - if (builtin.target.os.tag.isDarwin()) { + // macOS logging is thread safe so no need for locks/mutexes + macos: { + if (comptime !builtin.target.os.tag.isDarwin()) break :macos; + if (!state.logging.macos) break :macos; + + const prefix = if (scope == .default) "" else @tagName(scope) ++ ": "; + // Convert our levels to Mac levels const mac_level: macos.os.LogType = switch (level) { .debug => .debug, @@ -143,26 +141,35 @@ fn logFn( // but we shouldn't be logging too much. const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope)); defer logger.release(); - logger.log(std.heap.c_allocator, mac_level, format, args); + logger.log(std.heap.c_allocator, mac_level, prefix ++ format, args); } - switch (state.logging) { - .disabled => {}, + stderr: { + // don't log debug messages to stderr unless we are a debug build + if (comptime builtin.mode != .Debug and level == .debug) break :stderr; - .stderr => { - // Always try default to send to stderr - var buffer: [1024]u8 = undefined; - var stderr = std.fs.File.stderr().writer(&buffer); - const writer = &stderr.interface; - nosuspend writer.print(level_txt ++ prefix ++ format ++ "\n", args) catch return; - // TODO: Do we want to use flushless stderr in the future? - writer.flush() catch {}; - }, + // skip if we are not logging to stderr + if (!state.logging.stderr) break :stderr; + + // Lock so we are thread-safe + var buf: [64]u8 = undefined; + const stderr = std.debug.lockStderrWriter(&buf); + defer std.debug.unlockStderrWriter(); + + const level_txt = comptime level.asText(); + const prefix = if (scope == .default) ": " else "(" ++ @tagName(scope) ++ "): "; + nosuspend stderr.print(level_txt ++ prefix ++ format ++ "\n", args) catch break :stderr; + nosuspend stderr.flush() catch break :stderr; } } pub const std_options: std.Options = .{ // Our log level is always at least info in every build mode. + // + // Note, we don't lower this to debug even with conditional logging + // via GHOSTTY_LOG because our debug logs are very expensive to + // calculate and we want to make sure they're optimized out in + // builds. .log_level = switch (builtin.mode) { .Debug => .debug, else => .info, From 78e539d68453fcedb29b31c7a296a9a816c7858e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 15 Dec 2025 12:28:40 -0800 Subject: [PATCH 698/702] Revert "macos: populate the sparkle:channel element" --- dist/macos/update_appcast_tag.py | 2 -- dist/macos/update_appcast_tip.py | 2 -- .../Sources/Features/Update/UpdatePopoverView.swift | 12 ++---------- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/dist/macos/update_appcast_tag.py b/dist/macos/update_appcast_tag.py index 8c2ee8314..2cb20dd5d 100644 --- a/dist/macos/update_appcast_tag.py +++ b/dist/macos/update_appcast_tag.py @@ -77,8 +77,6 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) -elem = ET.SubElement(item, "sparkle:channel") -elem.text = "stable" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/dist/macos/update_appcast_tip.py b/dist/macos/update_appcast_tip.py index 1876f0a17..ff1fb4be5 100644 --- a/dist/macos/update_appcast_tip.py +++ b/dist/macos/update_appcast_tip.py @@ -75,8 +75,6 @@ elem = ET.SubElement(item, "title") elem.text = f"Build {build}" elem = ET.SubElement(item, "pubDate") elem.text = now.strftime(pubdate_format) -elem = ET.SubElement(item, "sparkle:channel") -elem.text = "tip" elem = ET.SubElement(item, "sparkle:version") elem.text = build elem = ET.SubElement(item, "sparkle:shortVersionString") diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 2c56e5f4e..87d76f801 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -125,15 +125,7 @@ fileprivate struct UpdateAvailableView: View { let dismiss: DismissAction private let labelWidth: CGFloat = 60 - - private func releaseDateString(date: Date, channel: String?) -> String { - let dateString = date.formatted(date: .abbreviated, time: .omitted) - if let channel, !channel.isEmpty { - return "\(dateString) (\(channel))" - } - return dateString - } - + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { @@ -165,7 +157,7 @@ fileprivate struct UpdateAvailableView: View { Text("Released:") .foregroundColor(.secondary) .frame(width: labelWidth, alignment: .trailing) - Text(releaseDateString(date: date, channel: update.appcastItem.channel)) + Text(date.formatted(date: .abbreviated, time: .omitted)) } .font(.system(size: 11)) } From c4cd2ca81d93c4af8d75cd930a6e8691ee36018c Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 16 Dec 2025 08:24:18 -0500 Subject: [PATCH 699/702] zsh: removed unused self_dir variable This came from the original Kitty script on which ours is based, but we don't use it. --- src/shell-integration/zsh/ghostty-integration | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index c87630c92..febf3e59c 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -93,9 +93,6 @@ _entrypoint() { _ghostty_deferred_init() { builtin emulate -L zsh -o no_warn_create_global -o no_aliases - # The directory where ghostty-integration is located: /../shell-integration/zsh. - builtin local self_dir="${functions_source[_ghostty_deferred_init]:A:h}" - # Enable semantic markup with OSC 133. _ghostty_precmd() { builtin local -i cmd_status=$? From 3f504f33e540b35f772505f6fd5f1a8702ac0c5b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 06:47:28 -0800 Subject: [PATCH 700/702] ci: color scheme GHA uploads to mirror This changes our GHA that updates our color schemes to also upload it to our dependency mirror at the same time. This prevents issues where the upstream disappears, which we've had many times. --- .github/workflows/update-colorschemes.yml | 25 +++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index dc3ebb2b6..4ca4d2901 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -37,16 +37,33 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Run zig fetch - id: zig_fetch + - name: Download colorschemes + id: download env: GH_TOKEN: ${{ github.token }} run: | # Get the latest release from iTerm2-Color-Schemes RELEASE_INFO=$(gh api repos/mbadolato/iTerm2-Color-Schemes/releases/latest) TAG_NAME=$(echo "$RELEASE_INFO" | jq -r '.tag_name') - nix develop -c zig fetch --save="iterm2_themes" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz" + FILENAME="ghostty-themes-${TAG_NAME}.tgz" + mkdir -p upload + curl -L -o "upload/${FILENAME}" "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/${TAG_NAME}/ghostty-themes.tgz" echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + echo "filename=$FILENAME" >> $GITHUB_OUTPUT + + - name: Upload to R2 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 + with: + r2-account-id: ${{ secrets.CF_R2_DEPS_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_DEPS_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_DEPS_SECRET_KEY }} + r2-bucket: ghostty-deps + source-dir: upload + destination-dir: ./ + + - name: Run zig fetch + run: | + nix develop -c zig fetch --save="iterm2_themes" "https://deps.files.ghostty.org/${{ steps.download.outputs.filename }}" - name: Update zig cache hash run: | @@ -75,5 +92,5 @@ jobs: build.zig.zon.json flatpak/zig-packages.json body: | - Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.zig_fetch.outputs.tag_name }} + Upstream release: https://github.com/mbadolato/iTerm2-Color-Schemes/releases/tag/${{ steps.download.outputs.tag_name }} labels: dependencies From 1a8eb52e998921aaf3d7f7233fe3d9996fad67e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 06:52:28 -0800 Subject: [PATCH 701/702] ci: disable many macOS builds we don't use This disables a bunch of configurations that we don't need to actually test for. The main one we want to keep building is Freetype because we sometimes use this to compare behaviors, but Coretext is the default. This is one of the primary drivers of CI CPU time. --- .github/workflows/test.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 854458c09..3a5bf58df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: - build-linux-libghostty - build-nix - build-macos - - build-macos-matrix + - build-macos-freetype - build-snap - build-windows - test @@ -464,7 +464,7 @@ jobs: cd macos xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" - build-macos-matrix: + build-macos-freetype: runs-on: namespace-profile-ghostty-macos-tahoe needs: test steps: @@ -493,18 +493,10 @@ jobs: - name: Test All run: | nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=freetype - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_freetype - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_harfbuzz - nix develop -c zig build test --system ${{ steps.deps.outputs.deps }} -Drenderer=metal -Dfont-backend=coretext_noshape - name: Build All run: | nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=freetype - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_freetype - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_harfbuzz - nix develop -c zig build --system ${{ steps.deps.outputs.deps }} -Demit-macos-app=false -Drenderer=metal -Dfont-backend=coretext_noshape build-windows: runs-on: windows-2022 From ef0fec473ae2478c03f3eedd8c6310457809964f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 16 Dec 2025 06:59:49 -0800 Subject: [PATCH 702/702] ci: move flatpak out to a triggered build similar to snap --- .github/workflows/flatpak.yml | 50 +++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 46 +++++++++++++------------------- 2 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/flatpak.yml diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 000000000..7c4256e0e --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,50 @@ +on: + workflow_dispatch: + inputs: + source-run-id: + description: run id of the workflow that generated the artifact + required: true + type: string + source-artifact-id: + description: source tarball built during build-dist + required: true + type: string + +name: Flatpak + +jobs: + build: + if: github.repository == 'ghostty-org/ghostty' + name: "Flatpak" + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 + options: --privileged + strategy: + fail-fast: false + matrix: + variant: + - arch: x86_64 + runner: namespace-profile-ghostty-md + - arch: aarch64 + runner: namespace-profile-ghostty-md-arm64 + runs-on: ${{ matrix.variant.runner }} + steps: + - name: Download Source Tarball Artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + run-id: ${{ inputs.source-run-id }} + artifact-ids: ${{ inputs.source-artifact-id }} + github-token: ${{ github.token }} + + - name: Extract tarball + run: | + mkdir dist + tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz + + - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 + with: + bundle: com.mitchellh.ghostty + manifest-path: dist/flatpak/com.mitchellh.ghostty.yml + cache-key: flatpak-builder-${{ github.sha }} + arch: ${{ matrix.variant.arch }} + verbose: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a5bf58df..30f34120a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ jobs: - test-debian-13 - valgrind - zig-fmt - - flatpak + steps: - id: status name: Determine status @@ -421,6 +421,24 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + trigger-flatpak: + if: github.event_name != 'pull_request' + runs-on: namespace-profile-ghostty-xsm + needs: [build-dist, build-flatpak] + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Trigger Flatpak workflow + run: | + gh workflow run \ + flatpak.yml \ + --ref ${{ github.ref_name || 'main' }} \ + --field source-run-id=${{ github.run_id }} \ + --field source-artifact-id=${{ needs.build-dist.outputs.artifact-id }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test @@ -1084,32 +1102,6 @@ jobs: build-args: | DISTRO_VERSION=13 - flatpak: - if: github.repository == 'ghostty-org/ghostty' - name: "Flatpak" - container: - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-47 - options: --privileged - strategy: - fail-fast: false - matrix: - variant: - - arch: x86_64 - runner: namespace-profile-ghostty-md - - arch: aarch64 - runner: namespace-profile-ghostty-md-arm64 - runs-on: ${{ matrix.variant.runner }} - needs: test - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6 - with: - bundle: com.mitchellh.ghostty - manifest-path: flatpak/com.mitchellh.ghostty.yml - cache-key: flatpak-builder-${{ github.sha }} - arch: ${{ matrix.variant.arch }} - verbose: true - valgrind: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-lg