From 572c06f67def29f1b1f344a7ffe914078967a1d6 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Thu, 4 Dec 2025 10:09:41 -0500 Subject: [PATCH 01/96] font/coretext: Use positions to fix x/y offsets --- pkg/macos/text/run.zig | 13 ++++++ src/font/shaper/coretext.zig | 80 ++++++++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/pkg/macos/text/run.zig b/pkg/macos/text/run.zig index 2895bfe34..a34cd5307 100644 --- a/pkg/macos/text/run.zig +++ b/pkg/macos/text/run.zig @@ -106,6 +106,19 @@ pub const Run = opaque { pub fn getStatus(self: *Run) Status { return @bitCast(c.CTRunGetStatus(@ptrCast(self))); } + + pub fn getAttributes(self: *Run) *foundation.Dictionary { + return @ptrCast(@constCast(c.CTRunGetAttributes(@ptrCast(self)))); + } + + pub fn getFont(self: *Run) ?*text.Font { + const attrs = self.getAttributes(); + const font_ptr = attrs.getValue(*const anyopaque, c.kCTFontAttributeName); + if (font_ptr) |ptr| { + return @ptrCast(@constCast(ptr)); + } + return null; + } }; /// https://developer.apple.com/documentation/coretext/ctrunstatus?language=objc diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 97cb5cd89..498b45799 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -377,11 +377,21 @@ pub const Shaper = struct { const line = typesetter.createLine(.{ .location = 0, .length = 0 }); self.cf_release_pool.appendAssumeCapacity(line); - // This keeps track of the current offsets within a single cell. + // This keeps track of the current offsets within a run. + var run_offset: struct { + x: f64 = 0, + y: f64 = 0, + } = .{}; + + // This keeps track of the current offsets within a cell. var cell_offset: struct { cluster: u32 = 0, x: f64 = 0, y: f64 = 0, + + // For debugging positions, turn this on: + start_index: usize = 0, + end_index: usize = 0, } = .{}; // Clear our cell buf and make sure we have enough room for the whole @@ -411,15 +421,18 @@ pub const Shaper = struct { // Get our glyphs and positions const glyphs = ctrun.getGlyphsPtr() orelse try ctrun.getGlyphs(alloc); const advances = ctrun.getAdvancesPtr() orelse try ctrun.getAdvances(alloc); + const positions = ctrun.getPositionsPtr() orelse try ctrun.getPositions(alloc); const indices = ctrun.getStringIndicesPtr() orelse try ctrun.getStringIndices(alloc); assert(glyphs.len == advances.len); + assert(glyphs.len == positions.len); assert(glyphs.len == indices.len); for ( glyphs, advances, + positions, indices, - ) |glyph, advance, index| { + ) |glyph, advance, position, index| { // Our cluster is also our cell X position. If the cluster changes // then we need to reset our current cell offsets. const cluster = state.codepoints.items[index].cluster; @@ -431,20 +444,71 @@ pub const Shaper = struct { // wait for that. if (cell_offset.cluster > cluster) break :pad; - cell_offset = .{ .cluster = cluster }; + cell_offset = .{ + .cluster = cluster, + .x = run_offset.x, + .y = run_offset.y, + + // For debugging positions, turn this on: + .start_index = index, + .end_index = index, + }; + } else { + if (index < cell_offset.start_index) { + cell_offset.start_index = index; + } + if (index > cell_offset.end_index) { + cell_offset.end_index = index; + } + } + + const x_offset = position.x - cell_offset.x; + const y_offset = position.y - cell_offset.y; + + const advance_x_offset = run_offset.x - cell_offset.x; + const advance_y_offset = run_offset.y - cell_offset.y; + const x_offset_diff = x_offset - advance_x_offset; + const y_offset_diff = y_offset - advance_y_offset; + + if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { + var allocating = std.Io.Writer.Allocating.init(alloc); + const writer = &allocating.writer; + const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("\\u{{{x}}}", .{cp.codepoint}); + } + try writer.writeAll(" → "); + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + } + const formatted_cps = try allocating.toOwnedSlice(); + + log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + cluster, + x_offset, + y_offset, + advance_x_offset, + advance_y_offset, + x_offset_diff, + y_offset_diff, + state.codepoints.items[index].codepoint, + formatted_cps, + }); } self.cell_buf.appendAssumeCapacity(.{ .x = @intCast(cluster), - .x_offset = @intFromFloat(@round(cell_offset.x)), - .y_offset = @intFromFloat(@round(cell_offset.y)), + .x_offset = @intFromFloat(@round(x_offset)), + .y_offset = @intFromFloat(@round(y_offset)), .glyph_index = glyph, }); - // Add our advances to keep track of our current cell offsets. + // Add our advances to keep track of our run offsets. // Advances apply to the NEXT cell. - cell_offset.x += advance.width; - cell_offset.y += advance.height; + run_offset.x += advance.width; + run_offset.y += advance.height; } } From f4560390d7ad6abdb15a61ccc09f02895cabf538 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Thu, 11 Dec 2025 09:35:40 -0500 Subject: [PATCH 02/96] Remove accidental changes to macos/text/run.ig --- pkg/macos/text/run.zig | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pkg/macos/text/run.zig b/pkg/macos/text/run.zig index a34cd5307..2895bfe34 100644 --- a/pkg/macos/text/run.zig +++ b/pkg/macos/text/run.zig @@ -106,19 +106,6 @@ pub const Run = opaque { pub fn getStatus(self: *Run) Status { return @bitCast(c.CTRunGetStatus(@ptrCast(self))); } - - pub fn getAttributes(self: *Run) *foundation.Dictionary { - return @ptrCast(@constCast(c.CTRunGetAttributes(@ptrCast(self)))); - } - - pub fn getFont(self: *Run) ?*text.Font { - const attrs = self.getAttributes(); - const font_ptr = attrs.getValue(*const anyopaque, c.kCTFontAttributeName); - if (font_ptr) |ptr| { - return @ptrCast(@constCast(ptr)); - } - return null; - } }; /// https://developer.apple.com/documentation/coretext/ctrunstatus?language=objc From 6addccdeeb450fbee6661c36e3ccdbecd94e94d4 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Thu, 11 Dec 2025 10:48:28 -0500 Subject: [PATCH 03/96] Add shape Tai Tham vowels test --- src/font/shaper/coretext.zig | 149 +++++++++++++++++++++++++---------- src/terminal/Terminal.zig | 2 +- 2 files changed, 108 insertions(+), 43 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 498b45799..32b7ab77b 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -390,8 +390,8 @@ pub const Shaper = struct { y: f64 = 0, // For debugging positions, turn this on: - start_index: usize = 0, - end_index: usize = 0, + //start_index: usize = 0, + //end_index: usize = 0, } = .{}; // Clear our cell buf and make sure we have enough room for the whole @@ -450,53 +450,56 @@ pub const Shaper = struct { .y = run_offset.y, // For debugging positions, turn this on: - .start_index = index, - .end_index = index, + //.start_index = index, + //.end_index = index, }; - } else { - if (index < cell_offset.start_index) { - cell_offset.start_index = index; - } - if (index > cell_offset.end_index) { - cell_offset.end_index = index; - } + + // For debugging positions, turn this on: + //} else { + // if (index < cell_offset.start_index) { + // cell_offset.start_index = index; + // } + // if (index > cell_offset.end_index) { + // cell_offset.end_index = index; + // } } const x_offset = position.x - cell_offset.x; const y_offset = position.y - cell_offset.y; - const advance_x_offset = run_offset.x - cell_offset.x; - const advance_y_offset = run_offset.y - cell_offset.y; - const x_offset_diff = x_offset - advance_x_offset; - const y_offset_diff = y_offset - advance_y_offset; + // Ford debugging positions, turn this on: + //const advance_x_offset = run_offset.x - cell_offset.x; + //const advance_y_offset = run_offset.y - cell_offset.y; + //const x_offset_diff = x_offset - advance_x_offset; + //const y_offset_diff = y_offset - advance_y_offset; - if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { - var allocating = std.Io.Writer.Allocating.init(alloc); - const writer = &allocating.writer; - const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; - for (codepoints) |cp| { - if (cp.codepoint == 0) continue; // Skip surrogate pair padding - try writer.print("\\u{{{x}}}", .{cp.codepoint}); - } - try writer.writeAll(" → "); - for (codepoints) |cp| { - if (cp.codepoint == 0) continue; // Skip surrogate pair padding - try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); - } - const formatted_cps = try allocating.toOwnedSlice(); + //if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { + // var allocating = std.Io.Writer.Allocating.init(alloc); + // const writer = &allocating.writer; + // const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; + // for (codepoints) |cp| { + // if (cp.codepoint == 0) continue; // Skip surrogate pair padding + // try writer.print("\\u{{{x}}}", .{cp.codepoint}); + // } + // try writer.writeAll(" → "); + // for (codepoints) |cp| { + // if (cp.codepoint == 0) continue; // Skip surrogate pair padding + // try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + // } + // const formatted_cps = try allocating.toOwnedSlice(); - log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ - cluster, - x_offset, - y_offset, - advance_x_offset, - advance_y_offset, - x_offset_diff, - y_offset_diff, - state.codepoints.items[index].codepoint, - formatted_cps, - }); - } + // log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + // cluster, + // x_offset, + // y_offset, + // advance_x_offset, + // advance_y_offset, + // x_offset_diff, + // y_offset_diff, + // state.codepoints.items[index].codepoint, + // formatted_cps, + // }); + //} self.cell_buf.appendAssumeCapacity(.{ .x = @intCast(cluster), @@ -1332,7 +1335,7 @@ test "shape with empty cells in between" { } } -test "shape Chinese characters" { +test "shape Combining characters" { const testing = std.testing; const alloc = testing.allocator; @@ -1350,6 +1353,9 @@ test "shape Chinese characters" { var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); defer t.deinit(alloc); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + var s = t.vtStream(); defer s.deinit(); try s.nextSlice(buf[0..buf_idx]); @@ -1397,6 +1403,9 @@ test "shape Devanagari string" { var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); defer t.deinit(alloc); + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + var s = t.vtStream(); defer s.deinit(); try s.nextSlice("अपार्टमेंट"); @@ -1429,6 +1438,62 @@ test "shape Devanagari string" { try testing.expect(try it.next(alloc) == null); } +test "shape Tai Tham vowels (position differs from advance)" { + const testing = std.testing; + const alloc = testing.allocator; + + // We need a font that supports Tai Tham for this to work, if we can't find + // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // test. + var testdata = testShaperWithDiscoveredFont( + alloc, + "Noto Sans Tai Tham", + ) catch return error.SkipZigTest; + defer testdata.deinit(); + + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ + buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ + + // Make a screen with some data + var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + 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, + .cells = state.row_data.get(0).cells.slice(), + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + + const cells = try shaper.shape(run); + const cell_width = run.grid.metrics.cell_width; + try testing.expectEqual(@as(usize, 2), cells.len); + try testing.expectEqual(@as(u16, 0), cells[0].x); + try testing.expectEqual(@as(u16, 0), cells[1].x); + + // The first glyph renders in the next cell + try testing.expectEqual(@as(i16, @intCast(cell_width)), cells[0].x_offset); + try testing.expectEqual(@as(i16, 0), cells[1].x_offset); + } + try testing.expectEqual(@as(usize, 1), count); +} + test "shape box glyphs" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6c9db6a8d..b0d43c192 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -8893,7 +8893,7 @@ test "Terminal: insertBlanks shift graphemes" { var t = try init(alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); - // Disable grapheme clustering + // Enable grapheme clustering t.modes.set(.grapheme_cluster, true); try t.printString("A"); From 075ef6980bfaa8f6c196bdc2124e78eaccd391bb Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Fri, 12 Dec 2025 09:27:45 -0500 Subject: [PATCH 04/96] Fix comment typo --- src/font/shaper/coretext.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 32b7ab77b..15ac5762b 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -467,7 +467,7 @@ pub const Shaper = struct { const x_offset = position.x - cell_offset.x; const y_offset = position.y - cell_offset.y; - // Ford debugging positions, turn this on: + // For debugging positions, turn this on: //const advance_x_offset = run_offset.x - cell_offset.x; //const advance_y_offset = run_offset.y - cell_offset.y; //const x_offset_diff = x_offset - advance_x_offset; From 139a23a0a2f4879c884dfab075c45ca93eb5ae64 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Wed, 17 Dec 2025 09:57:32 -0500 Subject: [PATCH 05/96] Pull out debugging into a separate function. --- src/font/shaper/coretext.zig | 136 +++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 54 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 15ac5762b..6b01d79aa 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -103,6 +103,17 @@ pub const Shaper = struct { } }; + const RunOffset = struct { + x: f64 = 0, + y: f64 = 0, + }; + + const CellOffset = struct { + cluster: u32 = 0, + x: f64 = 0, + y: f64 = 0, + }; + /// Create a CoreFoundation Dictionary suitable for /// settings the font features of a CoreText font. fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary { @@ -378,21 +389,14 @@ pub const Shaper = struct { self.cf_release_pool.appendAssumeCapacity(line); // This keeps track of the current offsets within a run. - var run_offset: struct { - x: f64 = 0, - y: f64 = 0, - } = .{}; + var run_offset: RunOffset = .{}; // This keeps track of the current offsets within a cell. - var cell_offset: struct { - cluster: u32 = 0, - x: f64 = 0, - y: f64 = 0, + var cell_offset: CellOffset = .{}; - // For debugging positions, turn this on: - //start_index: usize = 0, - //end_index: usize = 0, - } = .{}; + // For debugging positions, turn this on: + //var start_index: usize = 0; + //var end_index: usize = 0; // Clear our cell buf and make sure we have enough room for the whole // line of glyphs, so that we can just assume capacity when appending @@ -448,59 +452,26 @@ pub const Shaper = struct { .cluster = cluster, .x = run_offset.x, .y = run_offset.y, - - // For debugging positions, turn this on: - //.start_index = index, - //.end_index = index, }; // For debugging positions, turn this on: + // start_index = index; + // end_index = index; //} else { - // if (index < cell_offset.start_index) { - // cell_offset.start_index = index; + // if (index < start_index) { + // start_index = index; // } - // if (index > cell_offset.end_index) { - // cell_offset.end_index = index; + // if (index > end_index) { + // end_index = index; // } } + // For debugging positions, turn this on: + //try self.debugPositions(alloc, run_offset, cell_offset, position, start_index, end_index, index); + const x_offset = position.x - cell_offset.x; const y_offset = position.y - cell_offset.y; - // For debugging positions, turn this on: - //const advance_x_offset = run_offset.x - cell_offset.x; - //const advance_y_offset = run_offset.y - cell_offset.y; - //const x_offset_diff = x_offset - advance_x_offset; - //const y_offset_diff = y_offset - advance_y_offset; - - //if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { - // var allocating = std.Io.Writer.Allocating.init(alloc); - // const writer = &allocating.writer; - // const codepoints = state.codepoints.items[cell_offset.start_index .. cell_offset.end_index + 1]; - // for (codepoints) |cp| { - // if (cp.codepoint == 0) continue; // Skip surrogate pair padding - // try writer.print("\\u{{{x}}}", .{cp.codepoint}); - // } - // try writer.writeAll(" → "); - // for (codepoints) |cp| { - // if (cp.codepoint == 0) continue; // Skip surrogate pair padding - // try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); - // } - // const formatted_cps = try allocating.toOwnedSlice(); - - // log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ - // cluster, - // x_offset, - // y_offset, - // advance_x_offset, - // advance_y_offset, - // x_offset_diff, - // y_offset_diff, - // state.codepoints.items[index].codepoint, - // formatted_cps, - // }); - //} - self.cell_buf.appendAssumeCapacity(.{ .x = @intCast(cluster), .x_offset = @intFromFloat(@round(x_offset)), @@ -680,6 +651,63 @@ pub const Shaper = struct { _ = self; } }; + + fn debugPositions( + self: *Shaper, + alloc: Allocator, + run_offset: RunOffset, + cell_offset: CellOffset, + position: macos.graphics.Point, + start_index: usize, + end_index: usize, + index: usize, + ) !void { + const state = &self.run_state; + const x_offset = position.x - cell_offset.x; + const y_offset = position.y - cell_offset.y; + const advance_x_offset = run_offset.x - cell_offset.x; + const advance_y_offset = run_offset.y - cell_offset.y; + const x_offset_diff = x_offset - advance_x_offset; + const y_offset_diff = y_offset - advance_y_offset; + + if (@abs(x_offset_diff) > 0.0001 or @abs(y_offset_diff) > 0.0001) { + var allocating = std.Io.Writer.Allocating.init(alloc); + const writer = &allocating.writer; + const codepoints = state.codepoints.items[start_index .. end_index + 1]; + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("\\u{{{x}}}", .{cp.codepoint}); + } + try writer.writeAll(" → "); + for (codepoints) |cp| { + if (cp.codepoint == 0) continue; // Skip surrogate pair padding + try writer.print("{u}", .{@as(u21, @intCast(cp.codepoint))}); + } + const formatted_cps = try allocating.toOwnedSlice(); + + // Note that the codepoints from `start_index .. end_index + 1` + // might not include all the codepoints being shaped. Sometimes a + // codepoint gets represented in a glyph with a later codepoint + // such that the index for the former codepoint is skipped and just + // the index for the latter codepoint is used. Additionally, this + // gets called as we iterate through the glyphs, so it won't + // include the codepoints that come later that might be affecting + // positions for the current glyph. Usually though, for that case + // the positions of the later glyphs will also be affected and show + // up in the logs. + log.warn("position differs from advance: cluster={d} pos=({d:.2},{d:.2}) adv=({d:.2},{d:.2}) diff=({d:.2},{d:.2}) current cp={x}, cps={s}", .{ + cell_offset.cluster, + x_offset, + y_offset, + advance_x_offset, + advance_y_offset, + x_offset_diff, + y_offset_diff, + state.codepoints.items[index].codepoint, + formatted_cps, + }); + } + } }; test "run iterator" { From d820a633eeb293d8da7052a0d31097a7c0023d18 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 08:34:30 -0800 Subject: [PATCH 06/96] fix up typos --- typos.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/typos.toml b/typos.toml index 26876aef9..27ec9d684 100644 --- a/typos.toml +++ b/typos.toml @@ -56,6 +56,8 @@ DECID = "DECID" flate = "flate" typ = "typ" kend = "kend" +# Tai Tham is a script/writing system +Tham = "Tham" # GTK GIR = "GIR" # terminfo From 7e46200d318cc18c67deccb33f3cb3a9ca80cb1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 08:56:21 -0800 Subject: [PATCH 07/96] macos: command palette entries support leading color --- .../Features/Command Palette/CommandPalette.swift | 9 +++++++++ .../Command Palette/TerminalCommandPalette.swift | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 79c3ca756..70b444827 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -6,6 +6,7 @@ struct CommandOption: Identifiable, Hashable { let description: String? let symbols: [String]? let leadingIcon: String? + let leadingColor: Color? let badge: String? let emphasis: Bool let action: () -> Void @@ -15,6 +16,7 @@ struct CommandOption: Identifiable, Hashable { description: String? = nil, symbols: [String]? = nil, leadingIcon: String? = nil, + leadingColor: Color? = nil, badge: String? = nil, emphasis: Bool = false, action: @escaping () -> Void @@ -23,6 +25,7 @@ struct CommandOption: Identifiable, Hashable { self.description = description self.symbols = symbols self.leadingIcon = leadingIcon + self.leadingColor = leadingColor self.badge = badge self.emphasis = emphasis self.action = action @@ -283,6 +286,12 @@ fileprivate struct CommandRow: View { var body: some View { Button(action: action) { HStack(spacing: 8) { + if let color = option.leadingColor { + Circle() + .fill(color) + .frame(width: 8, height: 8) + } + if let icon = option.leadingIcon { Image(systemName: icon) .foregroundStyle(option.emphasis ? Color.accentColor : .secondary) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 96ff3d0c1..95e5e6a01 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -62,7 +62,7 @@ struct TerminalCommandPaletteView: View { return CommandOption( title: c.title, description: c.description, - symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList + symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, ) { onAction(c.action) } From 5d11bdddc3e81011b4543f19f6ea563a0e515ed6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:04:51 -0800 Subject: [PATCH 08/96] macos: implement the present terminal action so we can use that --- .../Terminal/BaseTerminalController.swift | 14 +++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 29 +++++++++++++++++-- macos/Sources/Ghostty/Package.swift | 3 ++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5f067c128..b70fd2c56 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -195,6 +195,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(ghosttyDidResizeSplit(_:)), name: Ghostty.Notification.didResizeSplit, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidPresentTerminal(_:)), + name: Ghostty.Notification.ghosttyPresentTerminal, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -700,6 +705,15 @@ class BaseTerminalController: NSWindowController, } } + @objc private func ghosttyDidPresentTerminal(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.contains(target) else { return } + + // Bring the window to front and focus the surface without activating the app + window?.orderFrontRegardless() + Ghostty.moveFocus(to: target) + } + // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4e9d039d4..3348ab714 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -627,12 +627,13 @@ extension Ghostty { case GHOSTTY_ACTION_SEARCH_SELECTED: searchSelected(app, target: target, v: action.action.search_selected) + case GHOSTTY_ACTION_PRESENT_TERMINAL: + return presentTerminal(app, target: target) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: fallthrough - case GHOSTTY_ACTION_PRESENT_TERMINAL: - fallthrough case GHOSTTY_ACTION_SIZE_LIMIT: fallthrough case GHOSTTY_ACTION_QUIT_TIMER: @@ -845,6 +846,30 @@ extension Ghostty { } } + private static func presentTerminal( + _ app: ghostty_app_t, + target: ghostty_target_s + ) -> Bool { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + 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 } + + NotificationCenter.default.post( + name: Notification.ghosttyPresentTerminal, + object: surfaceView + ) + return true + + default: + assertionFailure() + return false + } + } + private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s, mode: ghostty_action_close_tab_mode_e) { switch (target.tag) { case GHOSTTY_TARGET_APP: diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index b834ea31f..375e5c37b 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -435,6 +435,9 @@ extension Ghostty.Notification { /// New window. Has base surface config requested in userinfo. static let ghosttyNewWindow = Notification.Name("com.mitchellh.ghostty.newWindow") + /// Present terminal. Bring the surface's window to focus without activating the app. + static let ghosttyPresentTerminal = Notification.Name("com.mitchellh.ghostty.presentTerminal") + /// Toggle fullscreen of current window static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen") static let FullscreenModeKey = ghosttyToggleFullscreen.rawValue From e1e782c617ac311d6cb535de23fd23d5c687b437 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:10:46 -0800 Subject: [PATCH 09/96] macos: clean up the way command options are built up --- .../TerminalCommandPalette.swift | 120 +++++++++--------- 1 file changed, 63 insertions(+), 57 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 95e5e6a01..8150dbdbb 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -18,63 +18,6 @@ struct TerminalCommandPaletteView: View { /// The callback when an action is submitted. var onAction: ((String) -> Void) - // The commands available to the command palette. - private var commandOptions: [CommandOption] { - 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 { - let terminalCommands = try surface.commands().map { c in - return CommandOption( - title: c.title, - description: c.description, - symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, - ) { - onAction(c.action) - } - } - options.append(contentsOf: terminalCommands) - } catch { - return options - } - - return options - } - var body: some View { ZStack { if isPresented { @@ -116,6 +59,69 @@ struct TerminalCommandPaletteView: View { } } } + + /// All commands available in the command palette, combining update and terminal options. + private var commandOptions: [CommandOption] { + var options: [CommandOption] = [] + options.append(contentsOf: updateOptions) + options.append(contentsOf: terminalOptions) + return options + } + + /// Commands for installing or canceling available updates. + private var updateOptions: [CommandOption] { + var options: [CommandOption] = [] + + guard let updateViewModel, updateViewModel.state.isInstallable else { + return options + } + + // 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() + }) + + options.append(CommandOption( + title: "Cancel or Skip Update", + description: "Dismiss the current update process" + ) { + updateViewModel.state.cancel() + }) + + return options + } + + /// Commands exposed by the terminal surface. + private var terminalOptions: [CommandOption] { + guard let surface = surfaceView.surfaceModel else { return [] } + do { + return try surface.commands().map { c in + CommandOption( + title: c.title, + description: c.description, + symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, + ) { + onAction(c.action) + } + } + } catch { + return [] + } + } } /// This is done to ensure that the given view is in the responder chain. From 835fe3dd0fce241bc249ebc9e7a33b78d0ffe32e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:17:09 -0800 Subject: [PATCH 10/96] macos: add the active terminals to our command palette to jump --- .../TerminalCommandPalette.swift | 29 +++++++++++++++++++ .../Terminal/BaseTerminalController.swift | 9 ++++-- .../Helpers/Extensions/String+Extension.swift | 9 ++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 8150dbdbb..e3da9ff56 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -64,6 +64,7 @@ struct TerminalCommandPaletteView: View { private var commandOptions: [CommandOption] { var options: [CommandOption] = [] options.append(contentsOf: updateOptions) + options.append(contentsOf: jumpOptions) options.append(contentsOf: terminalOptions) return options } @@ -122,6 +123,34 @@ struct TerminalCommandPaletteView: View { return [] } } + + /// Commands for jumping to other terminal surfaces. + private var jumpOptions: [CommandOption] { + TerminalController.all.flatMap { controller -> [CommandOption] in + guard let window = controller.window else { return [] } + + let color = (window as? TerminalWindow)?.tabColor + let displayColor = color != TerminalTabColor.none ? color : nil + + return controller.surfaceTree.map { surface in + let title = surface.title.isEmpty ? window.title : surface.title + let displayTitle = title.isEmpty ? "Untitled" : title + + return CommandOption( + title: "Focus: \(displayTitle)", + description: surface.pwd?.abbreviatedPath, + leadingIcon: "rectangle.on.rectangle", + leadingColor: displayColor?.displayColor.map { Color($0) } + ) { + NotificationCenter.default.post( + name: Ghostty.Notification.ghosttyPresentTerminal, + object: surface + ) + } + } + } + } + } /// This is done to ensure that the given view is in the responder chain. diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index b70fd2c56..9e8aece2d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -709,9 +709,12 @@ class BaseTerminalController: NSWindowController, guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } - // Bring the window to front and focus the surface without activating the app - window?.orderFrontRegardless() - Ghostty.moveFocus(to: target) + // Bring the window to front and focus the surface. + window?.makeKeyAndOrderFront(nil) + + // We use a small delay to ensure this runs after any UI cleanup + // (e.g., command palette restoring focus to its original surface). + Ghostty.moveFocus(to: target, delay: 0.1) } // MARK: Local Events diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index 0c1c4fe91..a8d93091a 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -17,4 +17,13 @@ extension String { return url } #endif + + /// Returns the path with the home directory abbreviated as ~. + var abbreviatedPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + if hasPrefix(home) { + return "~" + dropFirst(home.count) + } + return self + } } From b30e94c0ece807b2a8af006758842db446ba8722 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:32:39 -0800 Subject: [PATCH 11/96] macos: sort in the focus jumps in other commands --- .../Command Palette/TerminalCommandPalette.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index e3da9ff56..6d6a89162 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -63,9 +63,16 @@ struct TerminalCommandPaletteView: View { /// All commands available in the command palette, combining update and terminal options. private var commandOptions: [CommandOption] { var options: [CommandOption] = [] + // Updates always appear first options.append(contentsOf: updateOptions) - options.append(contentsOf: jumpOptions) - options.append(contentsOf: terminalOptions) + + // Sort the rest. We replace ":" with a character that sorts before space + // so that "Foo:" sorts before "Foo Bar:". + options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in + let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") + let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") + return aNormalized.localizedCaseInsensitiveCompare(bNormalized) == .orderedAscending + }) return options } From 1fd3f27e26ca17ac3f299ade8f534f099d43f0e5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:44:01 -0800 Subject: [PATCH 12/96] macos: show pwd for jump options --- .../Command Palette/CommandPalette.swift | 20 ++++++++++++++++--- .../TerminalCommandPalette.swift | 8 +++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 70b444827..3cb4e3480 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -3,6 +3,7 @@ import SwiftUI struct CommandOption: Identifiable, Hashable { let id = UUID() let title: String + let subtitle: String? let description: String? let symbols: [String]? let leadingIcon: String? @@ -13,6 +14,7 @@ struct CommandOption: Identifiable, Hashable { init( title: String, + subtitle: String? = nil, description: String? = nil, symbols: [String]? = nil, leadingIcon: String? = nil, @@ -22,6 +24,7 @@ struct CommandOption: Identifiable, Hashable { action: @escaping () -> Void ) { self.title = title + self.subtitle = subtitle self.description = description self.symbols = symbols self.leadingIcon = leadingIcon @@ -55,7 +58,10 @@ struct CommandPaletteView: View { if query.isEmpty { return options } else { - return options.filter { $0.title.localizedCaseInsensitiveContains(query) } + return options.filter { + $0.title.localizedCaseInsensitiveContains(query) || + ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) + } } } @@ -298,8 +304,16 @@ fileprivate struct CommandRow: View { .font(.system(size: 14, weight: .medium)) } - Text(option.title) - .fontWeight(option.emphasis ? .medium : .regular) + VStack(alignment: .leading, spacing: 2) { + Text(option.title) + .fontWeight(option.emphasis ? .medium : .regular) + + if let subtitle = option.subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } Spacer() diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 6d6a89162..ecd301208 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -142,10 +142,16 @@ struct TerminalCommandPaletteView: View { return controller.surfaceTree.map { surface in let title = surface.title.isEmpty ? window.title : surface.title let displayTitle = title.isEmpty ? "Untitled" : title + let pwd = surface.pwd?.abbreviatedPath + let subtitle: String? = if let pwd, !displayTitle.contains(pwd) { + pwd + } else { + nil + } return CommandOption( title: "Focus: \(displayTitle)", - description: surface.pwd?.abbreviatedPath, + subtitle: subtitle, leadingIcon: "rectangle.on.rectangle", leadingColor: displayColor?.displayColor.map { Color($0) } ) { From d23f7e051fb207c7d7f20666e888d33818374c7a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:52:05 -0800 Subject: [PATCH 13/96] macos: stable sort for surfaces --- .../Command Palette/CommandPalette.swift | 14 +++++++++++ .../TerminalCommandPalette.swift | 16 +++++++++--- macos/Sources/Helpers/AnySortKey.swift | 25 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 macos/Sources/Helpers/AnySortKey.swift diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 3cb4e3480..333c69fec 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -1,15 +1,27 @@ import SwiftUI struct CommandOption: Identifiable, Hashable { + /// Unique identifier for this option. let id = UUID() + /// The primary text displayed for this command. let title: String + /// Secondary text displayed below the title. let subtitle: String? + /// Tooltip text shown on hover. let description: String? + /// Keyboard shortcut symbols to display. let symbols: [String]? + /// SF Symbol name for the leading icon. let leadingIcon: String? + /// Color for the leading indicator circle. let leadingColor: Color? + /// Badge text displayed as a pill. let badge: String? + /// Whether to visually emphasize this option. let emphasis: Bool + /// Sort key for stable ordering when titles are equal. + let sortKey: AnySortKey? + /// The action to perform when this option is selected. let action: () -> Void init( @@ -21,6 +33,7 @@ struct CommandOption: Identifiable, Hashable { leadingColor: Color? = nil, badge: String? = nil, emphasis: Bool = false, + sortKey: AnySortKey? = nil, action: @escaping () -> Void ) { self.title = title @@ -31,6 +44,7 @@ struct CommandOption: Identifiable, Hashable { self.leadingColor = leadingColor self.badge = badge self.emphasis = emphasis + self.sortKey = sortKey self.action = action } diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index ecd301208..19bedd4ee 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -67,11 +67,20 @@ struct TerminalCommandPaletteView: View { options.append(contentsOf: updateOptions) // Sort the rest. We replace ":" with a character that sorts before space - // so that "Foo:" sorts before "Foo Bar:". + // so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker + // for stable ordering when titles are equal. options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") - return aNormalized.localizedCaseInsensitiveCompare(bNormalized) == .orderedAscending + let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + // Tie-breaker: use sortKey if both have one + if let aSortKey = a.sortKey, let bSortKey = b.sortKey { + return aSortKey < bSortKey + } + return false }) return options } @@ -153,7 +162,8 @@ struct TerminalCommandPaletteView: View { title: "Focus: \(displayTitle)", subtitle: subtitle, leadingIcon: "rectangle.on.rectangle", - leadingColor: displayColor?.displayColor.map { Color($0) } + leadingColor: displayColor?.displayColor.map { Color($0) }, + sortKey: AnySortKey(ObjectIdentifier(surface)) ) { NotificationCenter.default.post( name: Ghostty.Notification.ghosttyPresentTerminal, diff --git a/macos/Sources/Helpers/AnySortKey.swift b/macos/Sources/Helpers/AnySortKey.swift new file mode 100644 index 000000000..6813ccf45 --- /dev/null +++ b/macos/Sources/Helpers/AnySortKey.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Type-erased wrapper for any Comparable type to use as a sort key. +struct AnySortKey: Comparable { + private let value: Any + private let comparator: (Any, Any) -> ComparisonResult + + init(_ value: T) { + self.value = value + self.comparator = { lhs, rhs in + guard let l = lhs as? T, let r = rhs as? T else { return .orderedSame } + if l < r { return .orderedAscending } + if l > r { return .orderedDescending } + return .orderedSame + } + } + + static func < (lhs: AnySortKey, rhs: AnySortKey) -> Bool { + lhs.comparator(lhs.value, rhs.value) == .orderedAscending + } + + static func == (lhs: AnySortKey, rhs: AnySortKey) -> Bool { + lhs.comparator(lhs.value, rhs.value) == .orderedSame + } +} From e1356538ac70b876bc55bffe0b191465dfe2db62 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:57:33 -0800 Subject: [PATCH 14/96] macos: show a highlight animation when a terminal is presented --- .../Terminal/BaseTerminalController.swift | 3 + macos/Sources/Ghostty/SurfaceView.swift | 62 ++++++++++++++++++- .../Sources/Ghostty/SurfaceView_AppKit.swift | 11 ++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9e8aece2d..5129351a1 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -715,6 +715,9 @@ class BaseTerminalController: NSWindowController, // We use a small delay to ensure this runs after any UI cleanup // (e.g., command palette restoring focus to its original surface). Ghostty.moveFocus(to: target, delay: 0.1) + + // Show a brief highlight to help the user locate the presented terminal. + target.highlight() } // MARK: Local Events diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 82232dd89..49c6a4982 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -49,7 +49,7 @@ extension Ghostty { // True if we're hovering over the left URL view, so we can show it on the right. @State private var isHoveringURLLeft: Bool = false - + #if canImport(AppKit) // Observe SecureInput to detect when its enabled @ObservedObject private var secureInput = SecureInput.shared @@ -219,6 +219,9 @@ extension Ghostty { BellBorderOverlay(bell: surfaceView.bell) } + // Show a highlight effect when this surface needs attention + HighlightOverlay(highlighted: surfaceView.highlighted) + // If our surface is not healthy, then we render an error view over it. if (!surfaceView.healthy) { Rectangle().fill(ghostty.config.backgroundColor) @@ -242,6 +245,7 @@ extension Ghostty { } } } + } } @@ -764,6 +768,62 @@ extension Ghostty { } } + /// Visual overlay that briefly highlights a surface to draw attention to it. + /// Uses a soft, soothing highlight with a pulsing border effect. + struct HighlightOverlay: View { + let highlighted: Bool + + @State private var borderPulse: Bool = false + + var body: some View { + ZStack { + Rectangle() + .fill( + RadialGradient( + gradient: Gradient(colors: [ + Color.accentColor.opacity(0.12), + Color.accentColor.opacity(0.03), + Color.clear + ]), + center: .center, + startRadius: 0, + endRadius: 2000 + ) + ) + + Rectangle() + .strokeBorder( + LinearGradient( + gradient: Gradient(colors: [ + Color.accentColor.opacity(0.8), + Color.accentColor.opacity(0.5), + Color.accentColor.opacity(0.8) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: borderPulse ? 4 : 2 + ) + .shadow(color: Color.accentColor.opacity(borderPulse ? 0.8 : 0.6), radius: borderPulse ? 12 : 8, x: 0, y: 0) + .shadow(color: Color.accentColor.opacity(borderPulse ? 0.5 : 0.3), radius: borderPulse ? 24 : 16, x: 0, y: 0) + } + .allowsHitTesting(false) + .opacity(highlighted ? 1.0 : 0.0) + .animation(.easeOut(duration: 0.4), value: highlighted) + .onChange(of: highlighted) { newValue in + if newValue { + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) { + borderPulse = true + } + } else { + withAnimation(.easeOut(duration: 0.4)) { + borderPulse = false + } + } + } + } + } + // MARK: Readonly Badge /// A badge overlay that indicates a surface is in readonly mode. diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d26545ebc..88a0bb6e8 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -126,6 +126,9 @@ extension Ghostty { /// True when the surface is in readonly mode. @Published private(set) var readonly: Bool = false + /// True when the surface should show a highlight effect (e.g., when presented via goto_split). + @Published private(set) var highlighted: 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 @@ -1523,6 +1526,14 @@ extension Ghostty { } } + /// Triggers a brief highlight animation on this surface. + func highlight() { + highlighted = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.highlighted = false + } + } + @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT) From 829dd1b9b23683e5e6bd583b7d1724d1fa69de52 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 10:13:53 -0800 Subject: [PATCH 15/96] macos: focus shenangians --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5129351a1..d79c89d2d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -714,6 +714,7 @@ class BaseTerminalController: NSWindowController, // We use a small delay to ensure this runs after any UI cleanup // (e.g., command palette restoring focus to its original surface). + Ghostty.moveFocus(to: target) Ghostty.moveFocus(to: target, delay: 0.1) // Show a brief highlight to help the user locate the presented terminal. From 842583b628538c4eb6232d9e4de8d23a55404016 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 10:25:59 -0800 Subject: [PATCH 16/96] macos: fix uikit build --- macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 +++ macos/Sources/Helpers/Extensions/String+Extension.swift | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 568a93314..b2e429455 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -46,6 +46,9 @@ extension Ghostty { /// True when the surface is in readonly mode. @Published private(set) var readonly: Bool = false + + /// True when the surface should show a highlight effect (e.g., when presented via goto_split). + @Published private(set) var highlighted: Bool = false // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index a8d93091a..139a7892c 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -7,7 +7,7 @@ extension String { return self.prefix(maxLength) + trailing } - #if canImport(AppKit) +#if canImport(AppKit) func temporaryFile(_ filename: String = "temp") -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent(filename) @@ -16,7 +16,6 @@ extension String { try? string.write(to: url, atomically: true, encoding: .utf8) return url } - #endif /// Returns the path with the home directory abbreviated as ~. var abbreviatedPath: String { @@ -26,4 +25,5 @@ extension String { } return self } +#endif } From e1d0b2202947f9c497fe521a93c24d57907d97ef Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 10:48:39 -0800 Subject: [PATCH 17/96] macos: allow searching sessions by color too --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Command Palette/CommandPalette.swift | 41 +++++++++++++++++-- .../Extensions/NSColor+Extension.swift | 39 ++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/NSColor+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 562166c87..1a810e621 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -157,6 +157,7 @@ "Helpers/Extensions/KeyboardShortcut+Extension.swift", "Helpers/Extensions/NSAppearance+Extension.swift", "Helpers/Extensions/NSApplication+Extension.swift", + "Helpers/Extensions/NSColor+Extension.swift", "Helpers/Extensions/NSImage+Extension.swift", "Helpers/Extensions/NSMenu+Extension.swift", "Helpers/Extensions/NSMenuItem+Extension.swift", diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 333c69fec..235881dde 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -67,14 +67,23 @@ struct CommandPaletteView: View { @FocusState private var isTextFieldFocused: Bool // The options that we should show, taking into account any filtering from - // the query. + // the query. Options with matching leadingColor are ranked higher. var filteredOptions: [CommandOption] { if query.isEmpty { return options } else { - return options.filter { + // Filter by title/subtitle match OR color match + let filtered = options.filter { $0.title.localizedCaseInsensitiveContains(query) || - ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) + ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) || + colorMatchScore(for: $0.leadingColor, query: query) > 0 + } + + // Sort by color match score (higher scores first), then maintain original order + return filtered.sorted { a, b in + let scoreA = colorMatchScore(for: a.leadingColor, query: query) + let scoreB = colorMatchScore(for: b.leadingColor, query: query) + return scoreA > scoreB } } } @@ -191,6 +200,32 @@ struct CommandPaletteView: View { isTextFieldFocused = isPresented } } + + /// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name. + /// Returns 0 if no color name in the query matches, or if the color is nil. + private func colorMatchScore(for color: Color?, query: String) -> Double { + guard let color = color else { return 0 } + + let queryLower = query.lowercased() + let nsColor = NSColor(color) + + var bestScore: Double = 0 + for name in NSColor.colorNames { + guard queryLower.contains(name), + let systemColor = NSColor(named: name) else { continue } + + let distance = nsColor.distance(to: systemColor) + // Max distance in weighted RGB space is ~3.0, so normalize and invert + // Use a threshold to determine "close enough" matches + let maxDistance: Double = 1.5 + if distance < maxDistance { + let score = 1.0 - (distance / maxDistance) + bestScore = max(bestScore, score) + } + } + + return bestScore + } } /// The text field for building the query for the command palette. diff --git a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift new file mode 100644 index 000000000..63cf02ed4 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift @@ -0,0 +1,39 @@ +import AppKit + +extension NSColor { + /// Using a color list let's us get localized names. + private static let appleColorList: NSColorList? = NSColorList(named: "Apple") + + convenience init?(named name: String) { + guard let colorList = Self.appleColorList, + let color = colorList.color(withKey: name.capitalized) else { + return nil + } + guard let components = color.usingColorSpace(.sRGB) else { + return nil + } + self.init( + red: components.redComponent, + green: components.greenComponent, + blue: components.blueComponent, + alpha: components.alphaComponent + ) + } + + static var colorNames: [String] { + appleColorList?.allKeys.map { $0.lowercased() } ?? [] + } + + /// Calculates the perceptual distance to another color in RGB space. + func distance(to other: NSColor) -> Double { + guard let a = self.usingColorSpace(.sRGB), + let b = other.usingColorSpace(.sRGB) else { return .infinity } + + let dr = a.redComponent - b.redComponent + let dg = a.greenComponent - b.greenComponent + let db = a.blueComponent - b.blueComponent + + // Weighted Euclidean distance (human eye is more sensitive to green) + return sqrt(2 * dr * dr + 4 * dg * dg + 3 * db * db) + } +} From 377bcddb4660370186c65795af76793cfb453ed1 Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Thu, 18 Dec 2025 12:52:13 +1100 Subject: [PATCH 18/96] fix(macOS): re-apply icon after app update --- macos/Sources/App/macOS/AppDelegate.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1697f7438..7f6005dd8 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -982,9 +982,15 @@ class AppDelegate: NSObject, 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 { + + // Only change the icon if it has actually changed from the current one, + // or if the app build has changed (e.g. after an update that reset the icon) + let cachedIconName = UserDefaults.standard.string(forKey: "CustomGhosttyIcon") + let cachedIconBuild = UserDefaults.standard.string(forKey: "CustomGhosttyIconBuild") + let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + let buildChanged = cachedIconBuild != currentBuild + + guard cachedIconName != appIconName || buildChanged else { #if DEBUG if appIcon == nil { await MainActor.run { @@ -1001,14 +1007,16 @@ class AppDelegate: NSObject, let newIcon = appIcon let appPath = Bundle.main.bundlePath - NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) + guard NSWorkspace.shared.setIcon(newIcon, forFile: appPath, options: []) else { return } NSWorkspace.shared.noteFileSystemChanged(appPath) await MainActor.run { self.appIcon = newIcon NSApplication.shared.applicationIconImage = newIcon } + UserDefaults.standard.set(appIconName, forKey: "CustomGhosttyIcon") + UserDefaults.standard.set(currentBuild, forKey: "CustomGhosttyIconBuild") } //MARK: - Restorable State From 5a2f5a6b9e87d2d3324dbcb4022cb02df4db8d6e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Dec 2025 13:53:38 -0800 Subject: [PATCH 19/96] terminal: RenderState linkCells needs to use Page y not Viewport y Fixes #9957 Our `Page.getRowAndCell` uses a _page-relative_ x/y coordinate system and we were passing in viewport x/y. This has the possibility to leading to all sorts of bugs, including the crash found in #9957 but also simply reading the wrong cell even in single-page scenarios. --- src/terminal/render.zig | 49 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index b6430ea34..093476f2c 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -817,11 +817,12 @@ pub const RenderState = struct { 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_pin: PageList.Pin = row_pins[viewport_point.y]; + const link_page: *page.Page = &link_pin.node.data; const link = link: { const rac = link_page.getRowAndCell( viewport_point.x, - viewport_point.y, + link_pin.y, ); // The likely scenario is that our mouse isn't even over a link. @@ -848,7 +849,7 @@ pub const RenderState = struct { const other_page: *page.Page = &pin.node.data; const other = link: { - const rac = other_page.getRowAndCell(x, y); + const rac = other_page.getRowAndCell(x, pin.y); const link_id = other_page.lookupHyperlink(rac.cell) orelse continue; break :link other_page.hyperlink_set.get( other_page.memory, @@ -1317,6 +1318,48 @@ test "string" { try testing.expectEqualStrings(expected, result); } +test "linkCells with scrollback spanning pages" { + const testing = std.testing; + const alloc = testing.allocator; + + const viewport_rows: size.CellCountInt = 10; + const tail_rows: size.CellCountInt = 5; + + var t = try Terminal.init(alloc, .{ + .cols = page.std_capacity.cols, + .rows = viewport_rows, + .max_scrollback = 10_000, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screens.active.pages; + const first_page_cap = pages.pages.first.?.data.capacity.rows; + + // Fill first page + for (0..first_page_cap - 1) |_| try s.nextSlice("\r\n"); + + // Create second page with hyperlink + try s.nextSlice("\r\n"); + try s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\"); + for (0..(tail_rows - 1)) |_| try s.nextSlice("\r\n"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const expected_viewport_y: usize = viewport_rows - tail_rows; + // BUG: This crashes without the fix + var cells = try state.linkCells(alloc, .{ + .x = 0, + .y = expected_viewport_y, + }); + defer cells.deinit(alloc); + try testing.expectEqual(@as(usize, 4), cells.count()); +} + test "dirty row resets highlights" { const testing = std.testing; const alloc = testing.allocator; From 0ace736f46148216c7d2d5b91d3d786ca7760ec5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Dec 2025 15:35:48 -0800 Subject: [PATCH 20/96] macos: remove the command palette appear/disappear animation Lots of people complained about this and I don't see value in it. --- .../Features/Command Palette/TerminalCommandPalette.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 19bedd4ee..902186ad3 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -39,13 +39,8 @@ struct TerminalCommandPaletteView: View { } .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 86a0eb1a75892c2a7fb986d00c40a0b7a597c574 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 07:13:43 -0800 Subject: [PATCH 21/96] macos: hide tab overview on `escape` This hides the macOS tab overview when the `escape` key is pressed. Our solution is a bit blunt here and I don't think its right. I think we have a first responder problem somewhere but I haven't been able to find it and find the proper place to implement `cancel` (or equivalent) to hide the overview. I tried implementing `cancel` in all the places I expect the responder chain to go through but none worked. For now let's do this since it is pretty tightly scoped! --- macos/Sources/App/macOS/AppDelegate.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 7f6005dd8..57bfba828 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -685,6 +685,18 @@ class AppDelegate: NSObject, } private func localEventKeyDown(_ event: NSEvent) -> NSEvent? { + // If the tab overview is visible and escape is pressed, close it. + // This can't POSSIBLY be right and is probably a FirstResponder problem + // that we should handle elsewhere in our program. But this works and it + // is guarded by the tab overview currently showing. + if event.keyCode == 0x35, // Escape key + let window = NSApp.keyWindow, + let tabGroup = window.tabGroup, + tabGroup.isOverviewVisible { + window.toggleTabOverview(nil) + return nil + } + // If we have a main window then we don't process any of the keys // because we let it capture and propagate. guard NSApp.mainWindow == nil else { return event } From 07b47b87fa26390c697e4e78292d4cf991031ae2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 07:27:57 -0800 Subject: [PATCH 22/96] renderer/metal: clamp texture sizes to the maximum allowed by the device This prevents a crash in our renderer when it is larger. I will pair this with apprt changes so that our mac app won't ever allow a default window larger than the screen but we should be resilient at the renderer level as well. --- src/renderer/Metal.zig | 44 ++++++++++++++++++++++++++++++++++++-- src/renderer/metal/api.zig | 21 ++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 168f54c2b..f1d912152 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -55,6 +55,9 @@ blending: configpkg.Config.AlphaBlending, /// the "shared" storage mode, instead we have to use the "managed" mode. default_storage_mode: mtl.MTLResourceOptions.StorageMode, +/// The maximum 2D texture width and height supported by the device. +max_texture_size: u32, + /// We start an AutoreleasePool before `drawFrame` and end it afterwards. autorelease_pool: ?*objc.AutoreleasePool = null, @@ -72,8 +75,14 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{}); errdefer queue.release(); + // Grab metadata about the device. const default_storage_mode: mtl.MTLResourceOptions.StorageMode = if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed; + const max_texture_size = queryMaxTextureSize(device); + log.debug( + "device properties default_storage_mode={} max_texture_size={}", + .{ default_storage_mode, max_texture_size }, + ); const ViewInfo = struct { view: objc.Object, @@ -138,6 +147,7 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { .queue = queue, .blending = opts.config.blending, .default_storage_mode = default_storage_mode, + .max_texture_size = max_texture_size, }; } @@ -202,9 +212,19 @@ pub fn initShaders( pub fn surfaceSize(self: *const Metal) !struct { width: u32, height: u32 } { const bounds = self.layer.layer.getProperty(graphics.Rect, "bounds"); const scale = self.layer.layer.getProperty(f64, "contentsScale"); + + // We need to clamp our runtime surface size to the maximum + // possible texture size since we can't create a screen buffer (texture) + // larger than that. return .{ - .width = @intFromFloat(bounds.size.width * scale), - .height = @intFromFloat(bounds.size.height * scale), + .width = @min( + @as(u32, @intFromFloat(bounds.size.width * scale)), + self.max_texture_size, + ), + .height = @min( + @as(u32, @intFromFloat(bounds.size.height * scale)), + self.max_texture_size, + ), }; } @@ -412,3 +432,23 @@ fn chooseDevice() error{NoMetalDevice}!objc.Object { const device = chosen_device orelse return error.NoMetalDevice; return device.retain(); } + +/// Determines the maximum 2D texture size supported by the device. +/// We need to clamp our frame size to this if its larger. +fn queryMaxTextureSize(device: objc.Object) u32 { + // https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf + + if (device.msgSend( + bool, + objc.sel("supportsFamily:"), + .{mtl.MTLGPUFamily.apple10}, + )) return 32768; + + if (device.msgSend( + bool, + objc.sel("supportsFamily:"), + .{mtl.MTLGPUFamily.apple3}, + )) return 16384; + + return 8192; +} diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index e1daa6848..a2d8a1356 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -391,6 +391,27 @@ pub const MTLRenderStage = enum(c_ulong) { mesh = 16, }; +/// https://developer.apple.com/documentation/metal/mtlgpufamily?language=objc +pub const MTLGPUFamily = enum(c_long) { + apple1 = 1001, + apple2 = 1002, + apple3 = 1003, + apple4 = 1004, + apple5 = 1005, + apple6 = 1006, + apple7 = 1007, + apple8 = 1008, + apple9 = 1009, + apple10 = 1010, + + common1 = 3001, + common2 = 3002, + common3 = 3003, + + metal3 = 5001, + metal4 = 5002, +}; + pub const MTLClearColor = extern struct { red: f64, green: f64, From 594195963d650d09ed6cb3245671f282aad4b8a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 09:31:10 -0800 Subject: [PATCH 23/96] Update src/renderer/Metal.zig Co-authored-by: Jon Parise --- src/renderer/Metal.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index f1d912152..2aac285c6 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -434,7 +434,7 @@ fn chooseDevice() error{NoMetalDevice}!objc.Object { } /// Determines the maximum 2D texture size supported by the device. -/// We need to clamp our frame size to this if its larger. +/// We need to clamp our frame size to this if it's larger. fn queryMaxTextureSize(device: objc.Object) u32 { // https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf From d1bea9d737eef15cfb0a2a6a3f8e33f132158a48 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 10:28:51 -0800 Subject: [PATCH 24/96] macos: window width/height should be clamped, work with position Fixes #9952 Fixes #9969 This fixes our `constrainToScreen` implementation to properly clamp the window size to the visible screen its coming on as documented. Further, this addresses the positioning problem, too. --- .../Helpers/Extensions/NSWindow+Extension.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index d834f5e63..f8df803db 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -16,19 +16,23 @@ extension NSWindow { return firstWindow === self } - /// Adjusts the window origin if necessary to ensure the window remains visible on screen. + /// Adjusts the window frame if necessary to ensure the window remains visible on screen. + /// This constrains both the size (to not exceed the screen) and the origin (to keep the window on screen). func constrainToScreen() { guard let screen = screen ?? NSScreen.main else { return } let visibleFrame = screen.visibleFrame var windowFrame = frame + windowFrame.size.width = min(windowFrame.size.width, visibleFrame.size.width) + windowFrame.size.height = min(windowFrame.size.height, visibleFrame.size.height) + 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) + if windowFrame != frame { + setFrame(windowFrame, display: true) } } } From 63422f4d4e10a9c78fe7162bf784689cf226bada Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 11:56:04 -0800 Subject: [PATCH 25/96] add the catch_all binding key Part of #9963 This adds a new special key `catch_all` that can be used in keybinding definitions to match any key that is not explicitly bound. For example: `keybind = catch_all=new_window` (chaos!). `catch_all` can be used in combination with modifiers, so if you want to catch any non-bound key with Ctrl held down, you can do: `keybind = ctrl+catch_all=new_window`. `catch_all` can also be used with trigger sequences, so you can do: `keybind = ctrl+a>catch_all=new_window` to catch any key pressed after `ctrl+a` that is not explicitly bound and make a new window! And if you want to remove the catch all binding, it is like any other: `keybind = catch_all=unbind`. --- include/ghostty.h | 2 + macos/Sources/Ghostty/Ghostty.Input.swift | 4 + src/apprt/gtk/key.zig | 2 + src/cli/list_keybinds.zig | 5 + src/config/Config.zig | 7 ++ src/input/Binding.zig | 129 +++++++++++++++++++++- 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 47db34e71..736c7546b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -317,12 +317,14 @@ typedef struct { typedef enum { GHOSTTY_TRIGGER_PHYSICAL, GHOSTTY_TRIGGER_UNICODE, + GHOSTTY_TRIGGER_CATCH_ALL, } ghostty_input_trigger_tag_e; typedef union { ghostty_input_key_e translated; ghostty_input_key_e physical; uint32_t unicode; + // catch_all has no payload } ghostty_input_trigger_key_u; typedef struct { diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index e05911c06..6b4eb0ae4 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -32,6 +32,10 @@ extension Ghostty { guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } key = KeyEquivalent(Character(scalar)) + case GHOSTTY_TRIGGER_CATCH_ALL: + // catch_all matches any key, so it can't be represented as a KeyboardShortcut + return nil + default: return nil } diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 19bdc8315..35c9390b2 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -74,6 +74,8 @@ fn writeTriggerKey( try writer.print("{u}", .{cp}); } }, + + .catch_all => return false, } return true; diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index a8899a4f5..e463f55b9 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -166,16 +166,19 @@ const ChordBinding = struct { var r_trigger = rhs.triggers.first; while (l_trigger != null and r_trigger != null) { + // We want catch_all to sort last. const lhs_key: c_int = blk: { switch (TriggerNode.get(l_trigger.?).data.key) { .physical => |key| break :blk @intFromEnum(key), .unicode => |key| break :blk @intCast(key), + .catch_all => break :blk std.math.maxInt(c_int), } }; const rhs_key: c_int = blk: { switch (TriggerNode.get(r_trigger.?).data.key) { .physical => |key| break :blk @intFromEnum(key), .unicode => |key| break :blk @intCast(key), + .catch_all => break :blk std.math.maxInt(c_int), } }; @@ -268,6 +271,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { const key = switch (trigger.data.key) { .physical => |k| try std.fmt.allocPrint(alloc, "{t}", .{k}), .unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}), + .catch_all => "catch_all", }; result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col }); @@ -314,6 +318,7 @@ fn iterateBindings( switch (t.key) { .physical => |k| try buf.writer.print("{t}", .{k}), .unicode => |c| try buf.writer.print("{u}", .{c}), + .catch_all => try buf.writer.print("catch_all", .{}), } break :blk win.gwidth(buf.written()); diff --git a/src/config/Config.zig b/src/config/Config.zig index 1aad62d7d..f2f6f2322 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1477,6 +1477,13 @@ class: ?[:0]const u8 = null, /// so if you specify both `a` and `KeyA`, the physical key will always be used /// regardless of what order they are configured. /// +/// The special key `catch_all` can be used to match any key that is not +/// otherwise bound. This can be combined with modifiers, for example +/// `ctrl+catch_all` will match any key pressed with `ctrl` that is not +/// otherwise bound. When looking up a binding, Ghostty first tries to match +/// `catch_all` with modifiers. If no match is found and the event has +/// modifiers, it falls back to `catch_all` without modifiers. +/// /// Valid modifiers are `shift`, `ctrl` (alias: `control`), `alt` (alias: `opt`, /// `option`), and `super` (alias: `cmd`, `command`). You may use the modifier /// or the alias. When debugging keybinds, the non-aliased modifier will always diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 9f3ad8a2a..666852094 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1505,6 +1505,10 @@ pub const Trigger = struct { /// codepoint. This is useful for binding to keys that don't have a /// registered keycode with Ghostty. unicode: u21, + + /// A catch-all key that matches any key press that is otherwise + /// unbound. + catch_all, }; /// The extern struct used for triggers in the C API. @@ -1516,6 +1520,7 @@ pub const Trigger = struct { pub const Tag = enum(c_int) { physical, unicode, + catch_all, }; pub const Key = extern union { @@ -1611,6 +1616,13 @@ pub const Trigger = struct { continue :loop; } + // Check for catch_all. We do this near the end since its unlikely + // in most cases that we're setting a catch-all key. + if (std.mem.eql(u8, part, "catch_all")) { + result.key = .catch_all; + continue :loop; + } + // If we're still unset then we look for backwards compatible // keys with Ghostty 1.1.x. We do this last so its least likely // to impact performance for modern users. @@ -1751,7 +1763,7 @@ pub const Trigger = struct { pub fn isKeyUnset(self: Trigger) bool { return switch (self.key) { .physical => |v| v == .unidentified, - else => false, + .unicode, .catch_all => false, }; } @@ -1771,6 +1783,7 @@ pub const Trigger = struct { hasher, foldedCodepoint(cp), ), + .catch_all => {}, } std.hash.autoHash(hasher, self.mods.binding()); } @@ -1801,6 +1814,9 @@ pub const Trigger = struct { .key = switch (self.key) { .physical => |v| .{ .physical = v }, .unicode => |v| .{ .unicode = @intCast(v) }, + // catch_all has no associated value so its an error + // for a C consumer to look at it. + .catch_all => undefined, }, .mods = self.mods, }; @@ -1821,6 +1837,7 @@ pub const Trigger = struct { switch (self.key) { .physical => |k| try writer.print("{t}", .{k}), .unicode => |c| try writer.print("{u}", .{c}), + .catch_all => try writer.writeAll("catch_all"), } } }; @@ -2213,6 +2230,14 @@ pub const Set = struct { if (self.get(trigger)) |v| return v; } + // Fallback to catch_all with modifiers first, then without modifiers. + trigger.key = .catch_all; + if (self.get(trigger)) |v| return v; + if (!trigger.mods.empty()) { + trigger.mods = .{}; + if (self.get(trigger)) |v| return v; + } + return null; } @@ -2433,6 +2458,31 @@ test "parse: w3c key names" { try testing.expectError(Error.InvalidFormat, parseSingle("Keya=ignore")); } +test "parse: catch_all" { + const testing = std.testing; + + // Basic catch_all + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .catch_all }, + .action = .{ .ignore = {} }, + }, + try parseSingle("catch_all=ignore"), + ); + + // catch_all with modifiers + try testing.expectEqual( + Binding{ + .trigger = .{ + .mods = .{ .ctrl = true }, + .key = .catch_all, + }, + .action = .{ .ignore = {} }, + }, + try parseSingle("ctrl+catch_all=ignore"), + ); +} + test "parse: plus sign" { const testing = std.testing; @@ -3329,6 +3379,83 @@ test "set: getEvent codepoint case folding" { } } +test "set: getEvent catch_all fallback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "catch_all=ignore"); + + // Matches unbound key without modifiers + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{}, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Matches unbound key with modifiers (falls back to catch_all without mods) + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Specific binding takes precedence over catch_all + try s.parseAndPut(alloc, "ctrl+b=new_window"); + { + const action = s.getEvent(.{ + .key = .key_b, + .mods = .{ .ctrl = true }, + .unshifted_codepoint = 'b', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } +} + +test "set: getEvent catch_all with modifiers" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+catch_all=close_surface"); + try s.parseAndPut(alloc, "catch_all=ignore"); + + // Key with ctrl matches catch_all with ctrl modifier + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .close_surface); + } + + // Key without mods matches catch_all without mods + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{}, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Key with different mods falls back to catch_all without mods + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .alt = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } +} + test "Action: clone" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator); From c53b3fffd5af411be05467ebc88ee9222e67b031 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 13:26:20 -0800 Subject: [PATCH 26/96] config: keybind table parsing --- src/config/Config.zig | 313 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 309 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index f2f6f2322..8d941c733 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5812,6 +5812,10 @@ pub const RepeatableFontVariation = struct { pub const Keybinds = struct { set: inputpkg.Binding.Set = .{}, + /// Defined key tables. The default key table is always the root "set", + /// which allows all table names to be available without reservation. + tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty, + pub fn init(self: *Keybinds, alloc: Allocator) !void { // We don't clear the memory because it's in the arena and unlikely // to be free-able anyways (since arenas can only clear the last @@ -6590,18 +6594,69 @@ pub const Keybinds = struct { return; } - // Let our much better tested binding package handle parsing and storage. + // Check for table syntax: "name/" or "name/binding" + // We look for '/' only before the first '=' to avoid matching + // action arguments like "foo=text:/hello". + const eq_idx = std.mem.indexOfScalar(u8, value, '=') orelse value.len; + if (std.mem.indexOfScalar(u8, value[0..eq_idx], '/')) |slash_idx| { + const table_name = value[0..slash_idx]; + const binding = value[slash_idx + 1 ..]; + + // Table name cannot be empty + if (table_name.len == 0) return error.InvalidFormat; + + // Get or create the table + const gop = try self.tables.getOrPut(alloc, table_name); + if (!gop.found_existing) { + gop.value_ptr.* = .{}; + } + + // If there's no binding after the slash, this is a table + // definition/clear command + if (binding.len == 0) { + log.debug("config has 'keybind = {s}/', table cleared", .{table_name}); + gop.value_ptr.* = .{}; + return; + } + + // Parse and add the binding to the table + try gop.value_ptr.parseAndPut(alloc, binding); + return; + } + + // Parse into default set try self.set.parseAndPut(alloc, value); } /// Deep copy of the struct. Required by Config. pub fn clone(self: *const Keybinds, alloc: Allocator) Allocator.Error!Keybinds { - return .{ .set = try self.set.clone(alloc) }; + var tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty; + try tables.ensureTotalCapacity(alloc, @intCast(self.tables.count())); + var it = self.tables.iterator(); + while (it.next()) |entry| { + tables.putAssumeCapacity(entry.key_ptr.*, try entry.value_ptr.clone(alloc)); + } + + return .{ + .set = try self.set.clone(alloc), + .tables = tables, + }; } /// Compare if two of our value are requal. Required by Config. pub fn equal(self: Keybinds, other: Keybinds) bool { - return equalSet(&self.set, &other.set); + if (!equalSet(&self.set, &other.set)) return false; + + // Compare tables + if (self.tables.count() != other.tables.count()) return false; + + var it = self.tables.iterator(); + while (it.next()) |entry| { + const other_set = other.tables.get(entry.key_ptr.*) orelse return false; + if (!equalSet(entry.value_ptr, &other_set)) return false; + } + + return true; } fn equalSet( @@ -6652,12 +6707,14 @@ pub const Keybinds = struct { /// Like formatEntry but has an option to include docs. pub fn formatEntryDocs(self: Keybinds, formatter: formatterpkg.EntryFormatter, docs: bool) !void { - if (self.set.bindings.size == 0) { + if (self.set.bindings.size == 0 and self.tables.count() == 0) { try formatter.formatEntry(void, {}); return; } var buf: [1024]u8 = undefined; + + // Format root set bindings var iter = self.set.bindings.iterator(); while (iter.next()) |next| { const k = next.key_ptr.*; @@ -6684,6 +6741,23 @@ pub const Keybinds = struct { writer.print("{f}", .{k}) catch return error.OutOfMemory; try v.formatEntries(&writer, formatter); } + + // Format table bindings + var table_iter = self.tables.iterator(); + while (table_iter.next()) |table_entry| { + const table_name = table_entry.key_ptr.*; + const table_set = table_entry.value_ptr.*; + + var binding_iter = table_set.bindings.iterator(); + while (binding_iter.next()) |next| { + const k = next.key_ptr.*; + const v = next.value_ptr.*; + + var writer: std.Io.Writer = .fixed(&buf); + writer.print("{s}/{f}", .{ table_name, k }) catch return error.OutOfMemory; + try v.formatEntries(&writer, formatter); + } + } } /// Used by Formatter @@ -6768,6 +6842,237 @@ pub const Keybinds = struct { ; try std.testing.expectEqualStrings(want, buf.written()); } + + test "parseCLI table definition" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Define a table by adding a binding to it + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try testing.expectEqual(1, keybinds.tables.count()); + try testing.expect(keybinds.tables.contains("foo")); + + const table = keybinds.tables.get("foo").?; + try testing.expectEqual(1, table.bindings.count()); + } + + test "parseCLI table clear" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Add a binding to a table + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try testing.expectEqual(1, keybinds.tables.get("foo").?.bindings.count()); + + // Clear the table with "foo/" + try keybinds.parseCLI(alloc, "foo/"); + try testing.expectEqual(0, keybinds.tables.get("foo").?.bindings.count()); + } + + test "parseCLI table multiple bindings" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window"); + + try testing.expectEqual(2, keybinds.tables.count()); + try testing.expectEqual(2, keybinds.tables.get("foo").?.bindings.count()); + try testing.expectEqual(1, keybinds.tables.get("bar").?.bindings.count()); + } + + test "parseCLI table does not affect root set" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + + // Root set should have the first binding + try testing.expectEqual(1, keybinds.set.bindings.count()); + // Table should have the second binding + try testing.expectEqual(1, keybinds.tables.get("foo").?.bindings.count()); + } + + test "parseCLI table empty name is invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + try testing.expectError(error.InvalidFormat, keybinds.parseCLI(alloc, "/shift+a=copy_to_clipboard")); + } + + test "parseCLI table with key sequence" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Key sequences should work within tables + try keybinds.parseCLI(alloc, "foo/ctrl+a>ctrl+b=new_window"); + + const table = keybinds.tables.get("foo").?; + try testing.expectEqual(1, table.bindings.count()); + } + + test "parseCLI slash in action argument is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // A slash after the = should not be interpreted as a table delimiter + try keybinds.parseCLI(alloc, "ctrl+a=text:/hello"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "clone with tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window"); + + const cloned = try keybinds.clone(alloc); + + // Verify the clone has the same structure + try testing.expectEqual(keybinds.set.bindings.count(), cloned.set.bindings.count()); + try testing.expectEqual(keybinds.tables.count(), cloned.tables.count()); + try testing.expectEqual( + keybinds.tables.get("foo").?.bindings.count(), + cloned.tables.get("foo").?.bindings.count(), + ); + try testing.expectEqual( + keybinds.tables.get("bar").?.bindings.count(), + cloned.tables.get("bar").?.bindings.count(), + ); + } + + test "equal with tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + try testing.expect(keybinds1.equal(keybinds2)); + } + + test "equal with tables different table count" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try keybinds2.parseCLI(alloc, "bar/shift+b=paste_from_clipboard"); + + try testing.expect(!keybinds1.equal(keybinds2)); + } + + test "equal with tables different table names" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "bar/shift+a=copy_to_clipboard"); + + try testing.expect(!keybinds1.equal(keybinds2)); + } + + test "equal with tables different bindings" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds1: Keybinds = .{}; + try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + + var keybinds2: Keybinds = .{}; + try keybinds2.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + + try testing.expect(!keybinds1.equal(keybinds2)); + } + + test "formatEntry with tables" { + 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 keybinds: Keybinds = .{}; + try keybinds.parseCLI(alloc, "foo/shift+a=csi:hello"); + try keybinds.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer)); + + try testing.expectEqualStrings("keybind = foo/shift+a=csi:hello\n", buf.written()); + } + + test "formatEntry with tables and root set" { + 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 keybinds: Keybinds = .{}; + try keybinds.parseCLI(alloc, "shift+b=csi:world"); + try keybinds.parseCLI(alloc, "foo/shift+a=csi:hello"); + try keybinds.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer)); + + const output = buf.written(); + try testing.expect(std.mem.indexOf(u8, output, "keybind = shift+b=csi:world\n") != null); + try testing.expect(std.mem.indexOf(u8, output, "keybind = foo/shift+a=csi:hello\n") != null); + } }; /// See "font-codepoint-map" for documentation. From 8c59143c1a9d5418ef9f97c7dfd57d8caa8d697f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 13:46:00 -0800 Subject: [PATCH 27/96] rename some key sequence state so it is clearer what it is --- src/Surface.zig | 56 ++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index fc5a239ab..711c79833 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -253,18 +253,9 @@ const Mouse = struct { /// Keyboard state for the surface. pub const Keyboard = struct { - /// The currently active keybindings for the surface. This is used to - /// implement sequences: as leader keys are pressed, the active bindings - /// set is updated to reflect the current leader key sequence. If this is - /// null then the root bindings are used. - bindings: ?*const input.Binding.Set = null, - - /// The last handled binding. This is used to prevent encoding release - /// events for handled bindings. We only need to keep track of one because - /// at least at the time of writing this, its impossible for two keys of - /// a combination to be handled by different bindings before the release - /// of the prior (namely since you can't bind modifier-only). - last_trigger: ?u64 = null, + /// The currently active key sequence for the surface. If this is null + /// then we're not currently in a key sequence. + sequence_set: ?*const input.Binding.Set = null, /// The queued keys when we're in the middle of a sequenced binding. /// These are flushed when the sequence is completed and unconsumed or @@ -272,7 +263,14 @@ pub const Keyboard = struct { /// /// This is naturally bounded due to the configuration maximum /// length of a sequence. - queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .{}, + sequence_queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .empty, + + /// The last handled binding. This is used to prevent encoding release + /// events for handled bindings. We only need to keep track of one because + /// at least at the time of writing this, its impossible for two keys of + /// a combination to be handled by different bindings before the release + /// of the prior (namely since you can't bind modifier-only). + last_trigger: ?u64 = null, }; /// The configuration that a surface has, this is copied from the main @@ -793,8 +791,8 @@ pub fn deinit(self: *Surface) void { } // Clean up our keyboard state - for (self.keyboard.queued.items) |req| req.deinit(); - self.keyboard.queued.deinit(self.alloc); + for (self.keyboard.sequence_queued.items) |req| req.deinit(); + self.keyboard.sequence_queued.deinit(self.alloc); // Clean up our font grid self.app.font_grid_set.deref(self.font_grid_key); @@ -2565,7 +2563,7 @@ pub fn keyEventIsBinding( // Our keybinding set is either our current nested set (for // sequences) or the root set. - const set = self.keyboard.bindings orelse &self.config.keybind.set; + const set = self.keyboard.sequence_set orelse &self.config.keybind.set; // log.warn("text keyEventIsBinding event={} match={}", .{ event, set.getEvent(event) != null }); @@ -2791,7 +2789,7 @@ fn maybeHandleBinding( // Find an entry in the keybind set that matches our event. const entry: input.Binding.Set.Entry = entry: { - const set = self.keyboard.bindings orelse &self.config.keybind.set; + const set = self.keyboard.sequence_set orelse &self.config.keybind.set; // Get our entry from the set for the given event. if (set.getEvent(event)) |v| break :entry v; @@ -2802,7 +2800,7 @@ fn maybeHandleBinding( // // We also ignore modifiers so that nested sequences such as // ctrl+a>ctrl+b>c work. - if (self.keyboard.bindings != null and + if (self.keyboard.sequence_set != null and !event.key.modifier()) { // Encode everything up to this point @@ -2816,13 +2814,13 @@ fn maybeHandleBinding( const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) { .leader => |set| { // Setup the next set we'll look at. - self.keyboard.bindings = set; + self.keyboard.sequence_set = set; // Store this event so that we can drain and encode on invalid. // We don't need to cap this because it is naturally capped by // the config validation. if (try self.encodeKey(event, insp_ev)) |req| { - try self.keyboard.queued.append(self.alloc, req); + try self.keyboard.sequence_queued.append(self.alloc, req); } // Start or continue our key sequence @@ -2861,8 +2859,8 @@ fn maybeHandleBinding( // perform an action (below) self.keyboard.last_trigger = null; - // An action also always resets the binding set. - self.keyboard.bindings = null; + // An action also always resets the sequence set. + self.keyboard.sequence_set = null; // Attempt to perform the action log.debug("key event binding flags={} action={f}", .{ @@ -2952,13 +2950,13 @@ fn endKeySequence( ); }; - // No matter what we clear our current binding set. This restores + // No matter what we clear our current sequence set. This restores // the set we look at to the root set. - self.keyboard.bindings = null; + self.keyboard.sequence_set = null; - if (self.keyboard.queued.items.len > 0) { + if (self.keyboard.sequence_queued.items.len > 0) { switch (action) { - .flush => for (self.keyboard.queued.items) |write_req| { + .flush => for (self.keyboard.sequence_queued.items) |write_req| { self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, @@ -2966,12 +2964,12 @@ fn endKeySequence( }, .unlocked); }, - .drop => for (self.keyboard.queued.items) |req| req.deinit(), + .drop => for (self.keyboard.sequence_queued.items) |req| req.deinit(), } switch (mem) { - .free => self.keyboard.queued.clearAndFree(self.alloc), - .retain => self.keyboard.queued.clearRetainingCapacity(), + .free => self.keyboard.sequence_queued.clearAndFree(self.alloc), + .retain => self.keyboard.sequence_queued.clearRetainingCapacity(), } } } From 34ae3848b6ec356ef56299d26a74d2d4a566147b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 13:40:45 -0800 Subject: [PATCH 28/96] core: key tables --- src/Surface.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 711c79833..77daaf7e9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -265,6 +265,10 @@ pub const Keyboard = struct { /// length of a sequence. sequence_queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .empty, + /// The stack of tables that is currently active. The first value + /// in this is the first activated table (NOT the default keybinding set). + table_stack: std.ArrayListUnmanaged(*const input.Binding.Set) = .empty, + /// The last handled binding. This is used to prevent encoding release /// events for handled bindings. We only need to keep track of one because /// at least at the time of writing this, its impossible for two keys of @@ -793,6 +797,7 @@ pub fn deinit(self: *Surface) void { // Clean up our keyboard state for (self.keyboard.sequence_queued.items) |req| req.deinit(); self.keyboard.sequence_queued.deinit(self.alloc); + self.keyboard.table_stack.deinit(self.alloc); // Clean up our font grid self.app.font_grid_set.deref(self.font_grid_key); From 18ce219d78986d5dedadee88e47f6da7b7a59304 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 14:19:23 -0800 Subject: [PATCH 29/96] input: activate/deactivate key table binding actions --- src/Surface.zig | 43 +++++++++++++++++++++++++++++++++++++++++++ src/input/Binding.zig | 30 ++++++++++++++++++++++++++++++ src/input/command.zig | 4 ++++ 3 files changed, 77 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 77daaf7e9..b4a1048e5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5569,6 +5569,49 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + inline .activate_key_table, + .activate_key_table_once, + => |name, tag| { + // Look up the table in our config + const set = self.config.keybind.tables.getPtr(name) orelse + return false; + + // If this is the same table as is currently active, then + // do nothing. + if (self.keyboard.table_stack.items.len > 0) { + const items = self.keyboard.table_stack.items; + const active = items[items.len - 1]; + if (active == set) return false; + } + + // Add the table to the stack. + try self.keyboard.table_stack.append(self.alloc, set); + + // TODO: once + _ = tag; + }, + + .deactivate_key_table => switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, + + // Final key table active, clear our state. + 1 => self.keyboard.table_stack.clearAndFree(self.alloc), + + // Restore the prior key table. We don't free any memory in + // this case because we assume it will be freed later when + // we finish our key table. + else => _ = self.keyboard.table_stack.pop(), + }, + + .deactivate_all_key_tables => switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, + + // Clear the entire table stack. + else => self.keyboard.table_stack.clearAndFree(self.alloc), + }, + .crash => |location| switch (location) { .main => @panic("crash binding action, crashing intentionally"), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 666852094..983ed98b3 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -799,6 +799,32 @@ pub const Action = union(enum) { /// be undone or redone. redo, + /// Activate a named key table (see `keybind` configuration documentation). + /// The named key table will remain active until `deactivate_key_table` + /// is called. If you want a one-shot key table activation, use the + /// `activate_key_table_once` action instead. + /// + /// If the named key table does not exist, this action has no effect + /// and performable will report false. + /// + /// If the named key table is already the currently active key table, + /// this action has no effect and performable will report false. + activate_key_table: []const u8, + + /// Same as activate_key_table, but the key table will only be active + /// until the first valid keybinding from that table is used (including + /// any defined `catch_all` bindings). + activate_key_table_once: []const u8, + + /// Deactivate the currently active key table, if any. The next most + /// recently activated key table (if any) will become active again. + /// If no key table is active, this action has no effect. + deactivate_key_table, + + /// Deactivate all active key tables. If no active key table exists, + /// this will report performable as false. + deactivate_all_key_tables, + /// Quit Ghostty. quit, @@ -1253,6 +1279,10 @@ pub const Action = union(enum) { .toggle_background_opacity, .show_on_screen_keyboard, .reset_window_size, + .activate_key_table, + .activate_key_table_once, + .deactivate_key_table, + .deactivate_all_key_tables, .crash, => .surface, diff --git a/src/input/command.zig b/src/input/command.zig index 6ac4312a9..67086f7ec 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -671,6 +671,10 @@ fn actionCommands(action: Action.Key) []const Command { .write_scrollback_file, .goto_tab, .resize_split, + .activate_key_table, + .activate_key_table_once, + .deactivate_key_table, + .deactivate_all_key_tables, .crash, => comptime &.{}, From 36f891afd8a63b64b6448277bf6cb59e44d005c0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 14:30:33 -0800 Subject: [PATCH 30/96] implement key table lookup in maybeHandleBinding --- src/Surface.zig | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index b4a1048e5..47cafaf6f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2794,25 +2794,39 @@ fn maybeHandleBinding( // Find an entry in the keybind set that matches our event. const entry: input.Binding.Set.Entry = entry: { - const set = self.keyboard.sequence_set orelse &self.config.keybind.set; + // Handle key sequences first. + if (self.keyboard.sequence_set) |set| { + // Get our entry from the set for the given event. + if (set.getEvent(event)) |v| break :entry v; - // Get our entry from the set for the given event. - if (set.getEvent(event)) |v| break :entry v; + // No entry found. We need to encode everything up to this + // point and send to the pty since we're in a sequence. + // + // We also ignore modifiers so that nested sequences such as + // ctrl+a>ctrl+b>c work. + if (!event.key.modifier()) { + // Encode everything up to this point + self.endKeySequence(.flush, .retain); + } - // No entry found. If we're not looking at the root set of the - // bindings we need to encode everything up to this point and - // send to the pty. - // - // We also ignore modifiers so that nested sequences such as - // ctrl+a>ctrl+b>c work. - if (self.keyboard.sequence_set != null and - !event.key.modifier()) - { - // Encode everything up to this point - self.endKeySequence(.flush, .retain); + return null; } - return null; + // No currently active sequence, move on to tables. For tables, + // we search inner-most table to outer-most. The table stack does + // NOT include the root set. + const table_items = self.keyboard.table_stack.items; + if (table_items.len > 0) { + for (0..table_items.len) |i| { + const rev_i: usize = table_items.len - 1 - i; + const set = table_items[rev_i]; + if (set.getEvent(event)) |v| break :entry v; + } + } + + // No table, use our default set + break :entry self.config.keybind.set.getEvent(event) orelse + return null; }; // Determine if this entry has an action or if its a leader key. From 14bbc4893f84bd02d0f2026d3cd091d1ba29dd07 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 14:36:35 -0800 Subject: [PATCH 31/96] implement one-shot key tables --- src/Surface.zig | 30 ++++++++++++++++++++++-------- src/input/Binding.zig | 5 +++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 47cafaf6f..8c1c31a84 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -267,7 +267,10 @@ pub const Keyboard = struct { /// The stack of tables that is currently active. The first value /// in this is the first activated table (NOT the default keybinding set). - table_stack: std.ArrayListUnmanaged(*const input.Binding.Set) = .empty, + table_stack: std.ArrayListUnmanaged(struct { + set: *const input.Binding.Set, + once: bool, + }) = .empty, /// The last handled binding. This is used to prevent encoding release /// events for handled bindings. We only need to keep track of one because @@ -2819,8 +2822,19 @@ fn maybeHandleBinding( if (table_items.len > 0) { for (0..table_items.len) |i| { const rev_i: usize = table_items.len - 1 - i; - const set = table_items[rev_i]; - if (set.getEvent(event)) |v| break :entry v; + const table = table_items[rev_i]; + if (table.set.getEvent(event)) |v| { + // If this is a one-shot activation AND its the currently + // active table, then we deactivate it after this. + // Note: we may want to change the semantics here to + // remove this table no matter where it is in the stack, + // maybe. + if (table.once and i == 0) _ = try self.performBindingAction( + .deactivate_key_table, + ); + + break :entry v; + } } } @@ -5594,15 +5608,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // do nothing. if (self.keyboard.table_stack.items.len > 0) { const items = self.keyboard.table_stack.items; - const active = items[items.len - 1]; + const active = items[items.len - 1].set; if (active == set) return false; } // Add the table to the stack. - try self.keyboard.table_stack.append(self.alloc, set); - - // TODO: once - _ = tag; + try self.keyboard.table_stack.append(self.alloc, .{ + .set = set, + .once = tag == .activate_key_table_once, + }); }, .deactivate_key_table => switch (self.keyboard.table_stack.items.len) { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 983ed98b3..22a5e8386 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -814,6 +814,11 @@ pub const Action = union(enum) { /// Same as activate_key_table, but the key table will only be active /// until the first valid keybinding from that table is used (including /// any defined `catch_all` bindings). + /// + /// The "once" check is only done if this is the currently active + /// key table. If another key table is activated later, then this + /// table will remain active until it pops back out to being the + /// active key table. activate_key_table_once: []const u8, /// Deactivate the currently active key table, if any. The next most From daa613482e6a7ceacb0e97ee24f96c0ecc3a871e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 14:57:28 -0800 Subject: [PATCH 32/96] keybind = clear and reset should reset tables, too --- src/Surface.zig | 20 ++++++++++++------ src/config/Config.zig | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 8c1c31a84..2254b287c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2569,14 +2569,22 @@ pub fn keyEventIsBinding( .press, .repeat => {}, } - // Our keybinding set is either our current nested set (for - // sequences) or the root set. - const set = self.keyboard.sequence_set orelse &self.config.keybind.set; + // If we're in a sequence, check the sequence set + if (self.keyboard.sequence_set) |set| { + return set.getEvent(event) != null; + } - // log.warn("text keyEventIsBinding event={} match={}", .{ event, set.getEvent(event) != null }); + // Check active key tables (inner-most to outer-most) + const table_items = self.keyboard.table_stack.items; + for (0..table_items.len) |i| { + const rev_i: usize = table_items.len - 1 - i; + if (table_items[rev_i].set.getEvent(event) != null) { + return true; + } + } - // If we have a keybinding for this event then we return true. - return set.getEvent(event) != null; + // Check the root set + return self.config.keybind.set.getEvent(event) != null; } /// Called for any key events. This handles keybindings, encoding and diff --git a/src/config/Config.zig b/src/config/Config.zig index 8d941c733..17b4275b0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5822,6 +5822,7 @@ pub const Keybinds = struct { // allocated value). This isn't a memory leak because the arena // will be freed when the config is freed. self.set = .{}; + self.tables = .empty; // keybinds for opening and reloading config try self.set.put( @@ -6591,6 +6592,7 @@ pub const Keybinds = struct { // will be freed when the config is freed. log.info("config has 'keybind = clear', all keybinds cleared", .{}); self.set = .{}; + self.tables = .empty; return; } @@ -7073,6 +7075,52 @@ pub const Keybinds = struct { try testing.expect(std.mem.indexOf(u8, output, "keybind = shift+b=csi:world\n") != null); try testing.expect(std.mem.indexOf(u8, output, "keybind = foo/shift+a=csi:hello\n") != null); } + + test "parseCLI clear clears tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Add bindings to root set and tables + try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard"); + try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window"); + + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(2, keybinds.tables.count()); + + // Clear all keybinds + try keybinds.parseCLI(alloc, "clear"); + + // Both root set and tables should be cleared + try testing.expectEqual(0, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI reset clears tables" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Add bindings to tables + try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard"); + try keybinds.parseCLI(alloc, "bar/shift+b=paste_from_clipboard"); + + try testing.expectEqual(2, keybinds.tables.count()); + + // Reset to defaults (empty value) + try keybinds.parseCLI(alloc, ""); + + // Tables should be cleared, root set has defaults + try testing.expectEqual(0, keybinds.tables.count()); + try testing.expect(keybinds.set.bindings.count() > 0); + } }; /// See "font-codepoint-map" for documentation. From 845bcdb498da4d2e3552ffb9385ed8c9b7fc5218 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 15:11:08 -0800 Subject: [PATCH 33/96] config: copy key table name into arena --- src/Surface.zig | 11 +++++++++-- src/config/Config.zig | 6 +++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 2254b287c..af7cdf136 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5609,15 +5609,20 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .activate_key_table_once, => |name, tag| { // Look up the table in our config - const set = self.config.keybind.tables.getPtr(name) orelse + const set = self.config.keybind.tables.getPtr(name) orelse { + log.debug("key table not found: {s}", .{name}); return false; + }; // If this is the same table as is currently active, then // do nothing. if (self.keyboard.table_stack.items.len > 0) { const items = self.keyboard.table_stack.items; const active = items[items.len - 1].set; - if (active == set) return false; + if (active == set) { + log.debug("ignoring duplicate activate table: {s}", .{name}); + return false; + } } // Add the table to the stack. @@ -5625,6 +5630,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .set = set, .once = tag == .activate_key_table_once, }); + + log.debug("key table activated: {s}", .{name}); }, .deactivate_key_table => switch (self.keyboard.table_stack.items.len) { diff --git a/src/config/Config.zig b/src/config/Config.zig index 17b4275b0..c0d8e813e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6610,6 +6610,9 @@ pub const Keybinds = struct { // Get or create the table const gop = try self.tables.getOrPut(alloc, table_name); if (!gop.found_existing) { + // We need to copy our table name into the arena + // for valid lookups later. + gop.key_ptr.* = try alloc.dupe(u8, table_name); gop.value_ptr.* = .{}; } @@ -6636,7 +6639,8 @@ pub const Keybinds = struct { try tables.ensureTotalCapacity(alloc, @intCast(self.tables.count())); var it = self.tables.iterator(); while (it.next()) |entry| { - tables.putAssumeCapacity(entry.key_ptr.*, try entry.value_ptr.clone(alloc)); + const key = try alloc.dupe(u8, entry.key_ptr.*); + tables.putAssumeCapacity(key, try entry.value_ptr.clone(alloc)); } return .{ From 1fbdcf1ee76406abec58b19ce8598a4000b06cb2 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 21 Dec 2025 00:15:47 +0000 Subject: [PATCH 34/96] 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 271428778..373c97aba 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-20251201-150531-bfb3ee1/ghostty-themes.tgz", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index c9a64ca5f..9ad3c39da 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -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/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 43a8efe46..9627286dd 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://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz"; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz"; hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 24a2978d6..484e1949b 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -5,6 +5,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 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/ghostty-themes-release-20251201-150531-bfb3ee1.tgz 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 @@ -32,4 +33,3 @@ 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 21f79ec04..3b17b64a6 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -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/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" }, From 44972198aeeaa21e7184de4e4425114c0fdbb870 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 19:47:51 -0800 Subject: [PATCH 35/96] apprt: add action for key table activation/deactivation --- include/ghostty.h | 23 ++++++++++ src/Surface.zig | 68 +++++++++++++++++++++++------ src/apprt/action.zig | 48 ++++++++++++++++++++ src/apprt/gtk/class/application.zig | 1 + 4 files changed, 126 insertions(+), 14 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 736c7546b..48915b179 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -691,6 +691,27 @@ typedef struct { ghostty_input_trigger_s trigger; } ghostty_action_key_sequence_s; +// apprt.action.KeyTable.Tag +typedef enum { + GHOSTTY_KEY_TABLE_ACTIVATE, + GHOSTTY_KEY_TABLE_DEACTIVATE, + GHOSTTY_KEY_TABLE_DEACTIVATE_ALL, +} ghostty_action_key_table_tag_e; + +// apprt.action.KeyTable.CValue +typedef union { + struct { + const char *name; + size_t len; + } activate; +} ghostty_action_key_table_u; + +// apprt.action.KeyTable.C +typedef struct { + ghostty_action_key_table_tag_e tag; + ghostty_action_key_table_u value; +} ghostty_action_key_table_s; + // apprt.action.ColorKind typedef enum { GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1, @@ -836,6 +857,7 @@ typedef enum { GHOSTTY_ACTION_FLOAT_WINDOW, GHOSTTY_ACTION_SECURE_INPUT, GHOSTTY_ACTION_KEY_SEQUENCE, + GHOSTTY_ACTION_KEY_TABLE, GHOSTTY_ACTION_COLOR_CHANGE, GHOSTTY_ACTION_RELOAD_CONFIG, GHOSTTY_ACTION_CONFIG_CHANGE, @@ -881,6 +903,7 @@ typedef union { ghostty_action_float_window_e float_window; ghostty_action_secure_input_e secure_input; ghostty_action_key_sequence_s key_sequence; + ghostty_action_key_table_s key_table; ghostty_action_color_change_s color_change; ghostty_action_reload_config_s reload_config; ghostty_action_config_change_s config_change; diff --git a/src/Surface.zig b/src/Surface.zig index af7cdf136..3a83c704a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5631,28 +5631,68 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .once = tag == .activate_key_table_once, }); + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .{ .activate = name }, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; + log.debug("key table activated: {s}", .{name}); }, - .deactivate_key_table => switch (self.keyboard.table_stack.items.len) { - // No key table active. This does nothing. - 0 => return false, + .deactivate_key_table => { + switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, - // Final key table active, clear our state. - 1 => self.keyboard.table_stack.clearAndFree(self.alloc), + // Final key table active, clear our state. + 1 => self.keyboard.table_stack.clearAndFree(self.alloc), - // Restore the prior key table. We don't free any memory in - // this case because we assume it will be freed later when - // we finish our key table. - else => _ = self.keyboard.table_stack.pop(), + // Restore the prior key table. We don't free any memory in + // this case because we assume it will be freed later when + // we finish our key table. + else => _ = self.keyboard.table_stack.pop(), + } + + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .deactivate, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; }, - .deactivate_all_key_tables => switch (self.keyboard.table_stack.items.len) { - // No key table active. This does nothing. - 0 => return false, + .deactivate_all_key_tables => { + switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, - // Clear the entire table stack. - else => self.keyboard.table_stack.clearAndFree(self.alloc), + // Clear the entire table stack. + else => self.keyboard.table_stack.clearAndFree(self.alloc), + } + + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .deactivate_all, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; }, .crash => |location| switch (location) { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 8e0a9d018..25fc6f08a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -250,6 +250,9 @@ pub const Action = union(Key) { /// key mode because other input may be ignored. key_sequence: KeySequence, + /// A key table has been activated or deactivated. + key_table: KeyTable, + /// A terminal color was changed programmatically through things /// such as OSC 10/11. color_change: ColorChange, @@ -371,6 +374,7 @@ pub const Action = union(Key) { float_window, secure_input, key_sequence, + key_table, color_change, reload_config, config_change, @@ -711,6 +715,50 @@ pub const KeySequence = union(enum) { } }; +pub const KeyTable = union(enum) { + activate: []const u8, + deactivate, + deactivate_all, + + // Sync with: ghostty_action_key_table_tag_e + pub const Tag = enum(c_int) { + activate, + deactivate, + deactivate_all, + }; + + // Sync with: ghostty_action_key_table_u + pub const CValue = extern union { + activate: extern struct { + name: [*]const u8, + len: usize, + }, + }; + + // Sync with: ghostty_action_key_table_s + pub const C = extern struct { + tag: Tag, + value: CValue, + }; + + pub fn cval(self: KeyTable) C { + return switch (self) { + .activate => |name| .{ + .tag = .activate, + .value = .{ .activate = .{ .name = name.ptr, .len = name.len } }, + }, + .deactivate => .{ + .tag = .deactivate, + .value = undefined, + }, + .deactivate_all => .{ + .tag = .deactivate_all, + .value = undefined, + }, + }; + } +}; + pub const ColorChange = extern struct { kind: ColorKind, r: u8, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index be0f3f2c8..1c0863f3c 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -744,6 +744,7 @@ pub const Application = extern struct { .toggle_background_opacity, .cell_size, .key_sequence, + .key_table, .render_inspector, .renderer_health, .color_change, From 901618cd8f944be82b47fad7efb577507d9802a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 19:58:24 -0800 Subject: [PATCH 36/96] macOS: hook up key table apprt action to state --- macos/Sources/Ghostty/Ghostty.Action.swift | 25 +++++++++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 32 +++++++++++++++++-- macos/Sources/Ghostty/Package.swift | 4 +++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 24 ++++++++++++++ macos/Sources/Ghostty/SurfaceView_UIKit.swift | 5 ++- 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 9eb7a8e46..bde3b3d69 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -141,6 +141,31 @@ extension Ghostty.Action { } } } + + enum KeyTable { + case activate(name: String) + case deactivate + case deactivateAll + + init?(c: ghostty_action_key_table_s) { + switch c.tag { + case GHOSTTY_KEY_TABLE_ACTIVATE: + let name = String( + bytesNoCopy: UnsafeMutableRawPointer(mutating: c.value.activate.name), + length: c.value.activate.len, + encoding: .utf8, + freeWhenDone: false + ) ?? "" + self = .activate(name: name) + case GHOSTTY_KEY_TABLE_DEACTIVATE: + self = .deactivate + case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL: + self = .deactivateAll + default: + return 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 3348ab714..4e9166168 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -578,7 +578,10 @@ extension Ghostty { case GHOSTTY_ACTION_KEY_SEQUENCE: keySequence(app, target: target, v: action.action.key_sequence) - + + case GHOSTTY_ACTION_KEY_TABLE: + keyTable(app, target: target, v: action.action.key_table) + case GHOSTTY_ACTION_PROGRESS_REPORT: progressReport(app, target: target, v: action.action.progress_report) @@ -1771,7 +1774,32 @@ extension Ghostty { assertionFailure() } } - + + private static func keyTable( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_key_table_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("key table 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 } + guard let action = Ghostty.Action.KeyTable(c: v) else { return } + + NotificationCenter.default.post( + name: Notification.didChangeKeyTable, + object: surfaceView, + userInfo: [Notification.KeyTableKey: action] + ) + + default: + assertionFailure() + } + } + private static func progressReport( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 375e5c37b..aa62c16f7 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -475,6 +475,10 @@ extension Ghostty.Notification { static let didContinueKeySequence = Notification.Name("com.mitchellh.ghostty.didContinueKeySequence") static let didEndKeySequence = Notification.Name("com.mitchellh.ghostty.didEndKeySequence") static let KeySequenceKey = didContinueKeySequence.rawValue + ".key" + + /// Notifications related to key tables + static let didChangeKeyTable = Notification.Name("com.mitchellh.ghostty.didChangeKeyTable") + static let KeyTableKey = didChangeKeyTable.rawValue + ".action" } // Make the input enum hashable. diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 88a0bb6e8..455249ff4 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -65,6 +65,9 @@ extension Ghostty { // The currently active key sequence. The sequence is not active if this is empty. @Published var keySequence: [KeyboardShortcut] = [] + // The currently active key tables. Empty if no tables are active. + @Published var keyTables: [String] = [] + // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil { didSet { @@ -324,6 +327,11 @@ extension Ghostty { selector: #selector(ghosttyDidEndKeySequence), name: Ghostty.Notification.didEndKeySequence, object: self) + center.addObserver( + self, + selector: #selector(ghosttyDidChangeKeyTable), + name: Ghostty.Notification.didChangeKeyTable, + object: self) center.addObserver( self, selector: #selector(ghosttyConfigDidChange(_:)), @@ -680,6 +688,22 @@ extension Ghostty { } } + @objc private func ghosttyDidChangeKeyTable(notification: SwiftUI.Notification) { + guard let action = notification.userInfo?[Ghostty.Notification.KeyTableKey] as? Ghostty.Action.KeyTable else { return } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + switch action { + case .activate(let name): + self.keyTables.append(name) + case .deactivate: + _ = self.keyTables.popLast() + case .deactivateAll: + self.keyTables.removeAll() + } + } + } + @objc private func ghosttyConfigDidChange(_ notification: SwiftUI.Notification) { // Get our managed configuration object out guard let config = notification.userInfo?[ diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index b2e429455..eb8a60fd9 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -43,7 +43,10 @@ extension Ghostty { // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil - + + // The currently active key tables. Empty if no tables are active. + @Published var keyTables: [String] = [] + /// True when the surface is in readonly mode. @Published private(set) var readonly: Bool = false From eac0ec14fdc89e7bb63831a427670a5ade094380 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 20:11:39 -0800 Subject: [PATCH 37/96] macOS: revamped key table/sequence UI --- macos/Sources/Ghostty/SurfaceView.swift | 257 +++++++++++++++++++++--- 1 file changed, 233 insertions(+), 24 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 49c6a4982..fce50073c 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -123,30 +123,12 @@ extension Ghostty { } } - // 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 { - let padding: CGFloat = 5 - VStack { - Spacer() - - HStack { - Text(verbatim: "Pending Key Sequence:") - ForEach(0.. dragThreshold { + position = .bottom + } + dragOffset = .zero + } + } + ) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyTables) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keySequence.count) + } + + private struct CapsuleSizeKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } + } + + private var indicatorContent: some View { + HStack(alignment: .center, spacing: 8) { + // Key table indicator + if !keyTables.isEmpty { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Image(systemName: "keyboard.badge.ellipsis") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + + // Show table stack with arrows between them + ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in + if index > 0 { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + } + Text(verbatim: table) + .font(.system(size: 13, weight: .medium, design: .rounded)) + } + } + } + + // Separator when both are active + if !keyTables.isEmpty && !keySequence.isEmpty { + Divider() + .frame(height: 14) + } + + // Key sequence indicator + if !keySequence.isEmpty { + HStack(alignment: .center, spacing: 4) { + ForEach(Array(keySequence.enumerated()), id: \.offset) { index, key in + KeyCap(key.description) + } + + // Animated ellipsis to indicate waiting for next key + PendingIndicator() + } + } + } + .frame(height: 18) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background { + Capsule() + .fill(.regularMaterial) + .overlay { + Capsule() + .strokeBorder(Color.primary.opacity(0.15), lineWidth: 1) + } + .shadow(color: .black.opacity(0.2), radius: 8, y: 2) + } + .contentShape(Capsule()) + .backport.pointerStyle(.link) + .popover(isPresented: $isShowingPopover, arrowEdge: position.popoverEdge) { + VStack(alignment: .leading, spacing: 8) { + if !keyTables.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Label("Key Table", systemImage: "keyboard.badge.ellipsis") + .font(.headline) + Text("A key table is a named set of keybindings, activated by some other key. Keys are interpreted using this table until it is deactivated.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + if !keyTables.isEmpty && !keySequence.isEmpty { + Divider() + } + + if !keySequence.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Label("Key Sequence", systemImage: "character.cursor.ibeam") + .font(.headline) + Text("A key sequence is a series of key presses that trigger an action. A pending key sequence is currently active.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .padding() + .frame(maxWidth: 400) + .fixedSize(horizontal: false, vertical: true) + } + .onTapGesture { + isShowingPopover.toggle() + } + } + + /// A small keycap-style view for displaying keyboard shortcuts + struct KeyCap: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(verbatim: text) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(Color(NSColor.controlBackgroundColor)) + .shadow(color: .black.opacity(0.12), radius: 0.5, y: 0.5) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder(Color.primary.opacity(0.15), lineWidth: 0.5) + ) + } + } + + /// Animated dots to indicate waiting for the next key + struct PendingIndicator: View { + @State private var animationPhase: Int = 0 + + var body: some View { + HStack(spacing: 2) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(Color.secondary) + .frame(width: 4, height: 4) + .opacity(dotOpacity(for: index)) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: false)) { + animationPhase = 3 + } + } + } + + private func dotOpacity(for index: Int) -> Double { + let phase = Double(animationPhase) + let offset = Double(index) / 3.0 + let wave = sin((phase + offset) * .pi * 2) + return 0.3 + 0.7 * ((wave + 1) / 2) + } + } + } +#endif + /// Visual overlay that shows a border around the edges when the bell rings with border feature enabled. struct BellBorderOverlay: View { let bell: Bool From dc8f08239235d85be875e03fc67148e8eaab758e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 20 Dec 2025 20:36:35 -0800 Subject: [PATCH 38/96] macos: copy the key table action bytes --- macos/Sources/Ghostty/Ghostty.Action.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index bde3b3d69..91f1491dd 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -150,12 +150,8 @@ extension Ghostty.Action { init?(c: ghostty_action_key_table_s) { switch c.tag { case GHOSTTY_KEY_TABLE_ACTIVATE: - let name = String( - bytesNoCopy: UnsafeMutableRawPointer(mutating: c.value.activate.name), - length: c.value.activate.len, - encoding: .utf8, - freeWhenDone: false - ) ?? "" + let data = Data(bytes: c.value.activate.name, count: c.value.activate.len) + let name = String(data: data, encoding: .utf8) ?? "" self = .activate(name: name) case GHOSTTY_KEY_TABLE_DEACTIVATE: self = .deactivate From 7d3db17396eeda31ec025cec314a42889fa115d4 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:17:01 +0100 Subject: [PATCH 39/96] macOS: key table animations and cleanup --- macos/Sources/Ghostty/SurfaceView.swift | 162 +++++++++++------------- 1 file changed, 76 insertions(+), 86 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index fce50073c..cf4bd37f6 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -124,12 +124,10 @@ extension Ghostty { } // Show key state indicator for active key tables and/or pending key sequences - if !surfaceView.keyTables.isEmpty || !surfaceView.keySequence.isEmpty { - KeyStateIndicator( - keyTables: surfaceView.keyTables, - keySequence: surfaceView.keySequence - ) - } + KeyStateIndicator( + keyTables: surfaceView.keyTables, + keySequence: surfaceView.keySequence + ) #endif // If we have a URL from hovering a link, we show that. @@ -745,7 +743,6 @@ extension Ghostty { @State private var position: Position = .bottom @State private var dragOffset: CGSize = .zero @State private var isDragging = false - @State private var capsuleSize: CGSize = .zero private let padding: CGFloat = 8 @@ -765,82 +762,75 @@ extension Ghostty { case .bottom: return .bottom } } - } - - var body: some View { - GeometryReader { geo in - indicatorContent - .background( - GeometryReader { capsuleGeo in - Color.clear.preference( - key: CapsuleSizeKey.self, - value: capsuleGeo.size - ) - } - ) - .offset(dragOffset) - .padding(padding) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.alignment) - .onPreferenceChange(CapsuleSizeKey.self) { size in - capsuleSize = size - } - .highPriorityGesture( - DragGesture(coordinateSpace: .local) - .onChanged { value in - isDragging = true - dragOffset = CGSize(width: 0, height: value.translation.height) - } - .onEnded { value in - isDragging = false - let dragThreshold: CGFloat = 50 - - withAnimation(.easeOut(duration: 0.2)) { - if position == .bottom && value.translation.height < -dragThreshold { - position = .top - } else if position == .top && value.translation.height > dragThreshold { - position = .bottom - } - dragOffset = .zero - } - } - ) + + var transitionEdge: Edge { + popoverEdge } - .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + var body: some View { + Group { + if !keyTables.isEmpty { + content + // Reset pointer style incase the mouse didn't move away + .backport.pointerStyle(keyTables.isEmpty ? nil : .link) + } + } + .transition(.move(edge: position.transitionEdge).combined(with: .opacity)) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyTables) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: keySequence.count) } - - private struct CapsuleSizeKey: PreferenceKey { - static var defaultValue: CGSize = .zero - static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - value = nextValue() - } + + var content: some View { + indicatorContent + .offset(dragOffset) + .padding(padding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.alignment) + .highPriorityGesture( + DragGesture(coordinateSpace: .local) + .onChanged { value in + isDragging = true + dragOffset = CGSize(width: 0, height: value.translation.height) + } + .onEnded { value in + isDragging = false + let dragThreshold: CGFloat = 50 + + withAnimation(.easeOut(duration: 0.2)) { + if position == .bottom && value.translation.height < -dragThreshold { + position = .top + } else if position == .top && value.translation.height > dragThreshold { + position = .bottom + } + dragOffset = .zero + } + } + ) } - + + @ViewBuilder private var indicatorContent: some View { HStack(alignment: .center, spacing: 8) { // Key table indicator - if !keyTables.isEmpty { - HStack(alignment: .firstTextBaseline, spacing: 5) { - Image(systemName: "keyboard.badge.ellipsis") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - - // Show table stack with arrows between them - ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in - if index > 0 { - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.tertiary) - } - Text(verbatim: table) - .font(.system(size: 13, weight: .medium, design: .rounded)) + HStack(spacing: 5) { + Image(systemName: "keyboard.badge.ellipsis") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + + // Show table stack with arrows between them + ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in + if index > 0 { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) } + Text(verbatim: table) + .font(.system(size: 13, weight: .medium, design: .rounded)) } } - + // Separator when both are active - if !keyTables.isEmpty && !keySequence.isEmpty { + if !keySequence.isEmpty { Divider() .frame(height: 14) } @@ -853,11 +843,10 @@ extension Ghostty { } // Animated ellipsis to indicate waiting for next key - PendingIndicator() + PendingIndicator(paused: isDragging) } } } - .frame(height: 18) .padding(.horizontal, 12) .padding(.vertical, 6) .background { @@ -933,26 +922,27 @@ extension Ghostty { /// Animated dots to indicate waiting for the next key struct PendingIndicator: View { - @State private var animationPhase: Int = 0 - + @State private var animationPhase: Double = 0 + let paused: Bool + var body: some View { - HStack(spacing: 2) { - ForEach(0..<3, id: \.self) { index in - Circle() - .fill(Color.secondary) - .frame(width: 4, height: 4) - .opacity(dotOpacity(for: index)) + TimelineView(.animation(paused: paused)) { context in + HStack(spacing: 2) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(Color.secondary) + .frame(width: 4, height: 4) + .opacity(dotOpacity(for: index)) + } } - } - .onAppear { - withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: false)) { - animationPhase = 3 + .onChange(of: context.date.timeIntervalSinceReferenceDate) { newValue in + animationPhase = newValue } } } private func dotOpacity(for index: Int) -> Double { - let phase = Double(animationPhase) + let phase = animationPhase let offset = Double(index) / 3.0 let wave = sin((phase + offset) * .pi * 2) return 0.3 + 0.7 * ((wave + 1) / 2) From 18c8c338e0a19b17f505ae37736eac481bb1922a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 21 Dec 2025 07:30:58 -0800 Subject: [PATCH 40/96] Reset key tables on config reload, bound max active key tables Two unrelated changes to polish key tables: 1. Key tables should be reset (deactivated) when teh config is reloaded. This matches the behavior of key sequences as well, which are reset on config reload. 2. A maximum number of active key tables is now enforced (8). This prevents a misbehaving config from consuming too much memory by activating too many key tables. This is an arbitrary limit we can adjust later if needed. --- src/Surface.zig | 67 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 3a83c704a..2784f93db 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -49,6 +49,10 @@ const Renderer = rendererpkg.Renderer; const min_window_width_cells: u32 = 10; const min_window_height_cells: u32 = 4; +/// The maximum number of key tables that can be active at any +/// given time. `activate_key_table` calls after this are ignored. +const max_active_key_tables = 8; + /// Allocator alloc: Allocator, @@ -267,6 +271,8 @@ pub const Keyboard = struct { /// The stack of tables that is currently active. The first value /// in this is the first activated table (NOT the default keybinding set). + /// + /// This is bounded by `max_active_key_tables`. table_stack: std.ArrayListUnmanaged(struct { set: *const input.Binding.Set, once: bool, @@ -1737,6 +1743,14 @@ pub fn updateConfig( // If we are in the middle of a key sequence, clear it. self.endKeySequence(.drop, .free); + // Deactivate all key tables since they may have changed. Importantly, + // we store pointers into the config as part of our table stack so + // we can't keep them active across config changes. But this behavior + // also matches key sequences. + _ = self.deactivateAllKeyTables() catch |err| { + log.warn("failed to deactivate key tables err={}", .{err}); + }; + // Before sending any other config changes, we give the renderer a new font // grid. We could check to see if there was an actual change to the font, // but this is easier and pretty rare so it's not a performance concern. @@ -2967,6 +2981,30 @@ fn maybeHandleBinding( return null; } +fn deactivateAllKeyTables(self: *Surface) !bool { + switch (self.keyboard.table_stack.items.len) { + // No key table active. This does nothing. + 0 => return false, + + // Clear the entire table stack. + else => self.keyboard.table_stack.clearAndFree(self.alloc), + } + + // Notify the UI. + _ = self.rt_app.performAction( + .{ .surface = self }, + .key_table, + .deactivate_all, + ) catch |err| { + log.warn( + "failed to notify app of key table err={}", + .{err}, + ); + }; + + return true; +} + const KeySequenceQueued = enum { flush, drop }; const KeySequenceMemory = enum { retain, free }; @@ -5625,6 +5663,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } } + // If we're already at the max, ignore it. + if (self.keyboard.table_stack.items.len >= max_active_key_tables) { + log.info( + "ignoring activate table, max depth reached: {s}", + .{name}, + ); + return false; + } + // Add the table to the stack. try self.keyboard.table_stack.append(self.alloc, .{ .set = set, @@ -5674,25 +5721,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .deactivate_all_key_tables => { - switch (self.keyboard.table_stack.items.len) { - // No key table active. This does nothing. - 0 => return false, - - // Clear the entire table stack. - else => self.keyboard.table_stack.clearAndFree(self.alloc), - } - - // Notify the UI. - _ = self.rt_app.performAction( - .{ .surface = self }, - .key_table, - .deactivate_all, - ) catch |err| { - log.warn( - "failed to notify app of key table err={}", - .{err}, - ); - }; + return try self.deactivateAllKeyTables(); }, .crash => |location| switch (location) { From 9ce04b81b74d069e719a1d982277e3b6ed78a929 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 21 Dec 2025 12:17:20 -0500 Subject: [PATCH 41/96] shell-integration: ensure shell resources exist Our automatic shell integrations require certain resource paths to exist. If they're missing, the launched shell could end up in an inconsistent and unexpected state. For example, we temporarily set ZDOTDIR to our zsh shell integration directory and then restore it from our .zshenv file, but if that script isn't available, the user's shell environment will be broken. The actual runtime logic change was simple: each shell integration routine attempts to open its expected resource path and skips automatic shell integration upon failure. The more complex change was reworking our unit tests to run in a temporary resources directory structure. --- src/termio/shell_integration.zig | 205 ++++++++++++++++++++++++------- 1 file changed, 164 insertions(+), 41 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 71492230e..fc2d4827a 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -114,7 +114,7 @@ fn setupShell( } if (std.mem.eql(u8, "elvish", exe)) { - try setupXdgDataDirs(alloc_arena, resource_dir, env); + if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null; return .{ .shell = .elvish, .command = try command.clone(alloc_arena), @@ -122,7 +122,7 @@ fn setupShell( } if (std.mem.eql(u8, "fish", exe)) { - try setupXdgDataDirs(alloc_arena, resource_dir, env); + if (!try setupXdgDataDirs(alloc_arena, resource_dir, env)) return null; return .{ .shell = .fish, .command = try command.clone(alloc_arena), @@ -130,7 +130,7 @@ fn setupShell( } if (std.mem.eql(u8, "zsh", exe)) { - try setupZsh(resource_dir, env); + if (!try setupZsh(resource_dir, env)) return null; return .{ .shell = .zsh, .command = try command.clone(alloc_arena), @@ -152,9 +152,13 @@ test "force shell" { inline for (@typeInfo(Shell).@"enum".fields) |field| { const shell = @field(Shell, field.name); + + var res: TmpResourcesDir = try .init(alloc, shell); + defer res.deinit(); + const result = try setup( alloc, - ".", + res.path, .{ .shell = "sh" }, &env, shell, @@ -345,13 +349,18 @@ fn setupBash( } // Set our new ENV to point to our integration script. - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const integ_dir = try std.fmt.bufPrint( - &path_buf, + var script_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const script_path = try std.fmt.bufPrint( + &script_path_buf, "{s}/shell-integration/bash/ghostty.bash", .{resource_dir}, ); - try env.put("ENV", integ_dir); + if (std.fs.openFileAbsolute(script_path, .{})) |file| { + file.close(); + try env.put("ENV", script_path); + } else |_| { + return null; + } // Get the command string from the builder, then copy it to the arena // allocator. The stackFallback allocator's memory becomes invalid after @@ -366,14 +375,21 @@ test "bash" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); - const command = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); - + const command = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); 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").?); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/ghostty.bash", .{res.shell_path}), + env.get("ENV").?, + ); } test "bash: unsupported options" { @@ -382,6 +398,9 @@ test "bash: unsupported options" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + const cmdlines = [_][:0]const u8{ "bash --posix", "bash --rcfile script.sh --posix", @@ -394,7 +413,7 @@ test "bash: unsupported options" { var env = EnvMap.init(alloc); defer env.deinit(); - try testing.expect(try setupBash(alloc, .{ .shell = cmdline }, ".", &env) == null); + try testing.expect(try setupBash(alloc, .{ .shell = cmdline }, res.path, &env) == null); try testing.expect(env.get("GHOSTTY_BASH_INJECT") == null); try testing.expect(env.get("GHOSTTY_BASH_RCFILE") == null); try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); @@ -407,13 +426,15 @@ test "bash: inject flags" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + // bash --norc { var env = EnvMap.init(alloc); defer env.deinit(); - const command = try setupBash(alloc, .{ .shell = "bash --norc" }, ".", &env); - + const command = try setupBash(alloc, .{ .shell = "bash --norc" }, res.path, &env); try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?); } @@ -423,8 +444,7 @@ test "bash: inject flags" { var env = EnvMap.init(alloc); defer env.deinit(); - const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, ".", &env); - + const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, res.path, &env); try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?); } @@ -436,19 +456,22 @@ test "bash: rcfile" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); // bash --rcfile { - const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, ".", &env); + const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, res.path, &env); 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); + const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, res.path, &env); try testing.expectEqualStrings("bash --posix", command.?.shell); try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?); } @@ -460,12 +483,15 @@ test "bash: HISTFILE" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + // HISTFILE unset { var env = EnvMap.init(alloc); defer env.deinit(); - _ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); + _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); try testing.expect(std.mem.endsWith(u8, env.get("HISTFILE").?, ".bash_history")); try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE").?); } @@ -477,7 +503,7 @@ test "bash: HISTFILE" { try env.put("HISTFILE", "my_history"); - _ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); + _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); try testing.expectEqualStrings("my_history", env.get("HISTFILE").?); try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); } @@ -489,14 +515,22 @@ test "bash: ENV" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); try env.put("ENV", "env.sh"); - _ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); - try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); + _ = try setupBash(alloc, .{ .shell = "bash" }, res.path, &env); try testing.expectEqualStrings("env.sh", env.get("GHOSTTY_BASH_ENV").?); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/ghostty.bash", .{res.shell_path}), + env.get("ENV").?, + ); } test "bash: additional arguments" { @@ -505,18 +539,21 @@ test "bash: additional arguments" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .bash); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); // "-" argument separator { - const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, ".", &env); + const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, res.path, &env); try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell); } // "--" argument separator { - const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, ".", &env); + const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, res.path, &env); try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell); } } @@ -532,20 +569,22 @@ fn setupXdgDataDirs( alloc_arena: Allocator, resource_dir: []const u8, env: *EnvMap, -) !void { +) !bool { var path_buf: [std.fs.max_path_bytes]u8 = undefined; // Get our path to the shell integration directory. - const integ_dir = try std.fmt.bufPrint( + const integ_path = try std.fmt.bufPrint( &path_buf, "{s}/shell-integration", .{resource_dir}, ); + var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch return false; + integ_dir.close(); // Set an env var so we can remove this from XDG_DATA_DIRS later. // This happens in the shell integration config itself. We do this // so that our modifications don't interfere with other commands. - try env.put("GHOSTTY_SHELL_INTEGRATION_XDG_DIR", integ_dir); + try env.put("GHOSTTY_SHELL_INTEGRATION_XDG_DIR", integ_path); // We attempt to avoid allocating by using the stack up to 4K. // Max stack size is considerably larger on mac @@ -565,9 +604,11 @@ fn setupXdgDataDirs( try internal_os.prependEnv( stack_alloc, env.get(xdg_data_dirs_key) orelse "/usr/local/share:/usr/share", - integ_dir, + integ_path, ), ); + + return true; } test "xdg: empty XDG_DATA_DIRS" { @@ -577,13 +618,23 @@ test "xdg: empty XDG_DATA_DIRS" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .fish); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); - try setupXdgDataDirs(alloc, ".", &env); + try testing.expect(try setupXdgDataDirs(alloc, res.path, &env)); - try testing.expectEqualStrings("./shell-integration", env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?); - try testing.expectEqualStrings("./shell-integration:/usr/local/share:/usr/share", env.get("XDG_DATA_DIRS").?); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}), + env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?, + ); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration:/usr/local/share:/usr/share", .{res.path}), + env.get("XDG_DATA_DIRS").?, + ); } test "xdg: existing XDG_DATA_DIRS" { @@ -593,14 +644,24 @@ test "xdg: existing XDG_DATA_DIRS" { defer arena.deinit(); const alloc = arena.allocator(); + var res: TmpResourcesDir = try .init(alloc, .fish); + defer res.deinit(); + var env = EnvMap.init(alloc); defer env.deinit(); try env.put("XDG_DATA_DIRS", "/opt/share"); - try setupXdgDataDirs(alloc, ".", &env); + try testing.expect(try setupXdgDataDirs(alloc, res.path, &env)); - try testing.expectEqualStrings("./shell-integration", env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?); - try testing.expectEqualStrings("./shell-integration:/opt/share", env.get("XDG_DATA_DIRS").?); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration", .{res.path}), + env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR").?, + ); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&path_buf, "{s}/shell-integration:/opt/share", .{res.path}), + env.get("XDG_DATA_DIRS").?, + ); } /// Setup the zsh automatic shell integration. This works by setting @@ -609,7 +670,7 @@ test "xdg: existing XDG_DATA_DIRS" { fn setupZsh( resource_dir: []const u8, env: *EnvMap, -) !void { +) !bool { // Preserve an existing ZDOTDIR value. We're about to overwrite it. if (env.get("ZDOTDIR")) |old| { try env.put("GHOSTTY_ZSH_ZDOTDIR", old); @@ -617,34 +678,96 @@ fn setupZsh( // 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( + const integ_path = try std.fmt.bufPrint( &path_buf, "{s}/shell-integration/zsh", .{resource_dir}, ); - try env.put("ZDOTDIR", integ_dir); + var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch return false; + integ_dir.close(); + try env.put("ZDOTDIR", integ_path); + + return true; } test "zsh" { const testing = std.testing; + var res: TmpResourcesDir = try .init(testing.allocator, .zsh); + defer res.deinit(); + var env = EnvMap.init(testing.allocator); defer env.deinit(); - try setupZsh(".", &env); - try testing.expectEqualStrings("./shell-integration/zsh", env.get("ZDOTDIR").?); + try testing.expect(try setupZsh(res.path, &env)); + try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?); try testing.expect(env.get("GHOSTTY_ZSH_ZDOTDIR") == null); } test "zsh: ZDOTDIR" { const testing = std.testing; + var res: TmpResourcesDir = try .init(testing.allocator, .zsh); + defer res.deinit(); + var env = EnvMap.init(testing.allocator); defer env.deinit(); try env.put("ZDOTDIR", "$HOME/.config/zsh"); - try setupZsh(".", &env); - try testing.expectEqualStrings("./shell-integration/zsh", env.get("ZDOTDIR").?); + try testing.expect(try setupZsh(res.path, &env)); + try testing.expectEqualStrings(res.shell_path, env.get("ZDOTDIR").?); try testing.expectEqualStrings("$HOME/.config/zsh", env.get("GHOSTTY_ZSH_ZDOTDIR").?); } + +/// Test helper that creates a temporary resources directory with shell integration paths. +const TmpResourcesDir = struct { + allocator: Allocator, + tmp_dir: std.testing.TmpDir, + path: []const u8, + shell_path: []const u8, + + fn init(allocator: std.mem.Allocator, shell: Shell) !TmpResourcesDir { + var tmp_dir = std.testing.tmpDir(.{}); + errdefer tmp_dir.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const relative_shell_path = try std.fmt.bufPrint( + &path_buf, + "shell-integration/{s}", + .{@tagName(shell)}, + ); + try tmp_dir.dir.makePath(relative_shell_path); + + const path = try tmp_dir.dir.realpathAlloc(allocator, "."); + errdefer allocator.free(path); + + const shell_path = try std.fmt.allocPrint( + allocator, + "{s}/{s}", + .{ path, relative_shell_path }, + ); + errdefer allocator.free(shell_path); + + switch (shell) { + .bash => try tmp_dir.dir.writeFile(.{ + .sub_path = "shell-integration/bash/ghostty.bash", + .data = "", + }), + else => {}, + } + + return .{ + .allocator = allocator, + .tmp_dir = tmp_dir, + .path = path, + .shell_path = shell_path, + }; + } + + fn deinit(self: *TmpResourcesDir) void { + self.allocator.free(self.shell_path); + self.allocator.free(self.path); + self.tmp_dir.cleanup(); + } +}; From 97cd4c71d50d3adc5690ee2645af0e08aa5f4b3c Mon Sep 17 00:00:00 2001 From: Henrique Albuquerque <18596542+henrialb@users.noreply.github.com> Date: Sun, 21 Dec 2025 17:57:23 +0000 Subject: [PATCH 42/96] Fix typo --- 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 c0d8e813e..15a1877ff 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3271,7 +3271,7 @@ else /// more subtle border. @"gtk-toolbar-style": GtkToolbarStyle = .raised, -/// The style of the GTK titlbar. Available values are `native` and `tabs`. +/// The style of the GTK titlebar. Available values are `native` and `tabs`. /// /// The `native` titlebar style is a traditional titlebar with a title, a few /// buttons and window controls. A separate tab bar will show up below the From 8a8b06e74dc52cb440df83931004b03b250e1e62 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 21 Dec 2025 13:28:12 -0800 Subject: [PATCH 43/96] config: document key tables for `keybind` --- src/config/Config.zig | 44 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 15a1877ff..8f1cece45 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1666,6 +1666,50 @@ class: ?[:0]const u8 = null, /// /// - Notably, global shortcuts have not been implemented on wlroots-based /// compositors like Sway (see [upstream issue](https://github.com/emersion/xdg-desktop-portal-wlr/issues/240)). +/// +/// You may also create a named set of keybindings known as a "key table." +/// A key table must be explicitly activated for the bindings to become +/// available. This can be used to implement features such as a +/// "copy mode", "vim mode", etc. Generically, this can implement modal +/// keyboard input. +/// +/// Key tables are defined using the syntax `/`. The +/// `` value is everything documented above for keybinds. The +/// `
` value is the name of the key table. Table names can contain +/// anything except `/` and `=`. For example `foo/ctrl+a=new_window` +/// defines a binding within a table named `foo`. +/// +/// Tables are activated and deactivated using the binding actions +/// `activate_key_table:` and `deactivate_key_table`. Other table +/// related binding actions also exist; see the documentation for a full list. +/// These are the primary way to interact with key tables. +/// +/// Binding lookup proceeds from the innermost table outward, so keybinds in +/// the default table remain available unless explicitly unbound in an inner +/// table. +/// +/// A key table has some special syntax and handling: +/// +/// * `/` (with no binding) defines and clears a table, resetting all +/// of its keybinds and settings. +/// +/// * You cannot activate a table that is already the innermost table; such +/// attempts are ignored. However, the same table can appear multiple times +/// in the stack as long as it is not innermost (e.g., `A -> B -> A -> B` +/// is valid, but `A -> B -> B` is not). +/// +/// * A table can be activated in one-shot mode using +/// `activate_key_table_once:`. A one-shot table is automatically +/// deactivated when any non-catch-all binding is invoked. +/// +/// * Key sequences work within tables: `foo/ctrl+a>ctrl+b=new_window`. +/// If an invalid key is pressed, the sequence ends but the table remains +/// active. +/// +/// * Prefixes like `global:` work within tables: +/// `foo/global:ctrl+a=new_window`. +/// +/// Key tables are available since Ghostty 1.3.0. keybind: Keybinds = .{}, /// Horizontal window padding. This applies padding between the terminal cells From 39481453fe8b958a94ec4aeae1a4f885954c6386 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 21 Dec 2025 13:32:23 -0800 Subject: [PATCH 44/96] macos: show the key sequence overlay if no tables are active --- macos/Sources/Ghostty/SurfaceView.swift | 33 +++++++++++++------------ 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index cf4bd37f6..2d5039d29 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -770,10 +770,9 @@ extension Ghostty { var body: some View { Group { - if !keyTables.isEmpty { + if !keyTables.isEmpty || !keySequence.isEmpty { content - // Reset pointer style incase the mouse didn't move away - .backport.pointerStyle(keyTables.isEmpty ? nil : .link) + .backport.pointerStyle(!keyTables.isEmpty ? .link : nil) } } .transition(.move(edge: position.transitionEdge).combined(with: .opacity)) @@ -812,25 +811,27 @@ extension Ghostty { private var indicatorContent: some View { HStack(alignment: .center, spacing: 8) { // Key table indicator - HStack(spacing: 5) { - Image(systemName: "keyboard.badge.ellipsis") - .font(.system(size: 13)) - .foregroundStyle(.secondary) + if !keyTables.isEmpty { + HStack(spacing: 5) { + Image(systemName: "keyboard.badge.ellipsis") + .font(.system(size: 13)) + .foregroundStyle(.secondary) - // Show table stack with arrows between them - ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in - if index > 0 { - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.tertiary) + // Show table stack with arrows between them + ForEach(Array(keyTables.enumerated()), id: \.offset) { index, table in + if index > 0 { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + } + Text(verbatim: table) + .font(.system(size: 13, weight: .medium, design: .rounded)) } - Text(verbatim: table) - .font(.system(size: 13, weight: .medium, design: .rounded)) } } // Separator when both are active - if !keySequence.isEmpty { + if !keyTables.isEmpty && !keySequence.isEmpty { Divider() .frame(height: 14) } From 73fd007a836f1f7423606c94d2c9c935aa3c81f5 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sun, 21 Dec 2025 16:44:43 -0500 Subject: [PATCH 45/96] shell-integration: log warnings for missing paths --- src/termio/shell_integration.zig | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index fc2d4827a..3a541dcae 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -358,7 +358,8 @@ fn setupBash( if (std.fs.openFileAbsolute(script_path, .{})) |file| { file.close(); try env.put("ENV", script_path); - } else |_| { + } else |err| { + log.warn("unable to open {s}: {}", .{ script_path, err }); return null; } @@ -578,7 +579,10 @@ fn setupXdgDataDirs( "{s}/shell-integration", .{resource_dir}, ); - var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch return false; + var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { + log.warn("unable to open {s}: {}", .{ integ_path, err }); + return false; + }; integ_dir.close(); // Set an env var so we can remove this from XDG_DATA_DIRS later. @@ -683,7 +687,10 @@ fn setupZsh( "{s}/shell-integration/zsh", .{resource_dir}, ); - var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch return false; + var integ_dir = std.fs.openDirAbsolute(integ_path, .{}) catch |err| { + log.warn("unable to open {s}: {}", .{ integ_path, err }); + return false; + }; integ_dir.close(); try env.put("ZDOTDIR", integ_path); From d0767a089aa7d81eec523fa3ab1233f401e9d0e4 Mon Sep 17 00:00:00 2001 From: -k Date: Sun, 21 Dec 2025 17:11:34 -0500 Subject: [PATCH 46/96] build: fix `simdutf`/`highway` flags --- pkg/highway/build.zig | 5 +++++ pkg/simdutf/build.zig | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index fd93675e6..04fe70853 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -72,6 +72,11 @@ pub fn build(b: *std.Build) !void { "-fno-sanitize=undefined", "-fno-sanitize-trap=undefined", }); + + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + if (target.result.os.tag != .windows) { try flags.appendSlice(b.allocator, &.{ "-fmath-errno", diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index 0d827c1cc..2b157d1a9 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -32,6 +32,10 @@ pub fn build(b: *std.Build) !void { "-fno-sanitize-trap=undefined", }); + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + lib.addCSourceFiles(.{ .flags = flags.items, .files = &.{ From ab352b5af9694a7cba8e237d0b1b5a507a6e4226 Mon Sep 17 00:00:00 2001 From: Yasu Flores Date: Sun, 21 Dec 2025 20:26:57 -0600 Subject: [PATCH 47/96] macos: Support native actions to move to beginning of document and move to end of document --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 455249ff4..817fca191 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -9,7 +9,7 @@ extension Ghostty { /// The NSView implementation for a terminal surface. class SurfaceView: OSView, ObservableObject, Codable, Identifiable { typealias ID = UUID - + /// Unique ID per surface let id: UUID @@ -44,14 +44,14 @@ extension Ghostty { // The hovered URL string @Published var hoverUrl: String? = nil - + // The progress report (if any) @Published var progressReport: Action.ProgressReport? = nil { didSet { // Cancel any existing timer progressReportTimer?.invalidate() progressReportTimer = nil - + // If we have a new progress report, start a timer to remove it after 15 seconds if progressReport != nil { progressReportTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: false) { [weak self] _ in @@ -101,7 +101,7 @@ extension Ghostty { } } } - + // Cancellable for search state needle changes private var searchNeedleCancellable: AnyCancellable? @@ -219,7 +219,7 @@ extension Ghostty { // A timer to fallback to ghost emoji if no title is set within the grace period private var titleFallbackTimer: Timer? - + // Timer to remove progress report after 15 seconds private var progressReportTimer: Timer? @@ -418,7 +418,7 @@ extension Ghostty { // Remove any notifications associated with this surface let identifiers = Array(self.notificationIdentifiers) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) - + // Cancel progress report timer progressReportTimer?.invalidate() } @@ -555,16 +555,16 @@ extension Ghostty { // Add buttons alert.addButton(withTitle: "OK") alert.addButton(withTitle: "Cancel") - + // Make the text field the first responder so it gets focus alert.window.initialFirstResponder = textField - + let completionHandler: (NSApplication.ModalResponse) -> Void = { [weak self] response in guard let self else { return } - + // Check if the user clicked "OK" guard response == .alertFirstButtonReturn else { return } - + // Get the input text let newTitle = textField.stringValue if newTitle.isEmpty { @@ -988,7 +988,7 @@ extension Ghostty { var x = event.scrollingDeltaX var y = event.scrollingDeltaY let precision = event.hasPreciseScrollingDeltas - + if precision { // We do a 2x speed multiplier. This is subjective, it "feels" better to me. x *= 2; @@ -1350,7 +1350,7 @@ extension Ghostty { var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags) key_ev.composing = composing - + // For text, we only encode UTF8 if we don't have a single control // character. Control characters are encoded by Ghostty itself. // Without this, `ctrl+enter` does the wrong thing. @@ -1509,7 +1509,7 @@ 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" @@ -1517,7 +1517,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func findNext(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:next" @@ -1533,7 +1533,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func findHide(_ sender: Any?) { guard let surface = self.surface else { return } let action = "end_search" @@ -1593,7 +1593,7 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } - + @IBAction func changeTitle(_ sender: Any) { promptTitle() } @@ -1703,7 +1703,7 @@ extension Ghostty { let isUserSetTitle = try container.decodeIfPresent(Bool.self, forKey: .isUserSetTitle) ?? false self.init(app, baseConfig: config, uuid: uuid) - + // Restore the saved title after initialization if let title = savedTitle { self.title = title @@ -1920,6 +1920,16 @@ extension Ghostty.SurfaceView: NSTextInputClient { return } + // Process MacOS native scroll events + switch selector { + case #selector(moveToBeginningOfDocument(_:)): + surfaceModel!.perform(action: "scroll_to_top") + case #selector(moveToEndOfDocument(_:)): + surfaceModel!.perform(action: "scroll_to_bottom") + default: + break + } + print("SEL: \(selector)") } @@ -1960,14 +1970,14 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor { // The "COMBINATION" bit is key: we might get sent a string (we can handle that) // but get requested an image (we can't handle that at the time of writing this), // so we must bubble up. - + // Types we can receive let receivable: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")] - + // Types that we can send. Currently the same as receivable but I'm separating // this out so we can modify this in the future. let sendable: [NSPasteboard.PasteboardType] = receivable - + // The sendable types that require a selection (currently all) let sendableRequiresSelection = sendable @@ -1984,7 +1994,7 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor { return super.validRequestor(forSendType: sendType, returnType: returnType) } } - + return self } @@ -2030,7 +2040,7 @@ 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 @@ -2135,7 +2145,7 @@ extension Ghostty.SurfaceView { override func accessibilitySelectedTextRange() -> NSRange { return selectedRange() } - + /// Returns the currently selected text as a string. /// This allows assistive technologies to read the selected content. override func accessibilitySelectedText() -> String? { @@ -2149,21 +2159,21 @@ extension Ghostty.SurfaceView { let str = String(cString: text.text) return str.isEmpty ? nil : str } - + /// Returns the number of characters in the terminal content. /// This helps assistive technologies understand the size of the content. override func accessibilityNumberOfCharacters() -> Int { let content = cachedScreenContents.get() return content.count } - + /// Returns the visible character range for the terminal. /// For terminals, we typically show all content as visible. override func accessibilityVisibleCharacterRange() -> NSRange { let content = cachedScreenContents.get() return NSRange(location: 0, length: content.count) } - + /// Returns the line number for a given character index. /// This helps assistive technologies navigate by line. override func accessibilityLine(for index: Int) -> Int { @@ -2171,7 +2181,7 @@ extension Ghostty.SurfaceView { let substring = String(content.prefix(index)) return substring.components(separatedBy: .newlines).count - 1 } - + /// Returns a substring for the given range. /// This allows assistive technologies to read specific portions of the content. override func accessibilityString(for range: NSRange) -> String? { @@ -2179,7 +2189,7 @@ extension Ghostty.SurfaceView { guard let swiftRange = Range(range, in: content) else { return nil } return String(content[swiftRange]) } - + /// Returns an attributed string for the given range. /// /// Note: right now this only applies font information. One day it'd be nice to extend @@ -2190,9 +2200,9 @@ extension Ghostty.SurfaceView { override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? { guard let surface = self.surface else { return nil } guard let plainString = accessibilityString(for: range) else { return nil } - + var attributes: [NSAttributedString.Key: Any] = [:] - + // Try to get the font from the surface if let fontRaw = ghostty_surface_quicklook_font(surface) { let font = Unmanaged.fromOpaque(fontRaw) From 2215b731da23013324b242fbb28f00b230441770 Mon Sep 17 00:00:00 2001 From: Yasu Flores Date: Sun, 21 Dec 2025 20:47:56 -0600 Subject: [PATCH 48/96] Address warning and add guard clause --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 817fca191..c54f674a5 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1910,6 +1910,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { /// 1. Prevents an audible NSBeep for unimplemented actions. /// 2. Allows us to properly encode super+key input events that we don't handle override func doCommand(by selector: Selector) { + guard let surfaceModel else { return } // If we are being processed by performKeyEquivalent with a command binding, // we send it back through the event system so it can be encoded. if let lastPerformKeyEvent, @@ -1923,9 +1924,9 @@ extension Ghostty.SurfaceView: NSTextInputClient { // Process MacOS native scroll events switch selector { case #selector(moveToBeginningOfDocument(_:)): - surfaceModel!.perform(action: "scroll_to_top") + _ = surfaceModel.perform(action: "scroll_to_top") case #selector(moveToEndOfDocument(_:)): - surfaceModel!.perform(action: "scroll_to_bottom") + _ = surfaceModel.perform(action: "scroll_to_bottom") default: break } From b4a5ddfef966f8def62864f901b0d9cec608fd82 Mon Sep 17 00:00:00 2001 From: Suyeol Jeon Date: Thu, 18 Dec 2025 17:13:02 +0900 Subject: [PATCH 49/96] macos: apply window position after setting content size When window-width/height is configured, the window size is set via setContentSize in windowDidLoad. However, window-position-x/y was not being applied after this resize, causing the window to appear at an incorrect position. This was a regression introduced in c75bade89 which refactored the default size logic from a computed NSRect property to a DefaultSize enum. The original code called adjustForWindowPosition after calculating the frame, but this was lost during the refactoring. Fixes the issue by calling adjustForWindowPosition after applying contentIntrinsicSize to ensure window position is correctly set. --- macos/Sources/Features/Terminal/TerminalController.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 8a0c5f46d..bccdd9c69 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -952,9 +952,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr 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 } + DispatchQueue.main.asyncAfter(deadline: .now() + .microseconds(10_000)) { [weak self, weak window] in + guard let self, let window else { return } defaultSize.apply(to: window) + if let screen = window.screen ?? NSScreen.main { + let frame = self.adjustForWindowPosition(frame: window.frame, on: screen) + window.setFrameOrigin(frame.origin) + } } } } From 5bd814adf8b2fad4f7d8ca7c05776c8dcb6cd35a Mon Sep 17 00:00:00 2001 From: Yasu Flores Date: Mon, 22 Dec 2025 08:53:43 -0600 Subject: [PATCH 50/96] move guard down to keep surfaceModel logic together --- 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 c54f674a5..37cc9282e 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1910,7 +1910,6 @@ extension Ghostty.SurfaceView: NSTextInputClient { /// 1. Prevents an audible NSBeep for unimplemented actions. /// 2. Allows us to properly encode super+key input events that we don't handle override func doCommand(by selector: Selector) { - guard let surfaceModel else { return } // If we are being processed by performKeyEquivalent with a command binding, // we send it back through the event system so it can be encoded. if let lastPerformKeyEvent, @@ -1921,6 +1920,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { return } + guard let surfaceModel else { return } // Process MacOS native scroll events switch selector { case #selector(moveToBeginningOfDocument(_:)): From 3877ead07133070792b02b79a10aa9e51d560879 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 08:46:57 -0800 Subject: [PATCH 51/96] input: parse chains (don't do anything with them yet) --- src/input/Binding.zig | 59 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 22a5e8386..0bacea87d 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -52,6 +52,7 @@ pub const Parser = struct { trigger_it: SequenceIterator, action: Action, flags: Flags = .{}, + chain: bool, pub const Elem = union(enum) { /// A leader trigger in a sequence. @@ -59,6 +60,12 @@ pub const Parser = struct { /// The final trigger and action in a sequence. binding: Binding, + + /// A chained action `chain=` that should be appended + /// to the previous binding. Note that any action is parsed, including + /// invalid actions for chains such as `unbind`. We expect downstream + /// consumers to validate that the action is valid for chaining. + chain: Action, }; pub fn init(raw_input: []const u8) Error!Parser { @@ -95,12 +102,23 @@ pub const Parser = struct { return Error.InvalidFormat; }; + // Detect chains. Chains must not have flags. + const chain = std.mem.eql(u8, input[0..eql_idx], "chain"); + if (chain and start_idx > 0) return Error.InvalidFormat; + // Sequence iterator goes up to the equal, action is after. We can // parse the action now. return .{ - .trigger_it = .{ .input = input[0..eql_idx] }, + .trigger_it = .{ + // This is kind of hacky but we put a dummy trigger + // for chained inputs. The `next` will never yield this + // because we have chain set. When we find a nicer way to + // do this we can remove it, the e2e is tested. + .input = if (chain) "a" else input[0..eql_idx], + }, .action = try .parse(input[eql_idx + 1 ..]), .flags = flags, + .chain = chain, }; } @@ -156,6 +174,9 @@ pub const Parser = struct { return .{ .leader = trigger }; } + // If we're a chain then return it as-is. + if (self.chain) return .{ .chain = self.action }; + // Out of triggers, yield the final action. return .{ .binding = .{ .trigger = trigger, @@ -191,19 +212,26 @@ const SequenceIterator = struct { /// Returns true if there are no more triggers to parse. pub fn done(self: *const SequenceIterator) bool { - return self.i > self.input.len; + return self.i >= self.input.len; } }; /// Parse a single, non-sequenced binding. To support sequences you must /// use parse. This is a convenience function for single bindings aimed /// primarily at tests. -fn parseSingle(raw_input: []const u8) (Error || error{UnexpectedSequence})!Binding { +/// +/// This doesn't support `chain` either, since chaining requires some +/// stateful concept of a prior binding. +fn parseSingle(raw_input: []const u8) (Error || error{ + UnexpectedChain, + UnexpectedSequence, +})!Binding { var p = try Parser.init(raw_input); const elem = (try p.next()) orelse return Error.InvalidFormat; return switch (elem) { .leader => error.UnexpectedSequence, .binding => elem.binding, + .chain => error.UnexpectedChain, }; } @@ -2155,6 +2183,8 @@ pub const Set = struct { b.flags, ), }, + + .chain => @panic("TODO"), } } @@ -2887,6 +2917,29 @@ test "parse: action with a tuple" { try testing.expectError(Error.InvalidFormat, parseSingle("a=resize_split:up,four")); } +test "parse: chain" { + const testing = std.testing; + + // Valid + { + var p = try Parser.init("chain=new_tab"); + try testing.expectEqual(Parser.Elem{ + .chain = .new_tab, + }, try p.next()); + try testing.expect(try p.next() == null); + } + + // Chain can't have flags + try testing.expectError(error.InvalidFormat, Parser.init("global:chain=ignore")); + + // Chain can't be part of a sequence + { + var p = try Parser.init("a>chain=ignore"); + _ = try p.next(); + try testing.expectError(error.InvalidFormat, p.next()); + } +} + test "sequence iterator" { const testing = std.testing; From 42c21eb16b0afc6d44820968bf4d585bbf9ccb21 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 09:13:05 -0800 Subject: [PATCH 52/96] input: leaf_chained tagged union value --- src/App.zig | 53 +++++++++++++------- src/Surface.zig | 44 ++++++++++++----- src/apprt/embedded.zig | 2 +- src/cli/list_keybinds.zig | 4 +- src/config/Config.zig | 15 ++++++ src/input/Binding.zig | 101 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 184 insertions(+), 35 deletions(-) diff --git a/src/App.zig b/src/App.zig index 99d03399c..00be56f49 100644 --- a/src/App.zig +++ b/src/App.zig @@ -357,15 +357,17 @@ pub fn keyEvent( // Get the keybind entry for this event. We don't support key sequences // so we can look directly in the top-level set. const entry = rt_app.config.keybind.set.getEvent(event) orelse return false; - const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) { + const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { // Sequences aren't supported. Our configuration parser verifies // this for global keybinds but we may still get an entry for // a non-global keybind. .leader => return false, // Leaf entries are good - .leaf => |leaf| leaf, + inline .leaf, .leaf_chained => |leaf| leaf.generic(), }; + const actions: []const input.Binding.Action = leaf.actionsSlice(); + assert(actions.len > 0); // If we aren't focused, then we only process global keybinds. if (!self.focused and !leaf.flags.global) return false; @@ -373,13 +375,7 @@ pub fn keyEvent( // Global keybinds are done using performAll so that they // can target all surfaces too. if (leaf.flags.global) { - self.performAllAction(rt_app, leaf.action) catch |err| { - log.warn("error performing global keybind action action={s} err={}", .{ - @tagName(leaf.action), - err, - }); - }; - + self.performAllChainedAction(rt_app, actions); return true; } @@ -389,14 +385,20 @@ pub fn keyEvent( // If we are focused, then we process keybinds only if they are // app-scoped. Otherwise, we do nothing. Surface-scoped should - // be processed by Surface.keyEvent. - const app_action = leaf.action.scoped(.app) orelse return false; - self.performAction(rt_app, app_action) catch |err| { - log.warn("error performing app keybind action action={s} err={}", .{ - @tagName(app_action), - err, - }); - }; + // be processed by Surface.keyEvent. For chained actions, all + // actions must be app-scoped. + for (actions) |action| if (action.scoped(.app) == null) return false; + for (actions) |action| { + self.performAction( + rt_app, + action.scoped(.app).?, + ) catch |err| { + log.warn("error performing app keybind action action={s} err={}", .{ + @tagName(action), + err, + }); + }; + } return true; } @@ -454,6 +456,23 @@ pub fn performAction( } } +/// Performs a chained action. We will continue executing each action +/// even if there is a failure in a prior action. +pub fn performAllChainedAction( + self: *App, + rt_app: *apprt.App, + actions: []const input.Binding.Action, +) void { + for (actions) |action| { + self.performAllAction(rt_app, action) catch |err| { + log.warn("error performing chained action action={s} err={}", .{ + @tagName(action), + err, + }); + }; + } +} + /// Perform an app-wide binding action. If the action is surface-specific /// then it will be performed on all surfaces. To perform only app-scoped /// actions, use performAction. diff --git a/src/Surface.zig b/src/Surface.zig index 2784f93db..0ce758636 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2866,7 +2866,7 @@ fn maybeHandleBinding( }; // Determine if this entry has an action or if its a leader key. - const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) { + const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { .leader => |set| { // Setup the next set we'll look at. self.keyboard.sequence_set = set; @@ -2893,9 +2893,8 @@ fn maybeHandleBinding( return .consumed; }, - .leaf => |leaf| leaf, + inline .leaf, .leaf_chained => |leaf| leaf.generic(), }; - const action = leaf.action; // consumed determines if the input is consumed or if we continue // encoding the key (if we have a key to encode). @@ -2917,36 +2916,58 @@ fn maybeHandleBinding( // An action also always resets the sequence set. self.keyboard.sequence_set = null; + // Setup our actions + const actions = leaf.actionsSlice(); + // Attempt to perform the action - log.debug("key event binding flags={} action={f}", .{ + log.debug("key event binding flags={} action={any}", .{ leaf.flags, - action, + actions, }); const performed = performed: { // If this is a global or all action, then we perform it on // the app and it applies to every surface. if (leaf.flags.global or leaf.flags.all) { - try self.app.performAllAction(self.rt_app, action); + self.app.performAllChainedAction( + self.rt_app, + actions, + ); // "All" actions are always performed since they are global. break :performed true; } - break :performed try self.performBindingAction(action); + // Perform each action. We are performed if ANY of the chained + // actions perform. + var performed: bool = false; + for (actions) |action| { + if (self.performBindingAction(action)) |_| { + performed = true; + } else |err| { + log.info( + "key binding action failed action={t} err={}", + .{ action, err }, + ); + } + } + + break :performed performed; }; if (performed) { // If we performed an action and it was a closing action, // our "self" pointer is not safe to use anymore so we need to // just exit immediately. - if (closingAction(action)) { + for (actions) |action| if (closingAction(action)) { log.debug("key binding is a closing binding, halting key event processing", .{}); return .closed; - } + }; // If our action was "ignore" then we return the special input // effect of "ignored". - if (action == .ignore) return .ignored; + for (actions) |action| if (action == .ignore) { + return .ignored; + }; } // If we have the performable flag and the action was not performed, @@ -2970,7 +2991,8 @@ fn maybeHandleBinding( // Store our last trigger so we don't encode the release event self.keyboard.last_trigger = event.bindingHash(); - if (insp_ev) |ev| ev.binding = action; + // TODO: Inspector must support chained events + if (insp_ev) |ev| ev.binding = actions[0]; return .consumed; } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index da7a585a5..1cb9231bc 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -155,7 +155,7 @@ pub const App = struct { while (it.next()) |entry| { switch (entry.value_ptr.*) { .leader => {}, - .leaf => |leaf| if (leaf.flags.global) return true, + inline .leaf, .leaf_chained => |leaf| if (leaf.flags.global) return true, } } diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index e463f55b9..fb7ad19ec 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -326,7 +326,6 @@ fn iterateBindings( switch (bind.value_ptr.*) { .leader => |leader| { - // Recursively iterate on the set of bindings for this leader key var n_iter = leader.bindings.iterator(); const sub_bindings, const max_width = try iterateBindings(alloc, &n_iter, win); @@ -353,6 +352,9 @@ fn iterateBindings( .action = leaf.action, }); }, + .leaf_chained => { + // TODO: Show these. + }, } } diff --git a/src/config/Config.zig b/src/config/Config.zig index 8f1cece45..f75944aeb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6749,6 +6749,21 @@ pub const Keybinds = struct { other_leaf, )) return false; }, + + .leaf_chained => { + const self_chain = self_entry.value_ptr.*.leaf_chained; + const other_chain = other_entry.value_ptr.*.leaf_chained; + + if (self_chain.flags != other_chain.flags) return false; + if (self_chain.actions.items.len != other_chain.actions.items.len) return false; + for (self_chain.actions.items, other_chain.actions.items) |a1, a2| { + if (!equalField( + inputpkg.Binding.Action, + a1, + a2, + )) return false; + } + }, } } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0bacea87d..39b6ffcba 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -212,7 +212,7 @@ const SequenceIterator = struct { /// Returns true if there are no more triggers to parse. pub fn done(self: *const SequenceIterator) bool { - return self.i >= self.input.len; + return self.i > self.input.len; } }; @@ -1953,6 +1953,9 @@ pub const Set = struct { /// to take along with the flags that may define binding behavior. leaf: Leaf, + /// A set of actions to take in response to a trigger. + leaf_chained: LeafChained, + /// Implements the formatter for the fmt package. This encodes the /// action back into the format used by parse. pub fn format( @@ -2018,6 +2021,8 @@ pub const Set = struct { buffer.print("={f}", .{leaf.action}) catch return error.OutOfMemory; try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); }, + + .leaf_chained => @panic("TODO"), } } }; @@ -2044,6 +2049,47 @@ pub const Set = struct { std.hash.autoHash(&hasher, self.flags); return hasher.final(); } + + pub fn generic(self: *const Leaf) GenericLeaf { + return .{ + .flags = self.flags, + .actions = .{ .single = .{self.action} }, + }; + } + }; + + /// Leaf node of a set that triggers multiple actions in sequence. + pub const LeafChained = struct { + actions: std.ArrayList(Action), + flags: Flags, + + pub fn deinit(self: *LeafChained, alloc: Allocator) void { + self.actions.deinit(alloc); + } + + pub fn generic(self: *const LeafChained) GenericLeaf { + return .{ + .flags = self.flags, + .actions = .{ .many = self.actions.items }, + }; + } + }; + + /// A generic leaf node that can be used to unify the handling of + /// leaf and leaf_chained. + pub const GenericLeaf = struct { + flags: Flags, + actions: union(enum) { + single: [1]Action, + many: []const Action, + }, + + pub fn actionsSlice(self: *const GenericLeaf) []const Action { + return switch (self.actions) { + .single => |*arr| arr, + .many => |slice| slice, + }; + } }; /// A full key-value entry for the set. @@ -2057,6 +2103,9 @@ pub const Set = struct { s.deinit(alloc); alloc.destroy(s); }, + + .leaf_chained => |*l| l.deinit(alloc), + .leaf => {}, }; @@ -2133,7 +2182,7 @@ pub const Set = struct { error.OutOfMemory => return error.OutOfMemory, }, - .leaf => { + .leaf, .leaf_chained => { // Remove the existing action. Fallthrough as if // we don't have a leader. set.remove(alloc, t); @@ -2163,6 +2212,7 @@ pub const Set = struct { leaf.action, leaf.flags, ) catch {}, + .leaf_chained => @panic("TODO"), }; }, @@ -2184,7 +2234,9 @@ pub const Set = struct { ), }, - .chain => @panic("TODO"), + .chain => { + // TODO: Do this, ignore for now. + }, } } @@ -2236,6 +2288,12 @@ pub const Set = struct { } } }, + + // Chained leaves aren't in the reverse mapping so we just + // clear it out. + .leaf_chained => |*l| { + l.deinit(alloc); + }, }; gop.value_ptr.* = .{ .leaf = .{ @@ -2312,7 +2370,7 @@ pub const Set = struct { } fn removeExact(self: *Set, alloc: Allocator, t: Trigger) void { - const entry = self.bindings.get(t) orelse return; + var entry = self.bindings.get(t) orelse return; _ = self.bindings.remove(t); switch (entry) { @@ -2334,7 +2392,7 @@ pub const Set = struct { var it = self.bindings.iterator(); while (it.next()) |it_entry| { switch (it_entry.value_ptr.*) { - .leader => {}, + .leader, .leaf_chained => {}, .leaf => |leaf_search| { if (leaf_search.action.hash() == action_hash) { self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*); @@ -2348,6 +2406,12 @@ pub const Set = struct { _ = self.reverse.remove(leaf.action); } }, + + // Chained leaves are never in our reverse mapping so no + // cleanup is required. + .leaf_chained => |*l| { + l.deinit(alloc); + }, } } @@ -2366,6 +2430,8 @@ pub const Set = struct { // contain allocated strings). .leaf => |*s| s.* = try s.clone(alloc), + .leaf_chained => @panic("TODO"), + // Must be deep cloned. .leader => |*s| { const ptr = try alloc.create(Set); @@ -3356,6 +3422,31 @@ test "set: consumed state" { try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); } +// test "set: parseAndPut chain" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var s: Set = .{}; +// defer s.deinit(alloc); +// +// try s.parseAndPut(alloc, "a=new_window"); +// try s.parseAndPut(alloc, "chain=new_tab"); +// +// // Creates forward mapping +// { +// const action = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf; +// try testing.expect(action.action == .new_window); +// try testing.expectEqual(Flags{}, action.flags); +// } +// +// // Does not create reverse mapping, because reverse mappings are only for +// // non-chain actions. +// { +// const trigger = s.getTrigger(.new_window); +// try testing.expect(trigger == null); +// } +// } + test "set: getEvent physical" { const testing = std.testing; const alloc = testing.allocator; From 457fededeb6f30b272b81bfe4583987091a0a846 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 10:51:43 -0800 Subject: [PATCH 53/96] input: keep track of chain parent --- src/input/Binding.zig | 323 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 305 insertions(+), 18 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 39b6ffcba..6c91415e4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1943,6 +1943,10 @@ pub const Set = struct { /// integration with GUI toolkits. reverse: ReverseMap = .{}, + /// The chain parent is the information necessary to attach a chained + /// action to the proper location in our mapping. + chain_parent: ?HashMap.Entry = null, + /// The entry type for the forward mapping of trigger to action. pub const Value = union(enum) { /// This key is a leader key in a sequence. You must follow the given @@ -2135,26 +2139,55 @@ pub const Set = struct { // We use recursion so that we can utilize the stack as our state // for cleanup. - self.parseAndPutRecurse(alloc, &it) catch |err| switch (err) { - // If this gets sent up to the root then we've unbound - // all the way up and this put was a success. - error.SequenceUnbind => {}, + const updated_set_ = self.parseAndPutRecurse( + alloc, + &it, + ) catch |err| err: { + switch (err) { + // If this gets sent up to the root then we've unbound + // all the way up and this put was a success. + error.SequenceUnbind => break :err null, - // Unrecoverable - error.OutOfMemory => return error.OutOfMemory, + // If our parser input was too short then the format + // is invalid because we handle all valid cases. + error.UnexpectedEndOfInput => return error.InvalidFormat, + + // Unrecoverable + error.OutOfMemory => return error.OutOfMemory, + } + + // Errors must never fall through. + unreachable; }; + + // If we have an updated set (a binding was added) then we store + // it for our chain parent. If we didn't update a set then we clear + // our chain parent since chaining is no longer valid until a + // valid binding is saved. + if (updated_set_) |updated_set| { + // A successful addition must have recorded a chain parent. + assert(updated_set.chain_parent != null); + if (updated_set != self) self.chain_parent = updated_set.chain_parent; + assert(self.chain_parent != null); + } else { + self.chain_parent = null; + } } const ParseAndPutRecurseError = Allocator.Error || error{ SequenceUnbind, + UnexpectedEndOfInput, }; + /// Returns the set that was ultimately updated if a binding was + /// added. Unbind does not return a set since nothing was added. fn parseAndPutRecurse( set: *Set, alloc: Allocator, it: *Parser, - ) ParseAndPutRecurseError!void { - const elem = (it.next() catch unreachable) orelse return; + ) ParseAndPutRecurseError!?*Set { + const elem = (it.next() catch unreachable) orelse + return error.UnexpectedEndOfInput; switch (elem) { .leader => |t| { // If we have a leader, we need to upsert a set for it. @@ -2177,9 +2210,11 @@ pub const Set = struct { error.SequenceUnbind => if (s.bindings.count() == 0) { set.remove(alloc, t); return error.SequenceUnbind; - }, + } else null, - error.OutOfMemory => return error.OutOfMemory, + error.UnexpectedEndOfInput, + error.OutOfMemory, + => err, }, .leaf, .leaf_chained => { @@ -2199,7 +2234,7 @@ pub const Set = struct { try set.bindings.put(alloc, t, .{ .leader = next }); // Recurse - parseAndPutRecurse(next, alloc, it) catch |err| switch (err) { + return parseAndPutRecurse(next, alloc, it) catch |err| switch (err) { // If our action was to unbind, we restore the old // action if we have it. error.SequenceUnbind => { @@ -2214,9 +2249,13 @@ pub const Set = struct { ) catch {}, .leaf_chained => @panic("TODO"), }; + + return null; }, - error.OutOfMemory => return error.OutOfMemory, + error.UnexpectedEndOfInput, + error.OutOfMemory, + => return err, }; }, @@ -2226,16 +2265,20 @@ pub const Set = struct { return error.SequenceUnbind; }, - else => try set.putFlags( - alloc, - b.trigger, - b.action, - b.flags, - ), + else => { + try set.putFlags( + alloc, + b.trigger, + b.action, + b.flags, + ); + return set; + }, }, .chain => { // TODO: Do this, ignore for now. + return set; }, } } @@ -2267,7 +2310,21 @@ pub const Set = struct { // See the reverse map docs for more information. const track_reverse: bool = !flags.performable; + // No matter what our chained parent becomes invalid because + // getOrPut invalidates pointers. + self.chain_parent = null; + const gop = try self.bindings.getOrPut(alloc, t); + self.chain_parent = .{ + .key_ptr = gop.key_ptr, + .value_ptr = gop.value_ptr, + }; + errdefer { + // If we have any errors we can't trust our values here. And + // we can't restore the old values because they're also invalidated + // by getOrPut so we just disable chaining. + self.chain_parent = null; + } if (gop.found_existing) switch (gop.value_ptr.*) { // If we have a leader we need to clean up the memory @@ -2304,6 +2361,13 @@ pub const Set = struct { if (track_reverse) try self.reverse.put(alloc, action, t); errdefer if (track_reverse) self.reverse.remove(action); + + // Invariant: after successful put, chain_parent must be valid and point + // to the entry we just added/updated. + assert(self.chain_parent != null); + assert(self.chain_parent.?.key_ptr == gop.key_ptr); + assert(self.chain_parent.?.value_ptr == gop.value_ptr); + assert(self.chain_parent.?.value_ptr.* == .leaf); } /// Get a binding for a given trigger. @@ -2370,6 +2434,11 @@ pub const Set = struct { } fn removeExact(self: *Set, alloc: Allocator, t: Trigger) void { + // Removal always resets our chain parent. We could make this + // finer grained but the way it is documented is that chaining + // must happen directly after sets so this works. + self.chain_parent = null; + var entry = self.bindings.get(t) orelse return; _ = self.bindings.remove(t); @@ -3097,6 +3166,15 @@ test "set: parseAndPut typical binding" { const trigger = s.getTrigger(.{ .new_window = {} }).?; try testing.expect(trigger.key.unicode == 'a'); } + + // Sets up the chain parent properly + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } } test "set: parseAndPut unconsumed binding" { @@ -3121,6 +3199,15 @@ test "set: parseAndPut unconsumed binding" { const trigger = s.getTrigger(.{ .new_window = {} }).?; try testing.expect(trigger.key.unicode == 'a'); } + + // Sets up the chain parent properly + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } } test "set: parseAndPut removed binding" { @@ -3139,6 +3226,206 @@ test "set: parseAndPut removed binding" { try testing.expect(s.get(trigger) == null); } try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); + + // Sets up the chain parent properly + try testing.expect(s.chain_parent == null); +} + +test "set: put sets chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + + // chain_parent should be set + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } + + // chain_parent value should be a leaf + try testing.expect(s.chain_parent.?.value_ptr.* == .leaf); +} + +test "set: putFlags sets chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'a' } }, + .{ .new_window = {} }, + .{ .consumed = false }, + ); + + // chain_parent should be set + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("a", buf.written()); + } + + // chain_parent value should be a leaf with correct flags + try testing.expect(s.chain_parent.?.value_ptr.* == .leaf); + try testing.expect(!s.chain_parent.?.value_ptr.*.leaf.flags.consumed); +} + +test "set: sequence sets chain_parent to final leaf" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + + // chain_parent should be set and point to 'b' (the final leaf) + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("b", buf.written()); + } + + // chain_parent value should be a leaf + try testing.expect(s.chain_parent.?.value_ptr.* == .leaf); + try testing.expect(s.chain_parent.?.value_ptr.*.leaf.action == .new_window); +} + +test "set: multiple leaves under leader updates chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + + // After first binding, chain_parent should be 'b' + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("b", buf.written()); + } + + try s.parseAndPut(alloc, "a>c=new_tab"); + + // After second binding, chain_parent should be updated to 'c' + try testing.expect(s.chain_parent != null); + { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try s.chain_parent.?.key_ptr.format(&buf.writer); + try testing.expectEqualStrings("c", buf.written()); + } + try testing.expect(s.chain_parent.?.value_ptr.*.leaf.action == .new_tab); +} + +test "set: sequence unbind clears chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try testing.expect(s.chain_parent != null); + + try s.parseAndPut(alloc, "a>b=unbind"); + + // After unbind, chain_parent should be cleared + try testing.expect(s.chain_parent == null); +} + +test "set: sequence unbind with remaining leaves clears chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try s.parseAndPut(alloc, "a>c=new_tab"); + try s.parseAndPut(alloc, "a>b=unbind"); + + // After unbind, chain_parent should be cleared even though 'c' remains + try testing.expect(s.chain_parent == null); + + // But 'c' should still exist + const a_entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(a_entry.value_ptr.* == .leader); + const inner_set = a_entry.value_ptr.*.leader; + try testing.expect(inner_set.get(.{ .key = .{ .unicode = 'c' } }) != null); +} + +test "set: direct remove clears chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try testing.expect(s.chain_parent != null); + + s.remove(alloc, .{ .key = .{ .unicode = 'a' } }); + + // After removal, chain_parent should be cleared + try testing.expect(s.chain_parent == null); +} + +test "set: invalid format preserves chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + const before_key = s.chain_parent.?.key_ptr; + const before_value = s.chain_parent.?.value_ptr; + + // Try an invalid parse - should fail + try testing.expectError(error.InvalidAction, s.parseAndPut(alloc, "a=invalid_action_xyz")); + + // chain_parent should be unchanged + try testing.expect(s.chain_parent != null); + try testing.expect(s.chain_parent.?.key_ptr == before_key); + try testing.expect(s.chain_parent.?.value_ptr == before_value); +} + +test "set: clone produces null chain_parent" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try testing.expect(s.chain_parent != null); + + var cloned = try s.clone(alloc); + defer cloned.deinit(alloc); + + // Clone should have null chain_parent + try testing.expect(cloned.chain_parent == null); + + // But should have the binding + try testing.expect(cloned.get(.{ .key = .{ .unicode = 'a' } }) != null); } test "set: parseAndPut sequence" { From 4fdc52b920c03035ad3ce9aef042b602d2bd0a0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 12:32:52 -0800 Subject: [PATCH 54/96] input: appendChain --- src/input/Binding.zig | 123 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 6c91415e4..a5bb44b4d 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2370,6 +2370,52 @@ pub const Set = struct { assert(self.chain_parent.?.value_ptr.* == .leaf); } + /// Append a chained action to the prior set action. + /// + /// It is an error if there is no valid prior chain parent. + pub fn appendChain( + self: *Set, + alloc: Allocator, + action: Action, + ) (Allocator.Error || error{NoChainParent})!void { + const parent = self.chain_parent orelse return error.NoChainParent; + switch (parent.value_ptr.*) { + // Leader can never be a chain parent. Verified through various + // assertions and unit tests. + .leader => unreachable, + + // If it is already a chained action, we just append the + // action. Easy! + .leaf_chained => |*leaf| try leaf.actions.append( + alloc, + action, + ), + + // If it is a leaf, we need to convert it to a leaf_chained. + // We also need to be careful to remove any prior reverse + // mappings for this action since chained actions are not + // part of the reverse mapping. + .leaf => |leaf| { + // Setup our failable actions list first. + var actions: std.ArrayList(Action) = .empty; + try actions.ensureTotalCapacity(alloc, 2); + errdefer actions.deinit(alloc); + actions.appendAssumeCapacity(leaf.action); + actions.appendAssumeCapacity(action); + + // Clean up our reverse mapping. We only do this if + // we're the chain parent because only the root set + // maintains reverse mappings. + // TODO + + parent.value_ptr.* = .{ .leaf_chained = .{ + .actions = actions, + .flags = leaf.flags, + } }; + }, + } + } + /// Get a binding for a given trigger. pub fn get(self: Set, t: Trigger) ?Entry { return self.bindings.getEntry(t); @@ -4069,3 +4115,80 @@ test "action: format" { try a.format(&buf.writer); try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.written()); } + +test "set: appendChain with no parent returns error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try testing.expectError(error.NoChainParent, s.appendChain(alloc, .{ .new_tab = {} })); +} + +test "set: appendChain after put converts to leaf_chained" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + + // First appendChain converts leaf to leaf_chained and appends the new action + try s.appendChain(alloc, .{ .new_tab = {} }); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + const chained = entry.value_ptr.*.leaf_chained; + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); +} + +test "set: appendChain after putFlags preserves flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'a' } }, + .{ .new_window = {} }, + .{ .consumed = false }, + ); + try s.appendChain(alloc, .{ .new_tab = {} }); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + const chained = entry.value_ptr.*.leaf_chained; + try testing.expect(!chained.flags.consumed); + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); +} + +test "set: appendChain multiple times" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try s.appendChain(alloc, .{ .new_tab = {} }); + try s.appendChain(alloc, .{ .close_surface = {} }); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + const chained = entry.value_ptr.*.leaf_chained; + try testing.expectEqual(@as(usize, 3), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); + try testing.expect(chained.actions.items[2] == .close_surface); +} From a3373f3c6a3653df70c0bb447b14cf72a181e906 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 12:43:16 -0800 Subject: [PATCH 55/96] input: appendChain reverse mapping --- src/input/Binding.zig | 166 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 136 insertions(+), 30 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a5bb44b4d..b4f519379 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1944,8 +1944,21 @@ pub const Set = struct { reverse: ReverseMap = .{}, /// The chain parent is the information necessary to attach a chained - /// action to the proper location in our mapping. - chain_parent: ?HashMap.Entry = null, + /// action to the proper location in our mapping. It tracks both the + /// entry in the hashmap and the set it belongs to, which is needed + /// to properly update reverse mappings when converting a leaf to + /// a chained action. + chain_parent: ?ChainParent = null, + + /// Information about a chain parent entry, including which set it + /// belongs to. This is needed because reverse mappings are only + /// maintained in the root set, but the chain parent entry may be + /// in a nested set (for leader key sequences). + const ChainParent = struct { + key_ptr: *Trigger, + value_ptr: *Value, + set: *Set, + }; /// The entry type for the forward mapping of trigger to action. pub const Value = union(enum) { @@ -2318,6 +2331,7 @@ pub const Set = struct { self.chain_parent = .{ .key_ptr = gop.key_ptr, .value_ptr = gop.value_ptr, + .set = self, }; errdefer { // If we have any errors we can't trust our values here. And @@ -2403,15 +2417,22 @@ pub const Set = struct { actions.appendAssumeCapacity(leaf.action); actions.appendAssumeCapacity(action); - // Clean up our reverse mapping. We only do this if - // we're the chain parent because only the root set - // maintains reverse mappings. - // TODO - + // Convert to leaf_chained first, before fixing up reverse + // mapping. This is important because fixupReverseForAction + // searches for other bindings with the same action, and we + // don't want to find this entry (which is now chained). parent.value_ptr.* = .{ .leaf_chained = .{ .actions = actions, .flags = leaf.flags, } }; + + // Clean up our reverse mapping. Chained actions are not + // part of the reverse mapping, so we need to fix up the + // reverse map (possibly restoring another trigger for the + // same action). + if (!leaf.flags.performable) { + parent.set.fixupReverseForAction(leaf.action); + } }, } } @@ -2498,29 +2519,7 @@ pub const Set = struct { }, // For an action we need to fix up the reverse mapping. - // Note: we'd LIKE to replace this with the most recent binding but - // our hash map obviously has no concept of ordering so we have to - // choose whatever. Maybe a switch to an array hash map here. - .leaf => |leaf| { - const action_hash = leaf.action.hash(); - - var it = self.bindings.iterator(); - while (it.next()) |it_entry| { - switch (it_entry.value_ptr.*) { - .leader, .leaf_chained => {}, - .leaf => |leaf_search| { - if (leaf_search.action.hash() == action_hash) { - self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*); - break; - } - }, - } - } else { - // No other trigger points to this action so we remove - // the reverse mapping completely. - _ = self.reverse.remove(leaf.action); - } - }, + .leaf => |leaf| self.fixupReverseForAction(leaf.action), // Chained leaves are never in our reverse mapping so no // cleanup is required. @@ -2530,6 +2529,38 @@ pub const Set = struct { } } + /// Fix up the reverse mapping after removing an action. + /// + /// When an action is removed from a binding (either by removal or by + /// converting to a chained action), we need to update the reverse mapping. + /// If another binding has the same action, we update the reverse mapping + /// to point to that binding. Otherwise, we remove the action from the + /// reverse mapping entirely. + /// + /// Note: we'd LIKE to replace this with the most recent binding but + /// our hash map obviously has no concept of ordering so we have to + /// choose whatever. Maybe a switch to an array hash map here. + fn fixupReverseForAction(self: *Set, action: Action) void { + const action_hash = action.hash(); + + var it = self.bindings.iterator(); + while (it.next()) |it_entry| { + switch (it_entry.value_ptr.*) { + .leader, .leaf_chained => {}, + .leaf => |leaf_search| { + if (leaf_search.action.hash() == action_hash) { + self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*); + return; + } + }, + } + } + + // No other trigger points to this action so we remove + // the reverse mapping completely. + _ = self.reverse.remove(action); + } + /// Deep clone the set. pub fn clone(self: *const Set, alloc: Allocator) !Set { var result: Set = .{ @@ -4192,3 +4223,78 @@ test "set: appendChain multiple times" { try testing.expect(chained.actions.items[1] == .new_tab); try testing.expect(chained.actions.items[2] == .close_surface); } + +test "set: appendChain removes reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + + // Verify reverse mapping exists before chaining + try testing.expect(s.getTrigger(.{ .new_window = {} }) != null); + + // Chaining should remove the reverse mapping + try s.appendChain(alloc, .{ .new_tab = {} }); + + // Reverse mapping should be gone since chained actions are not in reverse map + try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); +} + +test "set: appendChain with performable does not affect reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Add a non-performable binding first + try s.put(alloc, .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }); + try testing.expect(s.getTrigger(.{ .new_window = {} }) != null); + + // Add a performable binding (not in reverse map) and chain it + try s.putFlags( + alloc, + .{ .key = .{ .unicode = 'a' } }, + .{ .close_surface = {} }, + .{ .performable = true }, + ); + + // close_surface was performable, so not in reverse map + try testing.expect(s.getTrigger(.{ .close_surface = {} }) == null); + + // Chaining the performable binding should not crash or affect anything + try s.appendChain(alloc, .{ .new_tab = {} }); + + // The non-performable new_window binding should still be in reverse map + try testing.expect(s.getTrigger(.{ .new_window = {} }) != null); +} + +test "set: appendChain restores next valid reverse mapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Add two bindings for the same action + try s.put(alloc, .{ .key = .{ .unicode = 'a' } }, .{ .new_window = {} }); + try s.put(alloc, .{ .key = .{ .unicode = 'b' } }, .{ .new_window = {} }); + + // Reverse mapping should point to 'b' (most recent) + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'b'); + } + + // Chain an action to 'b', which should restore 'a' in the reverse map + try s.appendChain(alloc, .{ .new_tab = {} }); + + // Now reverse mapping should point to 'a' + { + const trigger = s.getTrigger(.{ .new_window = {} }).?; + try testing.expect(trigger.key.unicode == 'a'); + } +} From 67be309e3f43ab91c759368d3d7d4fd396182b94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 12:50:39 -0800 Subject: [PATCH 56/96] input: Trigger.eql --- src/input/Binding.zig | 73 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b4f519379..0d52f23cd 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1870,6 +1870,19 @@ pub const Trigger = struct { return array; } + /// Returns true if two triggers are equal. + pub fn eql(self: Trigger, other: Trigger) bool { + if (self.mods != other.mods) return false; + const self_tag = std.meta.activeTag(self.key); + const other_tag = std.meta.activeTag(other.key); + if (self_tag != other_tag) return false; + return switch (self.key) { + .physical => |v| v == other.key.physical, + .unicode => |v| v == other.key.unicode, + .catch_all => true, + }; + } + /// Convert the trigger to a C API compatible trigger. pub fn cval(self: Trigger) C { return .{ @@ -2974,6 +2987,66 @@ test "parse: all triggers" { } } +test "Trigger: eql" { + const testing = std.testing; + + // Equal physical keys + { + const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + const t2: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + try testing.expect(t1.eql(t2)); + } + + // Different physical keys + { + const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + const t2: Trigger = .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true } }; + try testing.expect(!t1.eql(t2)); + } + + // Different mods + { + const t1: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true } }; + const t2: Trigger = .{ .key = .{ .physical = .arrow_up }, .mods = .{ .shift = true } }; + try testing.expect(!t1.eql(t2)); + } + + // Equal unicode keys + { + const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + const t2: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + try testing.expect(t1.eql(t2)); + } + + // Different unicode keys + { + const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + const t2: Trigger = .{ .key = .{ .unicode = 'b' }, .mods = .{} }; + try testing.expect(!t1.eql(t2)); + } + + // Different key types + { + const t1: Trigger = .{ .key = .{ .unicode = 'a' }, .mods = .{} }; + const t2: Trigger = .{ .key = .{ .physical = .key_a }, .mods = .{} }; + try testing.expect(!t1.eql(t2)); + } + + // catch_all + { + const t1: Trigger = .{ .key = .catch_all, .mods = .{} }; + const t2: Trigger = .{ .key = .catch_all, .mods = .{} }; + try testing.expect(t1.eql(t2)); + } + + // catch_all with different mods + { + const t1: Trigger = .{ .key = .catch_all, .mods = .{} }; + const t2: Trigger = .{ .key = .catch_all, .mods = .{ .alt = true } }; + try testing.expect(!t1.eql(t2)); + } +} + test "parse: modifier aliases" { const testing = std.testing; From 9bf1b9ac711252782aa73d166173570c8fedd44d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 12:54:05 -0800 Subject: [PATCH 57/96] input: cleaner reverse mapping cleanup --- src/input/Binding.zig | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 0d52f23cd..2594cdbc5 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2443,9 +2443,10 @@ pub const Set = struct { // part of the reverse mapping, so we need to fix up the // reverse map (possibly restoring another trigger for the // same action). - if (!leaf.flags.performable) { - parent.set.fixupReverseForAction(leaf.action); - } + parent.set.fixupReverseForAction( + leaf.action, + parent.key_ptr.*, + ); }, } } @@ -2532,7 +2533,10 @@ pub const Set = struct { }, // For an action we need to fix up the reverse mapping. - .leaf => |leaf| self.fixupReverseForAction(leaf.action), + .leaf => |leaf| self.fixupReverseForAction( + leaf.action, + t, + ), // Chained leaves are never in our reverse mapping so no // cleanup is required. @@ -2550,19 +2554,35 @@ pub const Set = struct { /// to point to that binding. Otherwise, we remove the action from the /// reverse mapping entirely. /// + /// The `old` parameter is the trigger that was previously bound to this + /// action. It is used to check if the reverse mapping still points to + /// this trigger; if not, no fixup is needed since the reverse map already + /// points to a different trigger for this action. + /// /// Note: we'd LIKE to replace this with the most recent binding but /// our hash map obviously has no concept of ordering so we have to /// choose whatever. Maybe a switch to an array hash map here. - fn fixupReverseForAction(self: *Set, action: Action) void { - const action_hash = action.hash(); + fn fixupReverseForAction( + self: *Set, + action: Action, + old: Trigger, + ) void { + const entry = self.reverse.getEntry(action) orelse return; + // If our value is not the same as the old trigger, we can + // ignore it because our reverse mapping points somewhere else. + if (!entry.value_ptr.eql(old)) return; + + // It is the same trigger, so let's now go through our bindings + // and try to find another trigger that maps to the same action. + const action_hash = action.hash(); var it = self.bindings.iterator(); while (it.next()) |it_entry| { switch (it_entry.value_ptr.*) { .leader, .leaf_chained => {}, .leaf => |leaf_search| { if (leaf_search.action.hash() == action_hash) { - self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*); + entry.value_ptr.* = it_entry.key_ptr.*; return; } }, From b8fe66a70162712d845b7ff9bd5c989d629fde2c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 12:58:04 -0800 Subject: [PATCH 58/96] input: parseAndPut handles chains --- src/input/Binding.zig | 189 +++++++++++++++++++++++++++++++++++------- 1 file changed, 161 insertions(+), 28 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 2594cdbc5..66ebb49a2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2166,6 +2166,7 @@ pub const Set = struct { // We use recursion so that we can utilize the stack as our state // for cleanup. const updated_set_ = self.parseAndPutRecurse( + self, alloc, &it, ) catch |err| err: { @@ -2178,6 +2179,12 @@ pub const Set = struct { // is invalid because we handle all valid cases. error.UnexpectedEndOfInput => return error.InvalidFormat, + // If we had a chain without a parent then the format is wrong. + error.NoChainParent => return error.InvalidFormat, + + // If we had an invalid action for a chain (e.g. unbind). + error.InvalidChainAction => return error.InvalidFormat, + // Unrecoverable error.OutOfMemory => return error.OutOfMemory, } @@ -2202,12 +2209,15 @@ pub const Set = struct { const ParseAndPutRecurseError = Allocator.Error || error{ SequenceUnbind, + NoChainParent, UnexpectedEndOfInput, + InvalidChainAction, }; /// Returns the set that was ultimately updated if a binding was /// added. Unbind does not return a set since nothing was added. fn parseAndPutRecurse( + root: *Set, set: *Set, alloc: Allocator, it: *Parser, @@ -2225,7 +2235,7 @@ pub const Set = struct { if (old) |entry| switch (entry) { // We have an existing leader for this key already // so recurse into this set. - .leader => |s| return parseAndPutRecurse( + .leader => |s| return root.parseAndPutRecurse( s, alloc, it, @@ -2238,7 +2248,9 @@ pub const Set = struct { return error.SequenceUnbind; } else null, + error.NoChainParent, error.UnexpectedEndOfInput, + error.InvalidChainAction, error.OutOfMemory, => err, }, @@ -2260,7 +2272,7 @@ pub const Set = struct { try set.bindings.put(alloc, t, .{ .leader = next }); // Recurse - return parseAndPutRecurse(next, alloc, it) catch |err| switch (err) { + return root.parseAndPutRecurse(next, alloc, it) catch |err| switch (err) { // If our action was to unbind, we restore the old // action if we have it. error.SequenceUnbind => { @@ -2279,7 +2291,9 @@ pub const Set = struct { return null; }, + error.NoChainParent, error.UnexpectedEndOfInput, + error.InvalidChainAction, error.OutOfMemory, => return err, }; @@ -2302,8 +2316,12 @@ pub const Set = struct { }, }, - .chain => { - // TODO: Do this, ignore for now. + .chain => |action| { + // Chains can only happen on the root. + assert(set == root); + // Unbind is not valid for chains. + if (action == .unbind) return error.InvalidChainAction; + try set.appendChain(alloc, action); return set; }, } @@ -2405,6 +2423,9 @@ pub const Set = struct { alloc: Allocator, action: Action, ) (Allocator.Error || error{NoChainParent})!void { + // Unbind is not a valid chain action; callers must check this. + assert(action != .unbind); + const parent = self.chain_parent orelse return error.NoChainParent; switch (parent.value_ptr.*) { // Leader can never be a chain parent. Verified through various @@ -3879,30 +3900,142 @@ test "set: consumed state" { try testing.expect(s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf.flags.consumed); } -// test "set: parseAndPut chain" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var s: Set = .{}; -// defer s.deinit(alloc); -// -// try s.parseAndPut(alloc, "a=new_window"); -// try s.parseAndPut(alloc, "chain=new_tab"); -// -// // Creates forward mapping -// { -// const action = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*.leaf; -// try testing.expect(action.action == .new_window); -// try testing.expectEqual(Flags{}, action.flags); -// } -// -// // Does not create reverse mapping, because reverse mappings are only for -// // non-chain actions. -// { -// const trigger = s.getTrigger(.new_window); -// try testing.expect(trigger == null); -// } -// } +test "set: parseAndPut chain" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Creates forward mapping as leaf_chained + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); + } + + // Does not create reverse mapping, because reverse mappings are only for + // non-chained actions. + { + try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); + } +} + +test "set: parseAndPut chain without parent is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Chain without a prior binding should fail + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "chain=new_tab")); +} + +test "set: parseAndPut chain multiple times" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + try s.parseAndPut(alloc, "chain=close_surface"); + + // Should have 3 actions chained + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + try testing.expectEqual(@as(usize, 3), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); + try testing.expect(chained.actions.items[2] == .close_surface); + } +} + +test "set: parseAndPut chain preserves flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "unconsumed:a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Should preserve unconsumed flag + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + try testing.expect(!chained.flags.consumed); + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + } +} + +test "set: parseAndPut chain after unbind is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "a=unbind"); + + // Chain after unbind should fail because chain_parent is cleared + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "chain=new_tab")); +} + +test "set: parseAndPut chain on sequence" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a>b=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Navigate to the inner set + const a_entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(a_entry == .leader); + const inner_set = a_entry.leader; + + // Check the chained binding + const b_entry = inner_set.get(.{ .key = .{ .unicode = 'b' } }).?.value_ptr.*; + try testing.expect(b_entry == .leaf_chained); + const chained = b_entry.leaf_chained; + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .new_tab); +} + +test "set: parseAndPut chain with unbind is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + + // chain=unbind is not valid + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "chain=unbind")); + + // Original binding should still exist + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + try testing.expect(entry.leaf.action == .new_window); +} test "set: getEvent physical" { const testing = std.testing; From 442146cf9f8a60f4b4d127199b162df662588971 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:13:34 -0800 Subject: [PATCH 59/96] input: implement leaf_chained clone --- src/input/Binding.zig | 75 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 66ebb49a2..85c3c2942 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2093,6 +2093,21 @@ pub const Set = struct { actions: std.ArrayList(Action), flags: Flags, + pub fn clone( + self: LeafChained, + alloc: Allocator, + ) Allocator.Error!LeafChained { + var cloned_actions = try self.actions.clone(alloc); + errdefer cloned_actions.deinit(alloc); + for (cloned_actions.items) |*action| { + action.* = try action.clone(alloc); + } + return .{ + .actions = cloned_actions, + .flags = self.flags, + }; + } + pub fn deinit(self: *LeafChained, alloc: Allocator) void { self.actions.deinit(alloc); } @@ -2630,7 +2645,7 @@ pub const Set = struct { // contain allocated strings). .leaf => |*s| s.* = try s.clone(alloc), - .leaf_chained => @panic("TODO"), + .leaf_chained => |*s| s.* = try s.clone(alloc), // Must be deep cloned. .leader => |*s| { @@ -3619,6 +3634,64 @@ test "set: clone produces null chain_parent" { try testing.expect(cloned.get(.{ .key = .{ .unicode = 'a' } }) != null); } +test "set: clone with leaf_chained" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding using parseAndPut with chain= + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Verify we have a leaf_chained + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + try testing.expectEqual(@as(usize, 2), entry.value_ptr.leaf_chained.actions.items.len); + + // Clone the set + var cloned = try s.clone(alloc); + defer cloned.deinit(alloc); + + // Verify the cloned set has the leaf_chained with same actions + const cloned_entry = cloned.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(cloned_entry.value_ptr.* == .leaf_chained); + try testing.expectEqual(@as(usize, 2), cloned_entry.value_ptr.leaf_chained.actions.items.len); + try testing.expect(cloned_entry.value_ptr.leaf_chained.actions.items[0] == .new_window); + try testing.expect(cloned_entry.value_ptr.leaf_chained.actions.items[1] == .new_tab); +} + +test "set: clone with leaf_chained containing allocated data" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var s: Set = .{}; + + // Create a chained binding with text actions (which have allocated strings) + try s.parseAndPut(alloc, "a=text:hello"); + try s.parseAndPut(alloc, "chain=text:world"); + + // Verify we have a leaf_chained + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + // Clone the set + const cloned = try s.clone(alloc); + + // Verify the cloned set has independent copies of the text + const cloned_entry = cloned.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(cloned_entry.value_ptr.* == .leaf_chained); + try testing.expectEqualStrings("hello", cloned_entry.value_ptr.leaf_chained.actions.items[0].text); + try testing.expectEqualStrings("world", cloned_entry.value_ptr.leaf_chained.actions.items[1].text); + + // Verify the pointers are different (truly cloned, not shared) + try testing.expect(entry.value_ptr.leaf_chained.actions.items[0].text.ptr != + cloned_entry.value_ptr.leaf_chained.actions.items[0].text.ptr); +} + test "set: parseAndPut sequence" { const testing = std.testing; const alloc = testing.allocator; From 578b4c284b80fed9e07e121ada4e2515d17c8f58 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:19:09 -0800 Subject: [PATCH 60/96] apprt/gtk: handle global actions with chains --- src/apprt/gtk/class/global_shortcuts.zig | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/class/global_shortcuts.zig b/src/apprt/gtk/class/global_shortcuts.zig index 57652916a..718b371fd 100644 --- a/src/apprt/gtk/class/global_shortcuts.zig +++ b/src/apprt/gtk/class/global_shortcuts.zig @@ -169,13 +169,16 @@ pub const GlobalShortcuts = extern struct { var trigger_buf: [1024]u8 = undefined; var it = config.keybind.set.bindings.iterator(); while (it.next()) |entry| { - const leaf = switch (entry.value_ptr.*) { - // Global shortcuts can't have leaders + const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { .leader => continue, - .leaf => |leaf| leaf, + inline .leaf, .leaf_chained => |leaf| leaf.generic(), }; if (!leaf.flags.global) continue; + // We only allow global keybinds that map to exactly a single + // action for now. TODO: remove this restriction + if (leaf.actions.len != 1) continue; + const trigger = if (key.xdgShortcutFromTrigger( &trigger_buf, entry.key_ptr.*, @@ -197,7 +200,7 @@ pub const GlobalShortcuts = extern struct { try priv.map.put( alloc, try alloc.dupeZ(u8, trigger), - leaf.action, + leaf.actions[0], ); } From e4c7d4e059eb46fc98e63f2c7c9291e6354a6463 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:22:38 -0800 Subject: [PATCH 61/96] input: handle unbind cleanup for leaf chains --- src/input/Binding.zig | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 85c3c2942..b02d67019 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2300,7 +2300,25 @@ pub const Set = struct { leaf.action, leaf.flags, ) catch {}, - .leaf_chained => @panic("TODO"), + + .leaf_chained => |leaf| chain: { + // Rebuild our chain + set.putFlags( + alloc, + t, + leaf.actions.items[0], + leaf.flags, + ) catch break :chain; + for (leaf.actions.items[1..]) |action| { + set.appendChain( + alloc, + action, + ) catch { + set.remove(alloc, t); + break :chain; + }; + } + }, }; return null; From 7dd903588b5ddcab6c67256b6590b869b71af9a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:27:40 -0800 Subject: [PATCH 62/96] input: formatter for chained entries --- src/input/Binding.zig | 117 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b02d67019..83c6ef38f 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2052,7 +2052,19 @@ pub const Set = struct { try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); }, - .leaf_chained => @panic("TODO"), + .leaf_chained => |leaf| { + const pos = buffer.end; + for (leaf.actions.items, 0..) |action, i| { + if (i == 0) { + buffer.print("={f}", .{action}) catch return error.OutOfMemory; + } else { + buffer.end = 0; + buffer.print("chain={f}", .{action}) catch return error.OutOfMemory; + } + try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); + buffer.end = pos; + } + }, } } }; @@ -4615,3 +4627,106 @@ test "set: appendChain restores next valid reverse mapping" { try testing.expect(trigger.key.unicode == 'a'); } } + +test "set: formatEntries leaf_chained" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding + try s.parseAndPut(alloc, "a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + + // Verify it's a leaf_chained + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + + // Format the entries + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + // Write the trigger first (as formatEntry in Config.zig does) + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + const expected = + \\keybind = a=new_window + \\keybind = chain=new_tab + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} + +test "set: formatEntries leaf_chained multiple chains" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding with 3 actions + try s.parseAndPut(alloc, "ctrl+a=new_window"); + try s.parseAndPut(alloc, "chain=new_tab"); + try s.parseAndPut(alloc, "chain=close_surface"); + + // Verify it's a leaf_chained with 3 actions + const entry = s.get(.{ .key = .{ .unicode = 'a' }, .mods = .{ .ctrl = true } }).?; + try testing.expect(entry.value_ptr.* == .leaf_chained); + try testing.expectEqual(@as(usize, 3), entry.value_ptr.leaf_chained.actions.items.len); + + // Format the entries + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + const expected = + \\keybind = ctrl+a=new_window + \\keybind = chain=new_tab + \\keybind = chain=close_surface + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} + +test "set: formatEntries leaf_chained with text action" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a chained binding with text actions + try s.parseAndPut(alloc, "a=text:hello"); + try s.parseAndPut(alloc, "chain=text:world"); + + // Format the entries + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + const expected = + \\keybind = a=text:hello + \\keybind = chain=text:world + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} From 99325a3d451bdf5b3bb64b2569633f6f16f2e3c8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:32:12 -0800 Subject: [PATCH 63/96] config: docs for chains --- src/config/Config.zig | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index f75944aeb..a2ce88320 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1667,6 +1667,44 @@ class: ?[:0]const u8 = null, /// - Notably, global shortcuts have not been implemented on wlroots-based /// compositors like Sway (see [upstream issue](https://github.com/emersion/xdg-desktop-portal-wlr/issues/240)). /// +/// ## Chained Actions +/// +/// A keybind can have multiple actions by using the `chain` keyword for +/// subsequent actions. When a keybind is activated, all chained actions are +/// executed in order. The syntax is: +/// +/// ```ini +/// keybind = ctrl+a=new_window +/// keybind = chain=goto_split:left +/// ``` +/// +/// This binds `ctrl+a` to first open a new window, then move focus to the +/// left split. Each `chain` entry appends an action to the most recently +/// defined keybind. You can chain as many actions as you want: +/// +/// ```ini +/// keybind = ctrl+a=new_window +/// keybind = chain=goto_split:left +/// keybind = chain=toggle_fullscreen +/// ``` +/// +/// Chained actions cannot have prefixes like `global:` or `unconsumed:`. +/// The flags from the original keybind apply to the entire chain. +/// +/// Chained actions work with key sequences as well. For example: +/// +/// ```ini +/// keybind = ctrl+a>n=new_window +/// keybind = chain=goto_split:left +/// ```` +/// +/// Chains with key sequences apply to the most recent binding in the +/// sequence. +/// +/// Chained keybinds are available since Ghostty 1.3.0. +/// +/// ## Key Tables +/// /// You may also create a named set of keybindings known as a "key table." /// A key table must be explicitly activated for the bindings to become /// available. This can be used to implement features such as a From 931c6c71f2f522f1ac19563035879a75cdbc12cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 13:38:46 -0800 Subject: [PATCH 64/96] fix up gtk --- src/apprt/gtk/class/global_shortcuts.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/class/global_shortcuts.zig b/src/apprt/gtk/class/global_shortcuts.zig index 718b371fd..cf0f31a6e 100644 --- a/src/apprt/gtk/class/global_shortcuts.zig +++ b/src/apprt/gtk/class/global_shortcuts.zig @@ -177,7 +177,8 @@ pub const GlobalShortcuts = extern struct { // We only allow global keybinds that map to exactly a single // action for now. TODO: remove this restriction - if (leaf.actions.len != 1) continue; + const actions = leaf.actionsSlice(); + if (actions.len != 1) continue; const trigger = if (key.xdgShortcutFromTrigger( &trigger_buf, @@ -200,7 +201,7 @@ pub const GlobalShortcuts = extern struct { try priv.map.put( alloc, try alloc.dupeZ(u8, trigger), - leaf.actions[0], + actions[0], ); } From 76c0bdf559f466c56803e9b2f72a0790cbad07ad Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 22 Dec 2025 17:48:04 -0600 Subject: [PATCH 65/96] input: fix performable bindings --- src/Surface.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 0ce758636..0fb0c034b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2941,8 +2941,8 @@ fn maybeHandleBinding( // actions perform. var performed: bool = false; for (actions) |action| { - if (self.performBindingAction(action)) |_| { - performed = true; + if (self.performBindingAction(action)) |performed_| { + performed = performed or performed_; } else |err| { log.info( "key binding action failed action={t} err={}", From dcbb3fe56fbc67ed5257fd4233bedfd69e5e1a3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 20:34:23 -0800 Subject: [PATCH 66/96] inspector: show chained bindings --- src/Surface.zig | 18 ++++++++++++++---- src/inspector/key.zig | 24 +++++++++++++++++++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 0fb0c034b..ea17c6104 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2941,8 +2941,8 @@ fn maybeHandleBinding( // actions perform. var performed: bool = false; for (actions) |action| { - if (self.performBindingAction(action)) |performed_| { - performed = performed or performed_; + if (self.performBindingAction(action)) |v| { + performed = performed or v; } else |err| { log.info( "key binding action failed action={t} err={}", @@ -2991,8 +2991,18 @@ fn maybeHandleBinding( // Store our last trigger so we don't encode the release event self.keyboard.last_trigger = event.bindingHash(); - // TODO: Inspector must support chained events - if (insp_ev) |ev| ev.binding = actions[0]; + if (insp_ev) |ev| { + ev.binding = self.alloc.dupe( + input.Binding.Action, + actions, + ) catch |err| binding: { + log.warn( + "error allocating binding action for inspector err={}", + .{err}, + ); + break :binding &.{}; + }; + } return .consumed; } diff --git a/src/inspector/key.zig b/src/inspector/key.zig index dbccb47a8..e42e4f23c 100644 --- a/src/inspector/key.zig +++ b/src/inspector/key.zig @@ -13,7 +13,8 @@ pub const Event = struct { event: input.KeyEvent, /// The binding that was triggered as a result of this event. - binding: ?input.Binding.Action = null, + /// Multiple bindings are possible if they are chained. + binding: []const input.Binding.Action = &.{}, /// The data sent to the pty as a result of this keyboard event. /// This is allocated using the inspector allocator. @@ -32,6 +33,7 @@ pub const Event = struct { } pub fn deinit(self: *const Event, alloc: Allocator) void { + alloc.free(self.binding); if (self.event.utf8.len > 0) alloc.free(self.event.utf8); if (self.pty.len > 0) alloc.free(self.pty); } @@ -79,12 +81,28 @@ pub const Event = struct { ); defer cimgui.c.igEndTable(); - if (self.binding) |binding| { + if (self.binding.len > 0) { cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0); _ = cimgui.c.igTableSetColumnIndex(0); cimgui.c.igText("Triggered Binding"); _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%s", @tagName(binding).ptr); + + const height: f32 = height: { + const item_count: f32 = @floatFromInt(@min(self.binding.len, 5)); + const padding = cimgui.c.igGetStyle().*.FramePadding.y * 2; + break :height cimgui.c.igGetTextLineHeightWithSpacing() * item_count + padding; + }; + if (cimgui.c.igBeginListBox("##bindings", .{ .x = 0, .y = height })) { + defer cimgui.c.igEndListBox(); + for (self.binding) |action| { + _ = cimgui.c.igSelectable_Bool( + @tagName(action).ptr, + false, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + ); + } + } } pty: { From c11febd0dd8d31e20c70227e0e1af6e212181b1b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 20:42:50 -0800 Subject: [PATCH 67/96] cli/list-keybinds: support chained keybindings --- src/cli/list_keybinds.zig | 54 +++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index fb7ad19ec..2fb900e48 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -96,7 +96,7 @@ const TriggerNode = struct { const ChordBinding = struct { triggers: std.SinglyLinkedList, - action: Binding.Action, + actions: []const Binding.Action, // Order keybinds based on various properties // 1. Longest chord sequence @@ -281,16 +281,32 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { } } - const action = try std.fmt.allocPrint(alloc, "{f}", .{bind.action}); - // If our action has an argument, we print the argument in a different color - if (std.mem.indexOfScalar(u8, action, ':')) |idx| { - _ = win.print(&.{ - .{ .text = action[0..idx] }, - .{ .text = action[idx .. idx + 1], .style = .{ .dim = true } }, - .{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } }, - }, .{ .col_offset = widest_chord + 3 }); - } else { - _ = win.printSegment(.{ .text = action }, .{ .col_offset = widest_chord + 3 }); + var action_col: u16 = widest_chord + 3; + for (bind.actions, 0..) |act, i| { + if (i > 0) { + const chain_result = win.printSegment( + .{ .text = ", ", .style = .{ .dim = true } }, + .{ .col_offset = action_col }, + ); + action_col = chain_result.col; + } + + const action = try std.fmt.allocPrint(alloc, "{f}", .{act}); + // If our action has an argument, we print the argument in a different color + if (std.mem.indexOfScalar(u8, action, ':')) |idx| { + const print_result = win.print(&.{ + .{ .text = action[0..idx] }, + .{ .text = action[idx .. idx + 1], .style = .{ .dim = true } }, + .{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } }, + }, .{ .col_offset = action_col }); + action_col = print_result.col; + } else { + const print_result = win.printSegment( + .{ .text = action }, + .{ .col_offset = action_col }, + ); + action_col = print_result.col; + } } try vx.prettyPrint(writer); } @@ -346,14 +362,24 @@ fn iterateBindings( const node = try alloc.create(TriggerNode); node.* = .{ .data = bind.key_ptr.* }; + const actions = try alloc.alloc(Binding.Action, 1); + actions[0] = leaf.action; + widest_chord = @max(widest_chord, width); try bindings.append(alloc, .{ .triggers = .{ .first = &node.node }, - .action = leaf.action, + .actions = actions, }); }, - .leaf_chained => { - // TODO: Show these. + .leaf_chained => |leaf| { + const node = try alloc.create(TriggerNode); + node.* = .{ .data = bind.key_ptr.* }; + + widest_chord = @max(widest_chord, width); + try bindings.append(alloc, .{ + .triggers = .{ .first = &node.node }, + .actions = leaf.actions.items, + }); }, } } From 56f5a14dde230907e069b15955aacefd5a85a354 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 20:57:06 -0800 Subject: [PATCH 68/96] config: improve key table parsing robustness Fixes #10020 This improves parsing key tables so that the following edge cases are now handled correctly, which were regressions from prior tip behavior: - `/=action` - `ctrl+/=action` - `table//=action` (valid to bind `/` in a table) - `table/a>//=action` (valid to bind a table with a sequence) --- src/config/Config.zig | 122 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 6 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 8f1cece45..52141293a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1676,8 +1676,10 @@ class: ?[:0]const u8 = null, /// Key tables are defined using the syntax `
/`. The /// `` value is everything documented above for keybinds. The /// `
` value is the name of the key table. Table names can contain -/// anything except `/` and `=`. For example `foo/ctrl+a=new_window` -/// defines a binding within a table named `foo`. +/// anything except `/`, `=`, `+`, and `>`. The characters `+` and `>` are +/// reserved for keybind syntax (modifier combinations and key sequences). +/// For example `foo/ctrl+a=new_window` defines a binding within a table +/// named `foo`. /// /// Tables are activated and deactivated using the binding actions /// `activate_key_table:` and `deactivate_key_table`. Other table @@ -6644,12 +6646,21 @@ pub const Keybinds = struct { // We look for '/' only before the first '=' to avoid matching // action arguments like "foo=text:/hello". const eq_idx = std.mem.indexOfScalar(u8, value, '=') orelse value.len; - if (std.mem.indexOfScalar(u8, value[0..eq_idx], '/')) |slash_idx| { + if (std.mem.indexOfScalar(u8, value[0..eq_idx], '/')) |slash_idx| table: { const table_name = value[0..slash_idx]; - const binding = value[slash_idx + 1 ..]; - // Table name cannot be empty - if (table_name.len == 0) return error.InvalidFormat; + // Length zero is valid, so you can set `/=action` for the slash key + if (table_name.len == 0) break :table; + + // Ignore '+', '>' because they can be part of sequences and + // triggers. This lets things like `ctrl+/=action` work. + if (std.mem.indexOfAny( + u8, + table_name, + "+>", + ) != null) break :table; + + const binding = value[slash_idx + 1 ..]; // Get or create the table const gop = try self.tables.getOrPut(alloc, table_name); @@ -7002,6 +7013,105 @@ pub const Keybinds = struct { try testing.expectEqual(0, keybinds.tables.count()); } + test "parseCLI slash as key with modifier is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // ctrl+/ should be parsed as a keybind with '/' as the key, not a table + try keybinds.parseCLI(alloc, "ctrl+/=text:foo"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI shift+slash as key is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // shift+/ should be parsed as a keybind, not a table + try keybinds.parseCLI(alloc, "shift+/=ignore"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI bare slash as key is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Bare / as a key should work (empty table name is rejected) + try keybinds.parseCLI(alloc, "/=text:foo"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI slash in key sequence is not a table" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Key sequence ending with / should work + try keybinds.parseCLI(alloc, "ctrl+a>ctrl+/=new_window"); + + // Should be in root set, not a table + try testing.expectEqual(1, keybinds.set.bindings.count()); + try testing.expectEqual(0, keybinds.tables.count()); + } + + test "parseCLI table with slash in binding" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Table with a binding that uses / as the key + try keybinds.parseCLI(alloc, "mytable//=text:foo"); + + // Should be in the table + try testing.expectEqual(0, keybinds.set.bindings.count()); + try testing.expectEqual(1, keybinds.tables.count()); + try testing.expect(keybinds.tables.contains("mytable")); + try testing.expectEqual(1, keybinds.tables.get("mytable").?.bindings.count()); + } + + test "parseCLI table with sequence containing slash" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + // Table with a key sequence that ends with / + try keybinds.parseCLI(alloc, "mytable/a>/=new_window"); + + // Should be in the table + try testing.expectEqual(0, keybinds.set.bindings.count()); + try testing.expectEqual(1, keybinds.tables.count()); + try testing.expect(keybinds.tables.contains("mytable")); + } + test "clone with tables" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); From 6720076c952123e96578f1eec93111badd2a1b8c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 22 Dec 2025 21:18:59 -0800 Subject: [PATCH 69/96] ci: update macOS builds to use Xcode 26.2 We were fixed on 26.0 previously. --- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 6 +++--- .github/workflows/test.yml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 748965513..960ff4efe 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -143,7 +143,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index fb6aef87d..3af59e7a5 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -232,7 +232,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version @@ -466,7 +466,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version @@ -650,7 +650,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: XCode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30f34120a..b91555f2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -456,7 +456,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version @@ -499,7 +499,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version @@ -764,7 +764,7 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_26.0.app + run: sudo xcode-select -s /Applications/Xcode_26.2.app - name: Xcode Version run: xcodebuild -version From 12815f7fa32460c6168d65209f8da7a252a855b2 Mon Sep 17 00:00:00 2001 From: kadekillary Date: Tue, 23 Dec 2025 08:19:45 -0600 Subject: [PATCH 70/96] feat(cli): list keybindings from key tables - Display keybindings grouped by their source table, with table name as prefix - Sort default bindings before table bindings, maintaining visual hierarchy - Support keybindings defined in key tables alongside default bindings - Enable users to discover all available keybindings across the entire config --- src/cli/list_keybinds.zig | 61 ++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index 2fb900e48..61050d0cb 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -95,18 +95,35 @@ const TriggerNode = struct { }; const ChordBinding = struct { + table_name: ?[]const u8 = null, triggers: std.SinglyLinkedList, actions: []const Binding.Action, // Order keybinds based on various properties - // 1. Longest chord sequence - // 2. Most active modifiers - // 3. Alphabetically by active modifiers - // 4. Trigger key order + // 1. Default bindings before table bindings (tables grouped at end) + // 2. Longest chord sequence + // 3. Most active modifiers + // 4. Alphabetically by active modifiers + // 5. Trigger key order + // 6. Within tables, sort by table name // These properties propagate through chorded keypresses // // Adapted from Binding.lessThan pub fn lessThan(_: void, lhs: ChordBinding, rhs: ChordBinding) bool { + const lhs_has_table = lhs.table_name != null; + const rhs_has_table = rhs.table_name != null; + + if (lhs_has_table != rhs_has_table) { + return !lhs_has_table; + } + + if (lhs_has_table) { + const table_cmp = std.mem.order(u8, lhs.table_name.?, rhs.table_name.?); + if (table_cmp != .eq) { + return table_cmp == .lt; + } + } + const lhs_len = lhs.triggers.len(); const rhs_len = rhs.triggers.len(); @@ -231,10 +248,30 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { const win = vx.window(); - // Generate a list of bindings, recursively traversing chorded keybindings + // Collect default bindings, recursively flattening chords var iter = keybinds.set.bindings.iterator(); - const bindings, const widest_chord = try iterateBindings(alloc, &iter, &win); + const default_bindings, var widest_chord = try iterateBindings(alloc, &iter, &win); + var bindings_list: std.ArrayList(ChordBinding) = .empty; + try bindings_list.appendSlice(alloc, default_bindings); + + // Collect key table bindings + var widest_table_prefix: u16 = 0; + var table_iter = keybinds.tables.iterator(); + while (table_iter.next()) |table_entry| { + const table_name = table_entry.key_ptr.*; + var binding_iter = table_entry.value_ptr.bindings.iterator(); + const table_bindings, const table_width = try iterateBindings(alloc, &binding_iter, &win); + for (table_bindings) |*b| { + b.table_name = table_name; + } + + try bindings_list.appendSlice(alloc, table_bindings); + widest_chord = @max(widest_chord, table_width); + widest_table_prefix = @max(widest_table_prefix, @as(u16, @intCast(win.gwidth(table_name) + win.gwidth("/")))); + } + + const bindings = bindings_list.items; std.mem.sort(ChordBinding, bindings, {}, ChordBinding.lessThan); // Set up styles for each modifier @@ -242,12 +279,22 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { const ctrl_style: vaxis.Style = .{ .fg = .{ .index = 2 } }; const alt_style: vaxis.Style = .{ .fg = .{ .index = 3 } }; const shift_style: vaxis.Style = .{ .fg = .{ .index = 4 } }; + const table_style: vaxis.Style = .{ .fg = .{ .index = 8 } }; // Print the list for (bindings) |bind| { win.clear(); var result: vaxis.Window.PrintResult = .{ .col = 0, .row = 0, .overflow = false }; + + if (bind.table_name) |name| { + result = win.printSegment( + .{ .text = name, .style = table_style }, + .{ .col_offset = result.col }, + ); + result = win.printSegment(.{ .text = "/", .style = table_style }, .{ .col_offset = result.col }); + } + var maybe_trigger = bind.triggers.first; while (maybe_trigger) |node| : (maybe_trigger = node.next) { const trigger: *TriggerNode = .get(node); @@ -281,7 +328,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { } } - var action_col: u16 = widest_chord + 3; + var action_col: u16 = widest_table_prefix + widest_chord + 3; for (bind.actions, 0..) |act, i| { if (i > 0) { const chain_result = win.printSegment( From a1ee2f07648aea90a9df3d50c0a5341f1624d741 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 09:06:46 -0800 Subject: [PATCH 71/96] apprt/gtk: store key sequences/tables in surface state --- src/apprt/gtk.zig | 1 + src/apprt/gtk/class/application.zig | 35 +++++++- src/apprt/gtk/class/surface.zig | 72 ++++++++++++++++ src/apprt/gtk/key.zig | 122 ++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 415d3773d..07b4eb0e7 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -10,4 +10,5 @@ pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef; test { @import("std").testing.refAllDecls(@This()); _ = @import("gtk/ext.zig"); + _ = @import("gtk/key.zig"); } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 1c0863f3c..b16bce049 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -669,6 +669,9 @@ pub const Application = extern struct { .inspector => return Action.controlInspector(target, value), + .key_sequence => return Action.keySequence(target, value), + .key_table => return Action.keyTable(target, value), + .mouse_over_link => Action.mouseOverLink(target, value), .mouse_shape => Action.mouseShape(target, value), .mouse_visibility => Action.mouseVisibility(target, value), @@ -743,8 +746,6 @@ pub const Application = extern struct { .toggle_visibility, .toggle_background_opacity, .cell_size, - .key_sequence, - .key_table, .render_inspector, .renderer_health, .color_change, @@ -2660,6 +2661,36 @@ const Action = struct { }, } } + + pub fn keySequence(target: apprt.Target, value: apprt.Action.Value(.key_sequence)) bool { + switch (target) { + .app => { + log.warn("key_sequence action to app is unexpected", .{}); + return false; + }, + .surface => |core| { + core.rt_surface.gobj().keySequenceAction(value) catch |err| { + log.warn("error handling key_sequence action: {}", .{err}); + }; + return true; + }, + } + } + + pub fn keyTable(target: apprt.Target, value: apprt.Action.Value(.key_table)) bool { + switch (target) { + .app => { + log.warn("key_table action to app is unexpected", .{}); + return false; + }, + .surface => |core| { + core.rt_surface.gobj().keyTableAction(value) catch |err| { + log.warn("error handling key_table action: {}", .{err}); + }; + return true; + }, + } + } }; /// This sets various GTK-related environment variables as necessary diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 93d1beeb2..c35c78302 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -617,6 +617,10 @@ pub const Surface = extern struct { vscroll_policy: gtk.ScrollablePolicy = .natural, vadj_signal_group: ?*gobject.SignalGroup = null, + // Key state tracking for key sequences and tables + key_sequence: std.ArrayListUnmanaged([:0]const u8) = .empty, + key_tables: std.ArrayListUnmanaged([:0]const u8) = .empty, + // Template binds child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, @@ -778,6 +782,66 @@ pub const Surface = extern struct { if (priv.inspector) |v| v.queueRender(); } + /// Handle a key sequence action from the apprt. + pub fn keySequenceAction( + self: *Self, + value: apprt.action.KeySequence, + ) Allocator.Error!void { + const priv = self.private(); + const alloc = Application.default().allocator(); + + switch (value) { + .trigger => |trigger| { + // Convert the trigger to a human-readable label + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + if (gtk_key.labelFromTrigger(&buf.writer, trigger)) |success| { + if (!success) return; + } else |_| return error.OutOfMemory; + + // Make space + try priv.key_sequence.ensureUnusedCapacity(alloc, 1); + + // Copy and append + const duped = try buf.toOwnedSliceSentinel(0); + errdefer alloc.free(duped); + priv.key_sequence.appendAssumeCapacity(duped); + }, + .end => { + // Free all the stored strings and clear + for (priv.key_sequence.items) |s| alloc.free(s); + priv.key_sequence.clearAndFree(alloc); + }, + } + } + + /// Handle a key table action from the apprt. + pub fn keyTableAction( + self: *Self, + value: apprt.action.KeyTable, + ) Allocator.Error!void { + const priv = self.private(); + const alloc = Application.default().allocator(); + + switch (value) { + .activate => |name| { + // Duplicate the name string and push onto stack + const duped = try alloc.dupeZ(u8, name); + errdefer alloc.free(duped); + try priv.key_tables.append(alloc, duped); + }, + .deactivate => { + // Pop and free the top table + if (priv.key_tables.pop()) |s| alloc.free(s); + }, + .deactivate_all => { + // Free all tables and clear + for (priv.key_tables.items) |s| alloc.free(s); + priv.key_tables.clearAndFree(alloc); + }, + } + } + pub fn showOnScreenKeyboard(self: *Self, event: ?*gdk.Event) bool { const priv = self.private(); return priv.im_context.as(gtk.IMContext).activateOsk(event) != 0; @@ -1787,6 +1851,14 @@ pub const Surface = extern struct { glib.free(@ptrCast(@constCast(v))); priv.title_override = null; } + + // Clean up key sequence and key table state + const alloc = Application.default().allocator(); + for (priv.key_sequence.items) |s| alloc.free(s); + priv.key_sequence.deinit(alloc); + for (priv.key_tables.items) |s| alloc.free(s); + priv.key_tables.deinit(alloc); + self.clearCgroup(); gobject.Object.virtual_methods.finalize.call( diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 35c9390b2..5f717e14a 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -233,6 +233,70 @@ pub fn keyvalFromKey(key: input.Key) ?c_uint { } } +/// Converts a trigger to a human-readable label for display in UI. +/// +/// Uses GTK accelerator-style formatting (e.g., "Ctrl+Shift+A"). +/// Returns false if the trigger cannot be formatted (e.g., catch_all). +pub fn labelFromTrigger( + writer: *std.Io.Writer, + trigger: input.Binding.Trigger, +) std.Io.Writer.Error!bool { + // Modifiers first, using human-readable format + if (trigger.mods.super) try writer.writeAll("Super+"); + if (trigger.mods.ctrl) try writer.writeAll("Ctrl+"); + if (trigger.mods.alt) try writer.writeAll("Alt+"); + if (trigger.mods.shift) try writer.writeAll("Shift+"); + + // Write the key + return writeTriggerKeyLabel(writer, trigger); +} + +/// Writes the key portion of a trigger in human-readable format. +fn writeTriggerKeyLabel( + writer: *std.Io.Writer, + trigger: input.Binding.Trigger, +) error{WriteFailed}!bool { + switch (trigger.key) { + .physical => |k| { + const keyval = keyvalFromKey(k) orelse return false; + const name = gdk.keyvalName(keyval) orelse return false; + // Capitalize the first letter for nicer display + const span = std.mem.span(name); + if (span.len > 0) { + if (span[0] >= 'a' and span[0] <= 'z') { + try writer.writeByte(span[0] - 'a' + 'A'); + if (span.len > 1) try writer.writeAll(span[1..]); + } else { + try writer.writeAll(span); + } + } + }, + + .unicode => |cp| { + // Try to get a nice name from GDK first + if (gdk.keyvalName(cp)) |name| { + const span = std.mem.span(name); + if (span.len > 0) { + // Capitalize the first letter for nicer display + if (span[0] >= 'a' and span[0] <= 'z') { + try writer.writeByte(span[0] - 'a' + 'A'); + if (span.len > 1) try writer.writeAll(span[1..]); + } else { + try writer.writeAll(span); + } + } + } else { + // Fall back to printing the character + try writer.print("{u}", .{cp}); + } + }, + + .catch_all => return false, + } + + return true; +} + test "accelFromTrigger" { const testing = std.testing; var buf: [256]u8 = undefined; @@ -263,6 +327,64 @@ test "xdgShortcutFromTrigger" { })).?); } +test "labelFromTrigger" { + const testing = std.testing; + + // Simple unicode key with modifier + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'q' }, + })); + try testing.expectEqualStrings("Super+Q", buf.written()); + } + + // Multiple modifiers + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, + .key = .{ .unicode = 92 }, + })); + try testing.expectEqualStrings("Super+Ctrl+Alt+Shift+Backslash", buf.written()); + } + + // Physical key + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{ .ctrl = true }, + .key = .{ .physical = .key_a }, + })); + try testing.expectEqualStrings("Ctrl+A", buf.written()); + } + + // No modifiers + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(try labelFromTrigger(&buf.writer, .{ + .mods = .{}, + .key = .{ .physical = .escape }, + })); + try testing.expectEqualStrings("Escape", buf.written()); + } + + // catch_all returns false + { + var buf: std.Io.Writer.Allocating = .init(testing.allocator); + defer buf.deinit(); + try testing.expect(!try labelFromTrigger(&buf.writer, .{ + .mods = .{}, + .key = .catch_all, + })); + } +} + /// A raw entry in the keymap. Our keymap contains mappings between /// GDK keys and our own key enum. const RawEntry = struct { c_uint, input.Key }; From 8f44b74b331d4977c89cc928791905840f29d7f6 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 23 Dec 2025 12:22:29 -0500 Subject: [PATCH 72/96] shell-integration: add failure regression test Add a unit test to prevent regressions in our failure state. For example, we always want to set GHOSTTY_SHELL_FEATURES, even if automatic shell integration fails, because it's also used for manual shell integration (e.g. #5048). --- src/termio/shell_integration.zig | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 3a541dcae..dba4a8f32 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -70,7 +70,6 @@ pub fn setup( exe, ); - // Setup our feature env vars try setupFeatures(env, features); return result; @@ -168,6 +167,29 @@ test "force shell" { } } +test "shell integration failure" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + const result = try setup( + alloc, + "/nonexistent", + .{ .shell = "sh" }, + &env, + null, + .{ .cursor = true, .title = false, .path = false }, + ); + + try testing.expect(result == null); + try testing.expectEqualStrings("cursor", env.get("GHOSTTY_SHELL_FEATURES").?); +} + /// Set up the shell integration features environment variable. pub fn setupFeatures( env: *EnvMap, @@ -234,7 +256,7 @@ test "setup features" { var env = EnvMap.init(alloc); defer env.deinit(); - try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }); + try setupFeatures(&env, std.mem.zeroes(config.ShellIntegrationFeatures)); try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null); } From 1562967d5103355916b4065123caf89608b70ae4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 09:35:51 -0800 Subject: [PATCH 73/96] apprt/gtk: key state overlay --- src/apprt/gtk/build/gresource.zig | 1 + src/apprt/gtk/class/key_state_overlay.zig | 290 +++++++++++++++++++++ src/apprt/gtk/class/surface.zig | 6 + src/apprt/gtk/css/style.css | 12 + src/apprt/gtk/ui/1.2/key-state-overlay.blp | 58 +++++ src/apprt/gtk/ui/1.2/surface.blp | 3 + 6 files changed, 370 insertions(+) create mode 100644 src/apprt/gtk/class/key_state_overlay.zig create mode 100644 src/apprt/gtk/ui/1.2/key-state-overlay.blp diff --git a/src/apprt/gtk/build/gresource.zig b/src/apprt/gtk/build/gresource.zig index c77579aab..d3684c171 100644 --- a/src/apprt/gtk/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -44,6 +44,7 @@ pub const blueprints: []const Blueprint = &.{ .{ .major = 1, .minor = 5, .name = "inspector-window" }, .{ .major = 1, .minor = 2, .name = "resize-overlay" }, .{ .major = 1, .minor = 2, .name = "search-overlay" }, + .{ .major = 1, .minor = 2, .name = "key-state-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/key_state_overlay.zig b/src/apprt/gtk/class/key_state_overlay.zig new file mode 100644 index 000000000..15dc0d502 --- /dev/null +++ b/src/apprt/gtk/class/key_state_overlay.zig @@ -0,0 +1,290 @@ +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"); +const Common = @import("../class.zig").Common; + +const log = std.log.scoped(.gtk_ghostty_key_state_overlay); + +/// An overlay that displays the current key table stack and pending key sequence. +/// This helps users understand what key bindings are active and what keys they've +/// pressed in a multi-key sequence. +pub const KeyStateOverlay = extern struct { + const Self = @This(); + parent_instance: Parent, + pub const Parent = adw.Bin; + pub const getGObjectType = gobject.ext.defineClass(Self, .{ + .name = "GhosttyKeyStateOverlay", + .instanceInit = &init, + .classInit = &Class.init, + .parent_class = &Class.parent, + .private = .{ .Type = Private, .offset = &Private.offset }, + }); + + pub const properties = struct { + pub const active = struct { + pub const name = "active"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = C.privateShallowFieldAccessor("active"), + }, + ); + }; + + pub const @"tables-text" = struct { + pub const name = "tables-text"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("tables_text"), + }, + ); + }; + + pub const @"has-tables" = struct { + pub const name = "has-tables"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasTables }, + ), + }, + ); + }; + + pub const @"sequence-text" = struct { + pub const name = "sequence-text"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("sequence_text"), + }, + ); + }; + + pub const @"has-sequence" = struct { + pub const name = "has-sequence"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = gobject.ext.typedAccessor( + Self, + bool, + .{ .getter = getHasSequence }, + ), + }, + ); + }; + + pub const pending = struct { + pub const name = "pending"; + const impl = gobject.ext.defineProperty( + name, + Self, + bool, + .{ + .default = false, + .accessor = C.privateShallowFieldAccessor("pending"), + }, + ); + }; + + pub const @"valign-target" = struct { + pub const name = "valign-target"; + const impl = gobject.ext.defineProperty( + name, + Self, + gtk.Align, + .{ + .default = .end, + .accessor = C.privateShallowFieldAccessor("valign_target"), + }, + ); + }; + }; + + const Private = struct { + /// Whether the overlay is active/visible. + active: bool = false, + + /// The formatted key table stack text (e.g., "default › vim"). + tables_text: ?[:0]const u8 = null, + + /// The formatted key sequence text (e.g., "Ctrl+A B"). + sequence_text: ?[:0]const u8 = null, + + /// Whether we're waiting for more keys in a sequence. + pending: bool = false, + + /// Target vertical alignment for the overlay. + valign_target: gtk.Align = .end, + + pub var offset: c_int = 0; + }; + + fn init(self: *Self, _: *Class) callconv(.c) void { + gtk.Widget.initTemplate(self.as(gtk.Widget)); + + // Set dummy data for UI iteration + const priv = self.private(); + priv.active = true; + priv.tables_text = glib.ext.dupeZ(u8, "default › vim"); + priv.sequence_text = glib.ext.dupeZ(u8, "Ctrl+A"); + priv.pending = true; + + // Notify property changes so bindings update + const obj = self.as(gobject.Object); + obj.notifyByPspec(properties.active.impl.param_spec); + obj.notifyByPspec(properties.@"tables-text".impl.param_spec); + obj.notifyByPspec(properties.@"has-tables".impl.param_spec); + obj.notifyByPspec(properties.@"sequence-text".impl.param_spec); + obj.notifyByPspec(properties.@"has-sequence".impl.param_spec); + obj.notifyByPspec(properties.pending.impl.param_spec); + } + + fn getHasTables(self: *Self) bool { + return self.private().tables_text != null; + } + + fn getHasSequence(self: *Self) bool { + return self.private().sequence_text != null; + } + + fn closureShowChevron( + _: *Self, + has_tables: bool, + has_sequence: bool, + ) callconv(.c) c_int { + return if (has_tables and has_sequence) 1 else 0; + } + + //--------------------------------------------------------------- + // Template callbacks + + fn onDragEnd( + _: *gtk.GestureDrag, + _: f64, + offset_y: f64, + self: *Self, + ) callconv(.c) void { + // Key state overlay only moves between top-center and bottom-center. + // Horizontal alignment is always center. + const priv = self.private(); + const widget = self.as(gtk.Widget); + const parent = widget.getParent() orelse return; + + const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight()); + const self_height: f64 = @floatFromInt(widget.getAllocatedHeight()); + + const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height; + const new_y = self_y + offset_y + (self_height / 2); + + const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start; + + if (new_valign != priv.valign_target) { + priv.valign_target = new_valign; + self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec); + self.as(gtk.Widget).queueResize(); + } + } + + //--------------------------------------------------------------- + // Virtual methods + + fn dispose(self: *Self) callconv(.c) void { + 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(); + + if (priv.tables_text) |v| { + glib.free(@ptrCast(@constCast(v))); + } + if (priv.sequence_text) |v| { + glib.free(@ptrCast(@constCast(v))); + } + + 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 = "key-state-overlay", + }), + ); + + // Template Callbacks + class.bindTemplateCallback("on_drag_end", &onDragEnd); + class.bindTemplateCallback("show_chevron", &closureShowChevron); + + // Properties + gobject.ext.registerProperties(class, &.{ + properties.active.impl, + properties.@"tables-text".impl, + properties.@"has-tables".impl, + properties.@"sequence-text".impl, + properties.@"has-sequence".impl, + properties.pending.impl, + properties.@"valign-target".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/class/surface.zig b/src/apprt/gtk/class/surface.zig index c35c78302..50d7f3dc2 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -26,6 +26,7 @@ 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 KeyStateOverlay = @import("key_state_overlay.zig").KeyStateOverlay; const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited; const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog; const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog; @@ -553,6 +554,9 @@ pub const Surface = extern struct { /// The search overlay search_overlay: *SearchOverlay, + /// The key state overlay + key_state_overlay: *KeyStateOverlay, + /// The apprt Surface. rt_surface: ApprtSurface = undefined, @@ -3308,6 +3312,7 @@ pub const Surface = extern struct { fn init(class: *Class) callconv(.c) void { gobject.ext.ensureType(ResizeOverlay); gobject.ext.ensureType(SearchOverlay); + gobject.ext.ensureType(KeyStateOverlay); gobject.ext.ensureType(ChildExited); gtk.Widget.Class.setTemplateFromResource( class.as(gtk.Widget.Class), @@ -3328,6 +3333,7 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); class.bindTemplateChildPrivate("search_overlay", .{}); + class.bindTemplateChildPrivate("key_state_overlay", .{}); class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); class.bindTemplateChildPrivate("im_context", .{}); diff --git a/src/apprt/gtk/css/style.css b/src/apprt/gtk/css/style.css index 938d23ad8..f5491b7de 100644 --- a/src/apprt/gtk/css/style.css +++ b/src/apprt/gtk/css/style.css @@ -46,6 +46,18 @@ label.url-overlay.right { outline-width: 1px; } +/* + * GhosttySurface key state overlay + */ +.key-state-overlay { + padding: 6px 10px; + 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/key-state-overlay.blp b/src/apprt/gtk/ui/1.2/key-state-overlay.blp new file mode 100644 index 000000000..504d2e26e --- /dev/null +++ b/src/apprt/gtk/ui/1.2/key-state-overlay.blp @@ -0,0 +1,58 @@ +using Gtk 4.0; +using Adw 1; + +template $GhosttyKeyStateOverlay: Adw.Bin { + visible: bind template.active; + valign-target: end; + halign: center; + valign: bind template.valign-target; + + GestureDrag { + button: 1; + propagation-phase: capture; + drag-end => $on_drag_end(); + } + + Adw.Bin { + Box container { + styles [ + "background", + "key-state-overlay", + ] + + orientation: horizontal; + spacing: 6; + + Image { + icon-name: "input-keyboard-symbolic"; + pixel-size: 16; + } + + Label tables_label { + visible: bind template.has-tables; + label: bind template.tables-text; + xalign: 0.0; + } + + Label chevron_label { + visible: bind $show_chevron(template.has-tables, template.has-sequence) as ; + label: "›"; + + styles [ + "dim-label", + ] + } + + Label sequence_label { + visible: bind template.has-sequence; + label: bind template.sequence-text; + xalign: 0.0; + } + + Spinner pending_spinner { + visible: bind template.pending; + spinning: bind template.pending; + } + } + } +} diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 4ebfeabfb..e9db4208e 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -155,6 +155,9 @@ Overlay terminal_page { previous-match => $search_previous_match(); } + [overlay] + $GhosttyKeyStateOverlay key_state_overlay {} + [overlay] // Apply unfocused-split-fill and unfocused-split-opacity to current surface // this is only applied when a tab has more than one surface From 3d2aa9bd829665fcb96500317b4bb67767cfc5cc Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 23 Dec 2025 12:59:58 -0500 Subject: [PATCH 74/96] shell-integration: always call setupFeatures Our existing logic already ensured that setupFeatures() was always called, but that was happening from two code paths: explicitly when shell integration is .none and implicitly via setup(). We can simplify this by always calling setupFeatures() once, outside of the (automatic) shell integration path. There's one small behavioral change: we previously didn't set up shell features in the automatic shell integration path if we didn't have a resources directory (as a side effect). Resources are required for shell integrations, but we don't need them to export GHOSTTY_SHELL_FEATURES, which could potentially still be useful on its on. --- src/termio/Exec.zig | 15 +++++++-------- src/termio/shell_integration.zig | 9 ++------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 7c7b711fd..93ad835c5 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -750,15 +750,15 @@ const Subprocess = struct { else => "sh", } }; + // Always set up shell features (GHOSTTY_SHELL_FEATURES). These are + // used by both automatic and manual shell integrations. + try shell_integration.setupFeatures( + &env, + cfg.shell_integration_features, + ); + const force: ?shell_integration.Shell = switch (cfg.shell_integration) { .none => { - // Even if shell integration is none, we still want to - // set up the feature env vars - try shell_integration.setupFeatures( - &env, - cfg.shell_integration_features, - ); - // This is a source of confusion for users despite being // opt-in since it results in some Ghostty features not // working. We always want to log it. @@ -784,7 +784,6 @@ const Subprocess = struct { default_shell_command, &env, force, - cfg.shell_integration_features, ) orelse { log.warn("shell could not be detected, no automatic shell integration will be injected", .{}); break :shell default_shell_command; diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index dba4a8f32..e9f85e44c 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -44,7 +44,6 @@ pub fn setup( command: config.Command, env: *EnvMap, force_shell: ?Shell, - features: config.ShellIntegrationFeatures, ) !?ShellIntegration { const exe = if (force_shell) |shell| switch (shell) { .bash => "bash", @@ -70,8 +69,6 @@ pub fn setup( exe, ); - try setupFeatures(env, features); - return result; } @@ -161,7 +158,6 @@ test "force shell" { .{ .shell = "sh" }, &env, shell, - .{}, ); try testing.expectEqual(shell, result.?.shell); } @@ -183,11 +179,10 @@ test "shell integration failure" { .{ .shell = "sh" }, &env, null, - .{ .cursor = true, .title = false, .path = false }, ); try testing.expect(result == null); - try testing.expectEqualStrings("cursor", env.get("GHOSTTY_SHELL_FEATURES").?); + try testing.expectEqual(0, env.count()); } /// Set up the shell integration features environment variable. @@ -756,7 +751,7 @@ const TmpResourcesDir = struct { path: []const u8, shell_path: []const u8, - fn init(allocator: std.mem.Allocator, shell: Shell) !TmpResourcesDir { + fn init(allocator: Allocator, shell: Shell) !TmpResourcesDir { var tmp_dir = std.testing.tmpDir(.{}); errdefer tmp_dir.cleanup(); From 85ce7d0b04f258cb53068aa753e80d26dad15865 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 10:10:44 -0800 Subject: [PATCH 75/96] apprt/gtk: write StringList for boxed type of strings --- src/apprt/gtk/ext.zig | 3 + src/apprt/gtk/ext/slice.zig | 106 ++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/apprt/gtk/ext/slice.zig diff --git a/src/apprt/gtk/ext.zig b/src/apprt/gtk/ext.zig index 9b1eeecc6..df9ab4ea2 100644 --- a/src/apprt/gtk/ext.zig +++ b/src/apprt/gtk/ext.zig @@ -12,6 +12,8 @@ const gobject = @import("gobject"); const gtk = @import("gtk"); pub const actions = @import("ext/actions.zig"); +const slice = @import("ext/slice.zig"); +pub const StringList = slice.StringList; /// Wrapper around `gobject.boxedCopy` to copy a boxed type `T`. pub fn boxedCopy(comptime T: type, ptr: *const T) *T { @@ -64,4 +66,5 @@ pub fn gValueHolds(value_: ?*const gobject.Value, g_type: gobject.Type) bool { test { _ = actions; + _ = slice; } diff --git a/src/apprt/gtk/ext/slice.zig b/src/apprt/gtk/ext/slice.zig new file mode 100644 index 000000000..a746d8045 --- /dev/null +++ b/src/apprt/gtk/ext/slice.zig @@ -0,0 +1,106 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const glib = @import("glib"); +const gobject = @import("gobject"); + +/// A boxed type that holds a list of string slices. +pub const StringList = struct { + arena: ArenaAllocator, + strings: []const [:0]const u8, + + pub fn create( + alloc: Allocator, + strings: []const [:0]const u8, + ) Allocator.Error!*StringList { + var arena: ArenaAllocator = .init(alloc); + errdefer arena.deinit(); + const arena_alloc = arena.allocator(); + var stored = try arena_alloc.alloc([:0]const u8, strings.len); + for (strings, 0..) |s, i| stored[i] = try arena_alloc.dupeZ(u8, s); + + const ptr = try alloc.create(StringList); + errdefer alloc.destroy(ptr); + ptr.* = .{ .arena = arena, .strings = stored }; + + return ptr; + } + + pub fn deinit(self: *StringList) void { + self.arena.deinit(); + } + + pub fn destroy(self: *StringList) void { + const alloc = self.arena.child_allocator; + self.deinit(); + alloc.destroy(self); + } + + pub const getGObjectType = gobject.ext.defineBoxed( + StringList, + .{ + .name = "GhosttyStringList", + .funcs = .{ + .copy = &struct { + fn copy(self: *StringList) callconv(.c) *StringList { + return StringList.create( + self.arena.child_allocator, + self.strings, + ) catch @panic("OOM"); + } + }.copy, + .free = &struct { + fn free(self: *StringList) callconv(.c) void { + self.destroy(); + } + }.free, + }, + }, + ); +}; + +test "StringList create and destroy" { + const testing = std.testing; + const alloc = testing.allocator; + + const input: []const [:0]const u8 = &.{ "hello", "world" }; + const list = try StringList.create(alloc, input); + defer list.destroy(); + + try testing.expectEqual(@as(usize, 2), list.strings.len); + try testing.expectEqualStrings("hello", list.strings[0]); + try testing.expectEqualStrings("world", list.strings[1]); +} + +test "StringList create empty list" { + const testing = std.testing; + const alloc = testing.allocator; + + const input: []const [:0]const u8 = &.{}; + const list = try StringList.create(alloc, input); + defer list.destroy(); + + try testing.expectEqual(@as(usize, 0), list.strings.len); +} + +test "StringList boxedCopy and boxedFree" { + const testing = std.testing; + const alloc = testing.allocator; + + const input: []const [:0]const u8 = &.{ "foo", "bar", "baz" }; + const original = try StringList.create(alloc, input); + defer original.destroy(); + + const copied: *StringList = @ptrCast(@alignCast(gobject.boxedCopy( + StringList.getGObjectType(), + original, + ))); + defer gobject.boxedFree(StringList.getGObjectType(), copied); + + try testing.expectEqual(@as(usize, 3), copied.strings.len); + try testing.expectEqualStrings("foo", copied.strings[0]); + try testing.expectEqualStrings("bar", copied.strings[1]); + try testing.expectEqualStrings("baz", copied.strings[2]); + + try testing.expect(original.strings.ptr != copied.strings.ptr); +} From 481490bd1176760d154b4fa6584604d5de231757 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 10:25:04 -0800 Subject: [PATCH 76/96] apprt/gtk: add getters for key-sequence and key-table --- src/apprt/gtk/class/surface.zig | 54 +++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 50d7f3dc2..556e5e2ec 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -361,6 +361,44 @@ pub const Surface = extern struct { }, ); }; + + pub const @"key-sequence" = struct { + pub const name = "key-sequence"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ext.StringList, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getKeySequence, + .getter_transfer = .full, + }, + ), + }, + ); + }; + + pub const @"key-table" = struct { + pub const name = "key-table"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?*ext.StringList, + .{ + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getKeyTable, + .getter_transfer = .full, + }, + ), + }, + ); + }; }; pub const signals = struct { @@ -1949,6 +1987,20 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.@"default-size".impl.param_spec); } + /// Get the key sequence list. Full transfer. + fn getKeySequence(self: *Self) ?*ext.StringList { + const priv = self.private(); + const alloc = Application.default().allocator(); + return ext.StringList.create(alloc, priv.key_sequence.items) catch null; + } + + /// Get the key table list. Full transfer. + fn getKeyTable(self: *Self) ?*ext.StringList { + const priv = self.private(); + const alloc = Application.default().allocator(); + return ext.StringList.create(alloc, priv.key_tables.items) catch null; + } + /// Return the min size, if set. pub fn getMinSize(self: *Self) ?*Size { const priv = self.private(); @@ -3385,6 +3437,8 @@ pub const Surface = extern struct { properties.@"error".impl, properties.@"font-size-request".impl, properties.focused.impl, + properties.@"key-sequence".impl, + properties.@"key-table".impl, properties.@"min-size".impl, properties.@"mouse-shape".impl, properties.@"mouse-hidden".impl, From 7ca3f41f6f25e13e5da065700eaad0c5798a9ddb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 10:31:53 -0800 Subject: [PATCH 77/96] apprt/gtk: key state overlay take bindings from surface --- src/apprt/gtk/class/key_state_overlay.zig | 111 ++++++++++++++-------- src/apprt/gtk/ext/slice.zig | 5 + src/apprt/gtk/ui/1.2/surface.blp | 5 +- 3 files changed, 83 insertions(+), 38 deletions(-) diff --git a/src/apprt/gtk/class/key_state_overlay.zig b/src/apprt/gtk/class/key_state_overlay.zig index 15dc0d502..20c0a8ab8 100644 --- a/src/apprt/gtk/class/key_state_overlay.zig +++ b/src/apprt/gtk/class/key_state_overlay.zig @@ -1,10 +1,9 @@ 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 ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); const Common = @import("../class.zig").Common; @@ -39,15 +38,23 @@ pub const KeyStateOverlay = extern struct { ); }; - pub const @"tables-text" = struct { - pub const name = "tables-text"; + pub const tables = struct { + pub const name = "tables"; const impl = gobject.ext.defineProperty( name, Self, - ?[:0]const u8, + ?*ext.StringList, .{ - .default = null, - .accessor = C.privateStringFieldAccessor("tables_text"), + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getTables, + .getter_transfer = .full, + .setter = setTables, + .setter_transfer = .full, + }, + ), }, ); }; @@ -69,15 +76,23 @@ pub const KeyStateOverlay = extern struct { ); }; - pub const @"sequence-text" = struct { - pub const name = "sequence-text"; + pub const sequence = struct { + pub const name = "sequence"; const impl = gobject.ext.defineProperty( name, Self, - ?[:0]const u8, + ?*ext.StringList, .{ - .default = null, - .accessor = C.privateStringFieldAccessor("sequence_text"), + .accessor = gobject.ext.typedAccessor( + Self, + ?*ext.StringList, + .{ + .getter = getSequence, + .getter_transfer = .full, + .setter = setSequence, + .setter_transfer = .full, + }, + ), }, ); }; @@ -130,11 +145,11 @@ pub const KeyStateOverlay = extern struct { /// Whether the overlay is active/visible. active: bool = false, - /// The formatted key table stack text (e.g., "default › vim"). - tables_text: ?[:0]const u8 = null, + /// The key table stack. + tables: ?*ext.StringList = null, - /// The formatted key sequence text (e.g., "Ctrl+A B"). - sequence_text: ?[:0]const u8 = null, + /// The key sequence. + sequence: ?*ext.StringList = null, /// Whether we're waiting for more keys in a sequence. pending: bool = false, @@ -147,30 +162,52 @@ pub const KeyStateOverlay = extern struct { fn init(self: *Self, _: *Class) callconv(.c) void { gtk.Widget.initTemplate(self.as(gtk.Widget)); + } - // Set dummy data for UI iteration + fn getTables(self: *Self) ?*ext.StringList { const priv = self.private(); - priv.active = true; - priv.tables_text = glib.ext.dupeZ(u8, "default › vim"); - priv.sequence_text = glib.ext.dupeZ(u8, "Ctrl+A"); - priv.pending = true; + if (priv.tables) |tables| { + return ext.StringList.create(tables.allocator(), tables.strings) catch null; + } + return null; + } - // Notify property changes so bindings update - const obj = self.as(gobject.Object); - obj.notifyByPspec(properties.active.impl.param_spec); - obj.notifyByPspec(properties.@"tables-text".impl.param_spec); - obj.notifyByPspec(properties.@"has-tables".impl.param_spec); - obj.notifyByPspec(properties.@"sequence-text".impl.param_spec); - obj.notifyByPspec(properties.@"has-sequence".impl.param_spec); - obj.notifyByPspec(properties.pending.impl.param_spec); + fn getSequence(self: *Self) ?*ext.StringList { + const priv = self.private(); + if (priv.sequence) |sequence| { + return ext.StringList.create(sequence.allocator(), sequence.strings) catch null; + } + return null; + } + + fn setTables(self: *Self, value: ?*ext.StringList) void { + const priv = self.private(); + if (priv.tables) |old| { + old.destroy(); + priv.tables = null; + } + + priv.tables = value; + self.as(gobject.Object).notifyByPspec(properties.@"has-tables".impl.param_spec); + } + + fn setSequence(self: *Self, value: ?*ext.StringList) void { + const priv = self.private(); + if (priv.sequence) |old| { + old.destroy(); + priv.sequence = null; + } + + priv.sequence = value; + self.as(gobject.Object).notifyByPspec(properties.@"has-sequence".impl.param_spec); } fn getHasTables(self: *Self) bool { - return self.private().tables_text != null; + return self.private().tables != null; } fn getHasSequence(self: *Self) bool { - return self.private().sequence_text != null; + return self.private().sequence != null; } fn closureShowChevron( @@ -229,11 +266,11 @@ pub const KeyStateOverlay = extern struct { fn finalize(self: *Self) callconv(.c) void { const priv = self.private(); - if (priv.tables_text) |v| { - glib.free(@ptrCast(@constCast(v))); + if (priv.tables) |v| { + v.destroy(); } - if (priv.sequence_text) |v| { - glib.free(@ptrCast(@constCast(v))); + if (priv.sequence) |v| { + v.destroy(); } gobject.Object.virtual_methods.finalize.call( @@ -270,9 +307,9 @@ pub const KeyStateOverlay = extern struct { // Properties gobject.ext.registerProperties(class, &.{ properties.active.impl, - properties.@"tables-text".impl, + properties.tables.impl, properties.@"has-tables".impl, - properties.@"sequence-text".impl, + properties.sequence.impl, properties.@"has-sequence".impl, properties.pending.impl, properties.@"valign-target".impl, diff --git a/src/apprt/gtk/ext/slice.zig b/src/apprt/gtk/ext/slice.zig index a746d8045..49ad63d85 100644 --- a/src/apprt/gtk/ext/slice.zig +++ b/src/apprt/gtk/ext/slice.zig @@ -36,6 +36,11 @@ pub const StringList = struct { alloc.destroy(self); } + /// Returns the general-purpose allocator used by this StringList. + pub fn allocator(self: *const StringList) Allocator { + return self.arena.child_allocator; + } + pub const getGObjectType = gobject.ext.defineBoxed( StringList, .{ diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index e9db4208e..a594ba98f 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -156,7 +156,10 @@ Overlay terminal_page { } [overlay] - $GhosttyKeyStateOverlay key_state_overlay {} + $GhosttyKeyStateOverlay key_state_overlay { + tables: bind template.key-table; + sequence: bind template.key-sequence; + } [overlay] // Apply unfocused-split-fill and unfocused-split-opacity to current surface From 71d5ae5a51b96b4fc841e6cc07b2ccfc79489987 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 10:43:40 -0800 Subject: [PATCH 78/96] apprt/gtk: key state overlay text is dynamic --- src/apprt/gtk/class/key_state_overlay.zig | 115 ++++++++++++--------- src/apprt/gtk/class/surface.zig | 8 ++ src/apprt/gtk/ui/1.2/key-state-overlay.blp | 10 +- 3 files changed, 78 insertions(+), 55 deletions(-) diff --git a/src/apprt/gtk/class/key_state_overlay.zig b/src/apprt/gtk/class/key_state_overlay.zig index 20c0a8ab8..7aca8f01d 100644 --- a/src/apprt/gtk/class/key_state_overlay.zig +++ b/src/apprt/gtk/class/key_state_overlay.zig @@ -1,10 +1,12 @@ const std = @import("std"); const adw = @import("adw"); +const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); const ext = @import("../ext.zig"); const gresource = @import("../build/gresource.zig"); +const Application = @import("application.zig").Application; const Common = @import("../class.zig").Common; const log = std.log.scoped(.gtk_ghostty_key_state_overlay); @@ -25,19 +27,6 @@ pub const KeyStateOverlay = extern struct { }); pub const properties = struct { - pub const active = struct { - pub const name = "active"; - const impl = gobject.ext.defineProperty( - name, - Self, - bool, - .{ - .default = false, - .accessor = C.privateShallowFieldAccessor("active"), - }, - ); - }; - pub const tables = struct { pub const name = "tables"; const impl = gobject.ext.defineProperty( @@ -50,7 +39,7 @@ pub const KeyStateOverlay = extern struct { ?*ext.StringList, .{ .getter = getTables, - .getter_transfer = .full, + .getter_transfer = .none, .setter = setTables, .setter_transfer = .full, }, @@ -88,7 +77,7 @@ pub const KeyStateOverlay = extern struct { ?*ext.StringList, .{ .getter = getSequence, - .getter_transfer = .full, + .getter_transfer = .none, .setter = setSequence, .setter_transfer = .full, }, @@ -114,19 +103,6 @@ pub const KeyStateOverlay = extern struct { ); }; - pub const pending = struct { - pub const name = "pending"; - const impl = gobject.ext.defineProperty( - name, - Self, - bool, - .{ - .default = false, - .accessor = C.privateShallowFieldAccessor("pending"), - }, - ); - }; - pub const @"valign-target" = struct { pub const name = "valign-target"; const impl = gobject.ext.defineProperty( @@ -142,18 +118,12 @@ pub const KeyStateOverlay = extern struct { }; const Private = struct { - /// Whether the overlay is active/visible. - active: bool = false, - /// The key table stack. tables: ?*ext.StringList = null, /// The key sequence. sequence: ?*ext.StringList = null, - /// Whether we're waiting for more keys in a sequence. - pending: bool = false, - /// Target vertical alignment for the overlay. valign_target: gtk.Align = .end, @@ -165,19 +135,11 @@ pub const KeyStateOverlay = extern struct { } fn getTables(self: *Self) ?*ext.StringList { - const priv = self.private(); - if (priv.tables) |tables| { - return ext.StringList.create(tables.allocator(), tables.strings) catch null; - } - return null; + return self.private().tables; } fn getSequence(self: *Self) ?*ext.StringList { - const priv = self.private(); - if (priv.sequence) |sequence| { - return ext.StringList.create(sequence.allocator(), sequence.strings) catch null; - } - return null; + return self.private().sequence; } fn setTables(self: *Self, value: ?*ext.StringList) void { @@ -186,8 +148,11 @@ pub const KeyStateOverlay = extern struct { old.destroy(); priv.tables = null; } + if (value) |v| { + priv.tables = v; + } - priv.tables = value; + self.as(gobject.Object).notifyByPspec(properties.tables.impl.param_spec); self.as(gobject.Object).notifyByPspec(properties.@"has-tables".impl.param_spec); } @@ -197,17 +162,22 @@ pub const KeyStateOverlay = extern struct { old.destroy(); priv.sequence = null; } + if (value) |v| { + priv.sequence = v; + } - priv.sequence = value; + self.as(gobject.Object).notifyByPspec(properties.sequence.impl.param_spec); self.as(gobject.Object).notifyByPspec(properties.@"has-sequence".impl.param_spec); } fn getHasTables(self: *Self) bool { - return self.private().tables != null; + const v = self.private().tables orelse return false; + return v.strings.len > 0; } fn getHasSequence(self: *Self) bool { - return self.private().sequence != null; + const v = self.private().sequence orelse return false; + return v.strings.len > 0; } fn closureShowChevron( @@ -218,6 +188,50 @@ pub const KeyStateOverlay = extern struct { return if (has_tables and has_sequence) 1 else 0; } + fn closureHasState( + _: *Self, + has_tables: bool, + has_sequence: bool, + ) callconv(.c) c_int { + return if (has_tables or has_sequence) 1 else 0; + } + + fn closureTablesText( + _: *Self, + tables: ?*ext.StringList, + ) callconv(.c) ?[*:0]const u8 { + const list = tables orelse return null; + if (list.strings.len == 0) return null; + + var buf: std.Io.Writer.Allocating = .init(Application.default().allocator()); + defer buf.deinit(); + + for (list.strings, 0..) |s, i| { + if (i > 0) buf.writer.writeAll(" > ") catch return null; + buf.writer.writeAll(s) catch return null; + } + + return glib.ext.dupeZ(u8, buf.written()); + } + + fn closureSequenceText( + _: *Self, + sequence: ?*ext.StringList, + ) callconv(.c) ?[*:0]const u8 { + const list = sequence orelse return null; + if (list.strings.len == 0) return null; + + var buf: std.Io.Writer.Allocating = .init(Application.default().allocator()); + defer buf.deinit(); + + for (list.strings, 0..) |s, i| { + if (i > 0) buf.writer.writeAll(" ") catch return null; + buf.writer.writeAll(s) catch return null; + } + + return glib.ext.dupeZ(u8, buf.written()); + } + //--------------------------------------------------------------- // Template callbacks @@ -303,15 +317,16 @@ pub const KeyStateOverlay = extern struct { // Template Callbacks class.bindTemplateCallback("on_drag_end", &onDragEnd); class.bindTemplateCallback("show_chevron", &closureShowChevron); + class.bindTemplateCallback("has_state", &closureHasState); + class.bindTemplateCallback("tables_text", &closureTablesText); + class.bindTemplateCallback("sequence_text", &closureSequenceText); // Properties gobject.ext.registerProperties(class, &.{ - properties.active.impl, properties.tables.impl, properties.@"has-tables".impl, properties.sequence.impl, properties.@"has-sequence".impl, - properties.pending.impl, properties.@"valign-target".impl, }); diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 556e5e2ec..a14d53c32 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -832,6 +832,10 @@ pub const Surface = extern struct { const priv = self.private(); const alloc = Application.default().allocator(); + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.@"key-sequence".impl.param_spec); + switch (value) { .trigger => |trigger| { // Convert the trigger to a human-readable label @@ -865,6 +869,10 @@ pub const Surface = extern struct { const priv = self.private(); const alloc = Application.default().allocator(); + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + self.as(gobject.Object).notifyByPspec(properties.@"key-table".impl.param_spec); + switch (value) { .activate => |name| { // Duplicate the name string and push onto stack diff --git a/src/apprt/gtk/ui/1.2/key-state-overlay.blp b/src/apprt/gtk/ui/1.2/key-state-overlay.blp index 504d2e26e..c8654bfbb 100644 --- a/src/apprt/gtk/ui/1.2/key-state-overlay.blp +++ b/src/apprt/gtk/ui/1.2/key-state-overlay.blp @@ -2,7 +2,7 @@ using Gtk 4.0; using Adw 1; template $GhosttyKeyStateOverlay: Adw.Bin { - visible: bind template.active; + visible: bind $has_state(template.has-tables, template.has-sequence) as ; valign-target: end; halign: center; valign: bind template.valign-target; @@ -30,7 +30,7 @@ template $GhosttyKeyStateOverlay: Adw.Bin { Label tables_label { visible: bind template.has-tables; - label: bind template.tables-text; + label: bind $tables_text(template.tables) as ; xalign: 0.0; } @@ -45,13 +45,13 @@ template $GhosttyKeyStateOverlay: Adw.Bin { Label sequence_label { visible: bind template.has-sequence; - label: bind template.sequence-text; + label: bind $sequence_text(template.sequence) as ; xalign: 0.0; } Spinner pending_spinner { - visible: bind template.pending; - spinning: bind template.pending; + visible: bind template.has-sequence; + spinning: bind template.has-sequence; } } } From f2fe979bab56f434d9b3711b373a9b172ec1fc91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 11:23:02 -0800 Subject: [PATCH 79/96] update valgrind suppressions --- valgrind.supp | 64 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/valgrind.supp b/valgrind.supp index eeb395d03..27479fd5c 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -72,7 +72,16 @@ fun:gdk_surface_handle_event ... } - +{ + GTK CSS Node Validation + Memcheck:Leak + match-leak-kinds: possible + fun:malloc + ... + fun:gtk_css_node_validate_internal + fun:gtk_css_node_validate + ... +} { GTK CSS Provider Leak Memcheck:Leak @@ -196,8 +205,44 @@ fun:svga_context_flush ... } - { + SVGA Stuff + Memcheck:Leak + match-leak-kinds: definite + fun:calloc + fun:svga_create_surface_view + fun:svga_set_framebuffer_state + fun:st_update_framebuffer_state + fun:st_Clear + fun:gsk_gpu_render_pass_op_gl_command + ... +} +{ + GTK Icon + Memcheck:Leak + match-leak-kinds: possible + fun:*alloc + ... + fun:gtk_icon_theme_set_display + fun:gtk_icon_theme_get_for_display + ... +} +{ + GDK Wayland Connection + Memcheck:Leak + match-leak-kinds: possible + fun:calloc + fun:wl_closure_init + fun:wl_connection_demarshal + fun:wl_display_read_events + fun:gdk_wayland_poll_source_check + fun:g_main_context_check_unlocked + fun:g_main_context_iterate_unlocked.isra.0 + fun:g_main_context_iteration + ... +} +{ + GSK Renderer GPU Stuff Memcheck:Leak match-leak-kinds: possible @@ -297,6 +342,21 @@ fun:g_main_context_iteration ... } +{ + GSK More Forms + Memcheck:Leak + match-leak-kinds: possible + ... + fun:gsk_gl_device_use_program + fun:gsk_gl_frame_use_program + fun:gsk_gpu_shader_op_gl_command_n + fun:gsk_gpu_render_pass_op_gl_command + fun:gsk_gl_frame_submit + fun:gsk_gpu_renderer_render_texture + fun:gsk_renderer_render_texture + fun:render_contents + ... +} { GTK Shader Selector Memcheck:Leak From 256c3b9ffba19d6d18963dd34f719f8878d388f6 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 23 Dec 2025 14:51:09 -0500 Subject: [PATCH 80/96] shell-integration: ensure clean env on failure Our shell integration routines can now fail when resources are missing. This change introduces tests to ensure that they leave behind a clean environment upon failure. The bash integration needed a little reordering to support this. --- src/termio/shell_integration.zig | 103 ++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 23 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index dba4a8f32..519c226c7 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -344,6 +344,28 @@ fn setupBash( try cmd.appendArg(arg); } } + + // Preserve an existing ENV value. We're about to overwrite it. + if (env.get("ENV")) |v| { + try env.put("GHOSTTY_BASH_ENV", v); + } + + // Set our new ENV to point to our integration script. + var script_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const script_path = try std.fmt.bufPrint( + &script_path_buf, + "{s}/shell-integration/bash/ghostty.bash", + .{resource_dir}, + ); + if (std.fs.openFileAbsolute(script_path, .{})) |file| { + file.close(); + try env.put("ENV", script_path); + } else |err| { + log.warn("unable to open {s}: {}", .{ script_path, err }); + env.remove("GHOSTTY_BASH_ENV"); + return null; + } + try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]); if (rcfile) |v| { try env.put("GHOSTTY_BASH_RCFILE", v); @@ -365,26 +387,6 @@ fn setupBash( } } - // Preserve an existing ENV value. We're about to overwrite it. - if (env.get("ENV")) |v| { - try env.put("GHOSTTY_BASH_ENV", v); - } - - // Set our new ENV to point to our integration script. - var script_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const script_path = try std.fmt.bufPrint( - &script_path_buf, - "{s}/shell-integration/bash/ghostty.bash", - .{resource_dir}, - ); - if (std.fs.openFileAbsolute(script_path, .{})) |file| { - file.close(); - try env.put("ENV", script_path); - } else |err| { - log.warn("unable to open {s}: {}", .{ script_path, err }); - return null; - } - // 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. @@ -437,9 +439,7 @@ test "bash: unsupported options" { defer env.deinit(); try testing.expect(try setupBash(alloc, .{ .shell = cmdline }, res.path, &env) == null); - try testing.expect(env.get("GHOSTTY_BASH_INJECT") == null); - try testing.expect(env.get("GHOSTTY_BASH_RCFILE") == null); - try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); + try testing.expectEqual(0, env.count()); } } @@ -581,6 +581,25 @@ test "bash: additional arguments" { } } +test "bash: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(try setupBash(alloc, .{ .shell = "bash" }, resources_dir, &env) == null); + try testing.expectEqual(0, env.count()); +} + /// Setup automatic shell integration for shells that include /// their modules from paths in `XDG_DATA_DIRS` env variable. /// @@ -690,6 +709,25 @@ test "xdg: existing XDG_DATA_DIRS" { ); } +test "xdg: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(!try setupXdgDataDirs(alloc, resources_dir, &env)); + try testing.expectEqual(0, env.count()); +} + /// Setup the zsh automatic shell integration. This works by setting /// ZDOTDIR to our resources dir so that zsh will load our config. This /// config then loads the true user config. @@ -749,6 +787,25 @@ test "zsh: ZDOTDIR" { try testing.expectEqualStrings("$HOME/.config/zsh", env.get("GHOSTTY_ZSH_ZDOTDIR").?); } +test "zsh: missing resources" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const resources_dir = try tmp_dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(resources_dir); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try testing.expect(!try setupZsh(resources_dir, &env)); + try testing.expectEqual(0, env.count()); +} + /// Test helper that creates a temporary resources directory with shell integration paths. const TmpResourcesDir = struct { allocator: Allocator, From 0db0655ea56ee919043cf0ff841ce81dc00a41e2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 23 Dec 2025 20:28:49 -0800 Subject: [PATCH 81/96] Invalid key sequence does not encode if a `catch_all` has `ignore` This adds some new special case handling for key sequences when an unbound keyboard input is received. If the current keybinding set scope (i.e. active tables) has a `catch_all` binding that would `ignore` input, then the entire key sequence is dropped. Normally, when an unbound key sequence is received, Ghostty encodes it and sends it to the running program. This special behavior is useful for things like Vim mode which have `g>g` to scroll to top, and a `catch_all=ignore` to drop all other input. If the user presses `g>h` (unbound), you don't want `gh` to show up in your terminal input, because the `catch_all=ignore` indicates that the user wants that mode to drop all unbound input. --- src/Surface.zig | 78 ++++++++++++++++++++++++++++++++----------- src/config/Config.zig | 5 +++ 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index ea17c6104..fd658a43b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2826,14 +2826,21 @@ fn maybeHandleBinding( // No entry found. We need to encode everything up to this // point and send to the pty since we're in a sequence. - // - // We also ignore modifiers so that nested sequences such as + + // We ignore modifiers so that nested sequences such as // ctrl+a>ctrl+b>c work. - if (!event.key.modifier()) { - // Encode everything up to this point - self.endKeySequence(.flush, .retain); + if (event.key.modifier()) return null; + + // If we have a catch-all of ignore, then we special case our + // invalid sequence handling to ignore it. + if (self.catchAllIsIgnore()) { + self.endKeySequence(.drop, .retain); + return .ignored; } + // Encode everything up to this point + self.endKeySequence(.flush, .retain); + return null; } @@ -3037,6 +3044,34 @@ fn deactivateAllKeyTables(self: *Surface) !bool { return true; } +/// This checks if the current keybinding sets have a catch_all binding +/// with `ignore`. This is used to determine some special input cases. +fn catchAllIsIgnore(self: *Surface) bool { + // Get our catch all + const entry: input.Binding.Set.Entry = entry: { + const trigger: input.Binding.Trigger = .{ .key = .catch_all }; + + const table_items = self.keyboard.table_stack.items; + for (0..table_items.len) |i| { + const rev_i: usize = table_items.len - 1 - i; + const entry = table_items[rev_i].set.get(trigger) orelse continue; + break :entry entry; + } + + break :entry self.config.keybind.set.get(trigger) orelse + return false; + }; + + // We have a catch-all entry, see if its an ignore + return switch (entry.value_ptr.*) { + .leader => false, + .leaf => |leaf| leaf.action == .ignore, + .leaf_chained => |leaf| chained: for (leaf.actions.items) |action| { + if (action == .ignore) break :chained true; + } else false, + }; +} + const KeySequenceQueued = enum { flush, drop }; const KeySequenceMemory = enum { retain, free }; @@ -3065,23 +3100,26 @@ fn endKeySequence( // the set we look at to the root set. self.keyboard.sequence_set = null; - if (self.keyboard.sequence_queued.items.len > 0) { - switch (action) { - .flush => for (self.keyboard.sequence_queued.items) |write_req| { - self.queueIo(switch (write_req) { - .small => |v| .{ .write_small = v }, - .stable => |v| .{ .write_stable = v }, - .alloc => |v| .{ .write_alloc = v }, - }, .unlocked); - }, + // If we have no queued data, there is nothing else to do. + if (self.keyboard.sequence_queued.items.len == 0) return; - .drop => for (self.keyboard.sequence_queued.items) |req| req.deinit(), - } + // Run the proper action first + switch (action) { + .flush => for (self.keyboard.sequence_queued.items) |write_req| { + self.queueIo(switch (write_req) { + .small => |v| .{ .write_small = v }, + .stable => |v| .{ .write_stable = v }, + .alloc => |v| .{ .write_alloc = v }, + }, .unlocked); + }, - switch (mem) { - .free => self.keyboard.sequence_queued.clearAndFree(self.alloc), - .retain => self.keyboard.sequence_queued.clearRetainingCapacity(), - } + .drop => for (self.keyboard.sequence_queued.items) |req| req.deinit(), + } + + // Memory handling of the sequence after the action + switch (mem) { + .free => self.keyboard.sequence_queued.clearAndFree(self.alloc), + .retain => self.keyboard.sequence_queued.clearRetainingCapacity(), } } diff --git a/src/config/Config.zig b/src/config/Config.zig index 6911cd9f7..0df5c91b0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1521,6 +1521,11 @@ class: ?[:0]const u8 = null, /// specifically output that key (e.g. `ctrl+a>ctrl+a=text:foo`) or /// press an unbound key which will send both keys to the program. /// +/// * If an unbound key is pressed during a sequence and a `catch_all` +/// binding exists that would `ignore` the input, the entire sequence +/// is dropped and nothing happens. Otherwise, the entire sequence is +/// encoded and sent to the running program as if no keybind existed. +/// /// * If a prefix in a sequence is previously bound, the sequence will /// override the previous binding. For example, if `ctrl+a` is bound to /// `new_window` and `ctrl+a>n` is bound to `new_tab`, pressing `ctrl+a` From 7ce88b6811065683d74405a66ab41d034c7c2bd1 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 24 Dec 2025 19:41:05 +0100 Subject: [PATCH 82/96] macOS: fix initial surface color scheme in quickTerminal --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index d79c89d2d..1750e949d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1298,7 +1298,7 @@ extension BaseTerminalController: NSMenuItemValidation { } else { scheme = GHOSTTY_COLOR_SCHEME_LIGHT } - guard scheme != appliedColorScheme else { + guard scheme != appliedColorScheme, !surfaceTree.isEmpty else { return } for surfaceView in surfaceTree { From 574ee470bd870314d6d13daef6668b84b0746873 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:29:14 +0100 Subject: [PATCH 83/96] macOS: move `NSGlassEffectView` into `TerminalViewContainer` --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../QuickTerminalController.swift | 13 +- .../Terminal/TerminalController.swift | 4 +- .../Terminal/TerminalViewContainer.swift | 127 ++++++++++++++++++ .../Window Styles/TerminalWindow.swift | 62 +-------- macos/Sources/Ghostty/Ghostty.Config.swift | 12 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 + 7 files changed, 155 insertions(+), 67 deletions(-) create mode 100644 macos/Sources/Features/Terminal/TerminalViewContainer.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 1a810e621..779d95e5b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -118,6 +118,7 @@ Features/Terminal/TerminalRestorable.swift, Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, + Features/Terminal/TerminalViewContainer.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", "Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib", diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 8a642034f..07c0c4c19 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -137,11 +137,11 @@ class QuickTerminalController: BaseTerminalController { } // Setup our content - window.contentView = NSHostingView(rootView: TerminalView( + window.contentView = TerminalViewContainer( ghostty: self.ghostty, viewModel: self, delegate: self - )) + ) // Clear out our frame at this point, the fixup from above is complete. if let qtWindow = window as? QuickTerminalWindow { @@ -609,7 +609,7 @@ class QuickTerminalController: BaseTerminalController { // If we have window transparency then set it transparent. Otherwise set it opaque. // Also check if the user has overridden transparency to be fully opaque. - if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 { + if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -617,7 +617,9 @@ class QuickTerminalController: BaseTerminalController { // Terminal.app more easily. window.backgroundColor = .white.withAlphaComponent(0.001) - ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) + if !derivedConfig.backgroundBlur.isGlassStyle { + ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) + } } else { window.isOpaque = true window.backgroundColor = .windowBackgroundColor @@ -722,6 +724,7 @@ class QuickTerminalController: BaseTerminalController { let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior let quickTerminalSize: QuickTerminalSize let backgroundOpacity: Double + let backgroundBlur: Ghostty.Config.BackgroundBlur init() { self.quickTerminalScreen = .main @@ -730,6 +733,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = .move self.quickTerminalSize = QuickTerminalSize() self.backgroundOpacity = 1.0 + self.backgroundBlur = .disabled } init(_ config: Ghostty.Config) { @@ -739,6 +743,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior self.quickTerminalSize = config.quickTerminalSize self.backgroundOpacity = config.backgroundOpacity + self.backgroundBlur = config.backgroundBlur } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index bccdd9c69..c5481851b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -936,11 +936,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Initialize our content view to the SwiftUI root - window.contentView = NSHostingView(rootView: TerminalView( + window.contentView = TerminalViewContainer( ghostty: self.ghostty, viewModel: self, delegate: self, - )) + ) // If we have a default size, we want to apply it. if let defaultSize { diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift new file mode 100644 index 000000000..f4e2fc080 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -0,0 +1,127 @@ +import AppKit +import SwiftUI + +/// Use this container to achieve a glass effect at the window level. +/// Modifying `NSThemeFrame` can sometimes be unpredictable. +class TerminalViewContainer: NSView { + private let terminalView: NSView + + /// Glass effect view for liquid glass background when transparency is enabled + private var glassEffectView: NSView? + private var derivedConfig: DerivedConfig + + init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) { + self.derivedConfig = DerivedConfig(config: ghostty.config) + self.terminalView = NSHostingView(rootView: TerminalView( + ghostty: ghostty, + viewModel: viewModel, + delegate: delegate + )) + super.init(frame: .zero) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + addSubview(terminalView) + terminalView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + terminalView.topAnchor.constraint(equalTo: topAnchor), + terminalView.leadingAnchor.constraint(equalTo: leadingAnchor), + terminalView.bottomAnchor.constraint(equalTo: bottomAnchor), + terminalView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: nil + ) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateGlassEffectIfNeeded() + } + + @objc private func ghosttyConfigDidChange(_ notification: Notification) { + guard let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + let newValue = DerivedConfig(config: config) + guard newValue != derivedConfig else { return } + derivedConfig = newValue + DispatchQueue.main.async(execute: updateGlassEffectIfNeeded) + } +} + +// MARK: Glass + +private extension TerminalViewContainer { +#if compiler(>=6.2) + @available(macOS 26.0, *) + func addGlassEffectViewIfNeeded() -> NSGlassEffectView? { + if let existed = glassEffectView as? NSGlassEffectView { + return existed + } + guard let themeFrameView = window?.contentView?.superview else { + return nil + } + let effectView = NSGlassEffectView() + addSubview(effectView, positioned: .below, relativeTo: terminalView) + effectView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + effectView.topAnchor.constraint(equalTo: topAnchor, constant: -themeFrameView.safeAreaInsets.top), + effectView.leadingAnchor.constraint(equalTo: leadingAnchor), + effectView.bottomAnchor.constraint(equalTo: bottomAnchor), + effectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + glassEffectView = effectView + return effectView + } +#endif // compiler(>=6.2) + + func updateGlassEffectIfNeeded() { +#if compiler(>=6.2) + guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { + glassEffectView?.removeFromSuperview() + glassEffectView = nil + return + } + guard let effectView = addGlassEffectViewIfNeeded() else { + return + } + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: + effectView.style = NSGlassEffectView.Style.regular + case .macosGlassClear: + effectView.style = NSGlassEffectView.Style.clear + default: + break + } + let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) + effectView.tintColor = backgroundColor + .withAlphaComponent(derivedConfig.backgroundOpacity) + if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat { + effectView.cornerRadius = cornerRadius + } +#endif // compiler(>=6.2) + } + + struct DerivedConfig: Equatable { + var backgroundOpacity: Double = 0 + var backgroundBlur: Ghostty.Config.BackgroundBlur + var backgroundColor: Color = .clear + + init(config: Ghostty.Config) { + self.backgroundBlur = config.backgroundBlur + self.backgroundOpacity = config.backgroundOpacity + self.backgroundColor = config.backgroundColor + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 4196df97f..9debd2cb3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -474,7 +474,7 @@ class TerminalWindow: NSWindow { let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && !forceOpaque && - surfaceConfig.backgroundOpacity < 1 + (surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle) { isOpaque = false @@ -483,15 +483,8 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) - // Add liquid glass behind terminal content - 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() - } - + // We don't need to set blur when using glass + if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -499,11 +492,6 @@ 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) } @@ -581,50 +569,6 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } - -#if compiler(>=6.2) - // MARK: Glass - - @available(macOS 26.0, *) - private func setupGlassLayer() { - // Remove existing glass effect view - 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 - let effectView = NSGlassEffectView() - - // Map Ghostty config to NSGlassEffectView style - switch derivedConfig.backgroundBlur { - case .macosGlassRegular: - effectView.style = NSGlassEffectView.Style.regular - case .macosGlassClear: - effectView.style = NSGlassEffectView.Style.clear - default: - // 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] - - // 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 - } -#endif // compiler(>=6.2) // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 7ea545f7a..71b9eb17d 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -648,9 +648,17 @@ extension Ghostty.Config { case 0: self = .disabled case -1: - self = .macosGlassRegular + if #available(macOS 26.0, *) { + self = .macosGlassRegular + } else { + self = .disabled + } case -2: - self = .macosGlassClear + if #available(macOS 26.0, *) { + self = .macosGlassClear + } else { + self = .disabled + } default: self = .radius(Int(value)) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 37cc9282e..77e1c43d4 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1654,6 +1654,7 @@ extension Ghostty { struct DerivedConfig { let backgroundColor: Color let backgroundOpacity: Double + let backgroundBlur: Ghostty.Config.BackgroundBlur let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? @@ -1662,6 +1663,7 @@ extension Ghostty { init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundOpacity = 1 + self.backgroundBlur = .disabled self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil @@ -1671,6 +1673,7 @@ extension Ghostty { init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor self.backgroundOpacity = config.backgroundOpacity + self.backgroundBlur = config.backgroundBlur self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) From 017021787c32f364b547d48d1911c40273d119d8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 24 Dec 2025 14:22:21 -0800 Subject: [PATCH 84/96] config: RepeatableCommand cval --- include/ghostty.h | 6 +++ src/config/Config.zig | 88 ++++++++++++++++++++++++++++++++++++++++--- src/input/command.zig | 23 ++++++++++- 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 48915b179..d6e6fba70 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -454,6 +454,12 @@ typedef struct { size_t len; } ghostty_config_color_list_s; +// config.RepeatableCommand +typedef struct { + const ghostty_command_s* commands; + size_t len; +} ghostty_config_command_list_s; + // config.Palette typedef struct { ghostty_config_color_s colors[256]; diff --git a/src/config/Config.zig b/src/config/Config.zig index 0df5c91b0..92caa5744 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -8041,15 +8041,37 @@ pub const SplitPreserveZoom = packed struct { }; pub const RepeatableCommand = struct { - value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + const Self = @This(); - pub fn init(self: *RepeatableCommand, alloc: Allocator) !void { + value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + value_c: std.ArrayListUnmanaged(inputpkg.Command.C) = .empty, + + /// ghostty_config_command_list_s + pub const C = extern struct { + commands: [*]inputpkg.Command.C, + len: usize, + }; + + pub fn cval(self: *const Self) C { + return .{ + .commands = self.value_c.items.ptr, + .len = self.value_c.items.len, + }; + } + + pub fn init(self: *Self, alloc: Allocator) !void { self.value = .empty; + self.value_c = .empty; + errdefer { + self.value.deinit(alloc); + self.value_c.deinit(alloc); + } try self.value.appendSlice(alloc, inputpkg.command.defaults); + try self.value_c.appendSlice(alloc, inputpkg.command.defaultsC); } pub fn parseCLI( - self: *RepeatableCommand, + self: *Self, alloc: Allocator, input_: ?[]const u8, ) !void { @@ -8057,26 +8079,36 @@ pub const RepeatableCommand = struct { const input = input_ orelse ""; if (input.len == 0) { self.value.clearRetainingCapacity(); + self.value_c.clearRetainingCapacity(); return; } + // Reserve space in our lists + try self.value.ensureUnusedCapacity(alloc, 1); + try self.value_c.ensureUnusedCapacity(alloc, 1); + const cmd = try cli.args.parseAutoStruct( inputpkg.Command, alloc, input, null, ); - try self.value.append(alloc, cmd); + const cmd_c = try cmd.cval(alloc); + self.value.appendAssumeCapacity(cmd); + self.value_c.appendAssumeCapacity(cmd_c); } /// Deep copy of the struct. Required by Config. - pub fn clone(self: *const RepeatableCommand, alloc: Allocator) Allocator.Error!RepeatableCommand { + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { const value = try self.value.clone(alloc); for (value.items) |*item| { item.* = try item.clone(alloc); } - return .{ .value = value }; + return .{ + .value = value, + .value_c = try self.value_c.clone(alloc), + }; } /// Compare if two of our value are equal. Required by Config. @@ -8232,6 +8264,50 @@ pub const RepeatableCommand = struct { try testing.expectEqualStrings("kurwa", item.action.text); } } + + test "RepeatableCommand cval" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle"); + + try testing.expectEqual(@as(usize, 2), list.value.items.len); + try testing.expectEqual(@as(usize, 2), list.value_c.items.len); + + const cv = list.cval(); + try testing.expectEqual(@as(usize, 2), cv.len); + + // First entry + try testing.expectEqualStrings("Foo", std.mem.sliceTo(cv.commands[0].title, 0)); + try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action_key, 0)); + try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action, 0)); + + // Second entry + try testing.expectEqualStrings("Bar", std.mem.sliceTo(cv.commands[1].title, 0)); + try testing.expectEqualStrings("bobr", std.mem.sliceTo(cv.commands[1].description, 0)); + try testing.expectEqualStrings("text", std.mem.sliceTo(cv.commands[1].action_key, 0)); + try testing.expectEqualStrings("text:ale bydle", std.mem.sliceTo(cv.commands[1].action, 0)); + } + + test "RepeatableCommand cval cleared" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try testing.expectEqual(@as(usize, 1), list.cval().len); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.cval().len); + } }; /// OSC 4, 10, 11, and 12 default color reporting format. diff --git a/src/input/command.zig b/src/input/command.zig index 67086f7ec..936f2211c 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -43,7 +43,7 @@ pub const Command = struct { return true; } - /// Convert this command to a C struct. + /// Convert this command to a C struct at comptime. pub fn comptimeCval(self: Command) C { assert(@inComptime()); @@ -55,6 +55,27 @@ pub const Command = struct { }; } + /// Convert this command to a C struct at runtime. + /// + /// This shares memory with the original command. + /// + /// The action string is allocated using the provided allocator. You can + /// free the slice directly if you need to but we recommend an arena + /// for this. + pub fn cval(self: Command, alloc: Allocator) Allocator.Error!C { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + self.action.format(&buf.writer) catch return error.OutOfMemory; + const action = try buf.toOwnedSliceSentinel(0); + + return .{ + .action_key = @tagName(self.action), + .action = action.ptr, + .title = self.title, + .description = self.description, + }; + } + /// Implements a comparison function for std.mem.sortUnstable /// and similar functions. The sorting is defined by Ghostty /// to be what we prefer. If a caller wants some other sorting, From 12523ca61c2d3d3dfbe0a0f36183e11b61e88d2e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 24 Dec 2025 14:28:12 -0800 Subject: [PATCH 85/96] macOS: command-palette-entry is now visible in macOS --- .../Command Palette/TerminalCommandPalette.swift | 15 ++++++++++++++- macos/Sources/Ghostty/Ghostty.Config.swift | 10 ++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 902186ad3..6efb588cd 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -64,7 +64,7 @@ struct TerminalCommandPaletteView: View { // Sort the rest. We replace ":" with a character that sorts before space // so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker // for stable ordering when titles are equal. - options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in + options.append(contentsOf: (jumpOptions + terminalOptions + customEntries).sorted { a, b in let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized) @@ -135,6 +135,19 @@ struct TerminalCommandPaletteView: View { } } + /// Custom commands from the command-palette-entry configuration. + private var customEntries: [CommandOption] { + guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] } + return appDelegate.ghostty.config.commandPaletteEntries.map { c in + CommandOption( + title: c.title, + description: c.description + ) { + onAction(c.action) + } + } + } + /// Commands for jumping to other terminal surfaces. private var jumpOptions: [CommandOption] { TerminalController.all.flatMap { controller -> [CommandOption] in diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 7ea545f7a..5aa79a149 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -622,6 +622,16 @@ extension Ghostty { let str = String(cString: ptr) return Scrollbar(rawValue: str) ?? defaultValue } + + var commandPaletteEntries: [Ghostty.Command] { + guard let config = self.config else { return [] } + var v: ghostty_config_command_list_s = .init() + let key = "command-palette-entry" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return [] } + guard v.len > 0 else { return [] } + let buffer = UnsafeBufferPointer(start: v.commands, count: v.len) + return buffer.map { Ghostty.Command(cValue: $0) } + } } } From f7f29934f30cb469d15f909fdd68466352e0e2c5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 24 Dec 2025 14:41:40 -0800 Subject: [PATCH 86/96] macos: ghostty.command should be part of iOS build --- macos/Ghostty.xcodeproj/project.pbxproj | 1 - 1 file changed, 1 deletion(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 1a810e621..91c2300cc 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -137,7 +137,6 @@ Features/Update/UpdateSimulator.swift, Features/Update/UpdateViewModel.swift, "Ghostty/FullscreenMode+Extension.swift", - Ghostty/Ghostty.Command.swift, Ghostty/Ghostty.Error.swift, Ghostty/Ghostty.Event.swift, Ghostty/Ghostty.Input.swift, From bf73f753048a4b4262b71a7aea5f6bfc75c2720e Mon Sep 17 00:00:00 2001 From: rezky_nightky Date: Fri, 26 Dec 2025 00:27:08 +0700 Subject: [PATCH 87/96] chore: fixed some typo Author: rezky_nightky Repository: ghostty Branch: main Signing: GPG (4B65AAC2) HashAlgo: BLAKE3 [ Block Metadata ] BlockHash: c37f4ee817412728a8058ba6087f5ca6aaff5a845560447d595d8055972d0eac PrevHash: 3510917a780936278debe21786b7bae3a2162cb3857957314c3b8702e921b3d4 PatchHash: 5e5bb4ab35df304ea13c3d297c6d9a965156052c82bccf852b1f00b7bcaa7dd4 FilesChanged: 18 Lines: +92 / -92 Timestamp: 2025-12-25T17:27:08Z Signature1: c1970dbb94600d1e24dfe8efcc00f001664db7b777902df9632a689b1d9d1498 Signature2: 30babb1e3ca07264931e067bfe36c676fb7988c2e06f8c54e0c9538fe7c7fc9a --- .../TitlebarTabsVenturaTerminalWindow.swift | 4 +- .../TransparentTitlebarTerminalWindow.swift | 32 +++--- macos/Sources/Ghostty/Ghostty.Input.swift | 100 +++++++++--------- macos/Sources/Helpers/Fullscreen.swift | 10 +- src/Surface.zig | 4 +- src/config/theme.zig | 2 +- src/font/Atlas.zig | 2 +- src/font/shaper/coretext.zig | 2 +- src/font/sprite/Face.zig | 2 +- src/input/Binding.zig | 2 +- src/input/key_encode.zig | 2 +- src/input/mouse.zig | 2 +- src/inspector/Inspector.zig | 2 +- src/renderer/generic.zig | 2 +- src/terminal/Terminal.zig | 2 +- src/terminal/kitty/graphics_unicode.zig | 4 +- src/terminal/ref_counted_set.zig | 8 +- src/terminal/tmux/viewer.zig | 2 +- 18 files changed, 92 insertions(+), 92 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index c0aad46b3..39db13c6d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -5,7 +5,7 @@ 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 @@ -395,7 +395,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // Hide the window drag handle. windowDragHandle?.isHidden = true - // Reenable the main toolbar title + // Re-enable the main toolbar title if let toolbar = toolbar as? TerminalToolbar { toolbar.titleIsHidden = false } diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 57b889b82..a72436d7f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -7,16 +7,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { /// This is necessary because various macOS operations (tab switching, tab bar /// visibility changes) can reset the titlebar appearance. private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig? - + /// KVO observation for tab group window changes. private var tabGroupWindowsObservation: NSKeyValueObservation? private var tabBarVisibleObservation: NSKeyValueObservation? - + deinit { tabGroupWindowsObservation?.invalidate() tabBarVisibleObservation?.invalidate() } - + // MARK: NSWindow override func awakeFromNib() { @@ -29,7 +29,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { override func becomeMain() { super.becomeMain() - + guard let lastSurfaceConfig else { return } syncAppearance(lastSurfaceConfig) @@ -42,7 +42,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { } } } - + override func update() { super.update() @@ -67,7 +67,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // Save our config in case we need to reapply lastSurfaceConfig = surfaceConfig - // Everytime we change appearance, set KVO up again in case any of our + // Every time we change appearance, set KVO up again in case any of our // references changed (e.g. tabGroup is new). setupKVO() @@ -99,7 +99,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { ? NSColor.clear.cgColor : preferredBackgroundColor?.cgColor } - + // In all cases, we have to hide the background view since this has multiple subviews // that force a background color. titlebarBackgroundView?.isHidden = true @@ -108,14 +108,14 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { @available(macOS 13.0, *) private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { guard let titlebarContainer else { return } - + // Setup the titlebar background color to match ours titlebarContainer.wantsLayer = true titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor - + // See the docs for the function that sets this to true on why effectViewIsHidden = false - + // Necessary to not draw the border around the title titlebarAppearsTransparent = true } @@ -141,7 +141,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // Remove existing observation if any tabGroupWindowsObservation?.invalidate() tabGroupWindowsObservation = nil - + // Check if tabGroup is available guard let tabGroup else { return } @@ -170,7 +170,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // Remove existing observation if any tabBarVisibleObservation?.invalidate() tabBarVisibleObservation = nil - + // Set up KVO observation for isTabBarVisible tabBarVisibleObservation = tabGroup?.observe( \.isTabBarVisible, @@ -181,18 +181,18 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { self.syncAppearance(lastSurfaceConfig) } } - + // MARK: macOS 13 to 15 - + // We only need to set this once, but need to do it after the window has been created in order // to determine if the theme is using a very dark background, in which case we don't want to // remove the effect view if the default tab bar is being used since the effect created in // `updateTabsForVeryDarkBackgrounds` creates a confusing visual design. private var effectViewIsHidden = false - + private func hideEffectView() { guard !effectViewIsHidden else { return } - + // By hiding the visual effect view, we allow the window's (or titlebar's in this case) // background color to show through. If we were to set `titlebarAppearsTransparent` to true // the selected tab would look fine, but the unselected ones and new tab button backgrounds diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 6b4eb0ae4..44011d5b9 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -68,7 +68,7 @@ extension Ghostty { if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue } // Handle sided input. We can't tell that both are pressed in the - // Ghostty structure but thats okay -- we don't use that information. + // Ghostty structure but that's okay -- we don't use that information. let rawFlags = flags.rawValue if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue } @@ -139,7 +139,7 @@ extension Ghostty.Input { case GHOSTTY_ACTION_REPEAT: self.action = .repeat default: self.action = .press } - + // Convert key from keycode guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil } self.key = key @@ -150,18 +150,18 @@ extension Ghostty.Input { } else { self.text = nil } - + // Set composing state self.composing = cValue.composing - + // Convert modifiers self.mods = Mods(cMods: cValue.mods) self.consumedMods = Mods(cMods: cValue.consumed_mods) - + // Set unshifted codepoint self.unshiftedCodepoint = cValue.unshifted_codepoint } - + /// Executes a closure with a temporary C representation of this KeyEvent. /// /// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct @@ -180,7 +180,7 @@ extension Ghostty.Input { keyEvent.mods = mods.cMods keyEvent.consumed_mods = consumedMods.cMods keyEvent.unshifted_codepoint = unshiftedCodepoint - + // Handle text with proper memory management if let text = text { return text.withCString { textPtr in @@ -203,7 +203,7 @@ extension Ghostty.Input { case release case press case `repeat` - + var cAction: ghostty_input_action_e { switch self { case .release: GHOSTTY_ACTION_RELEASE @@ -232,7 +232,7 @@ extension Ghostty.Input { let action: MouseState let button: MouseButton let mods: Mods - + init( action: MouseState, button: MouseButton, @@ -242,7 +242,7 @@ extension Ghostty.Input { self.button = button self.mods = mods } - + /// Creates a MouseEvent from C enum values. /// /// This initializer converts C-style mouse input enums to Swift types. @@ -259,7 +259,7 @@ extension Ghostty.Input { case GHOSTTY_MOUSE_PRESS: self.action = .press default: return nil } - + // Convert button switch button { case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown @@ -268,7 +268,7 @@ extension Ghostty.Input { case GHOSTTY_MOUSE_MIDDLE: self.button = .middle default: return nil } - + // Convert modifiers self.mods = Mods(cMods: mods) } @@ -279,7 +279,7 @@ extension Ghostty.Input { let x: Double let y: Double let mods: Mods - + init( x: Double, y: Double, @@ -316,7 +316,7 @@ extension Ghostty.Input { enum MouseState: String, CaseIterable { case release case press - + var cMouseState: ghostty_input_mouse_state_e { switch self { case .release: GHOSTTY_MOUSE_RELEASE @@ -344,7 +344,7 @@ extension Ghostty.Input { case left case right case middle - + var cMouseButton: ghostty_input_mouse_button_e { switch self { case .unknown: GHOSTTY_MOUSE_UNKNOWN @@ -382,18 +382,18 @@ extension Ghostty.Input { /// for scroll events, matching the Zig `ScrollMods` packed struct. struct ScrollMods { let rawValue: Int32 - + /// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse) var precision: Bool { rawValue & 0b0000_0001 != 0 } - + /// The momentum phase of the scroll event for inertial scrolling var momentum: Momentum { let momentumBits = (rawValue >> 1) & 0b0000_0111 return Momentum(rawValue: UInt8(momentumBits)) ?? .none } - + init(precision: Bool = false, momentum: Momentum = .none) { var value: Int32 = 0 if precision { @@ -402,11 +402,11 @@ extension Ghostty.Input { value |= Int32(momentum.rawValue) << 1 self.rawValue = value } - + init(rawValue: Int32) { self.rawValue = rawValue } - + var cScrollMods: ghostty_input_scroll_mods_t { rawValue } @@ -425,7 +425,7 @@ extension Ghostty.Input { case ended = 4 case cancelled = 5 case mayBegin = 6 - + var cMomentum: ghostty_input_mouse_momentum_e { switch self { case .none: GHOSTTY_MOUSE_MOMENTUM_NONE @@ -442,7 +442,7 @@ extension Ghostty.Input { extension Ghostty.Input.Momentum: AppEnum { static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scroll Momentum") - + static var caseDisplayRepresentations: [Ghostty.Input.Momentum : DisplayRepresentation] = [ .none: "None", .began: "Began", @@ -479,7 +479,7 @@ extension Ghostty.Input { /// `ghostty_input_mods_e` struct Mods: OptionSet { let rawValue: UInt32 - + static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue) static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue) static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue) @@ -490,23 +490,23 @@ extension Ghostty.Input { static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue) static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue) static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue) - + var cMods: ghostty_input_mods_e { ghostty_input_mods_e(rawValue) } - + init(rawValue: UInt32) { self.rawValue = rawValue } - + init(cMods: ghostty_input_mods_e) { self.rawValue = cMods.rawValue } - + init(nsFlags: NSEvent.ModifierFlags) { self.init(cMods: Ghostty.ghosttyMods(nsFlags)) } - + var nsFlags: NSEvent.ModifierFlags { Ghostty.eventModifierFlags(mods: cMods) } @@ -1120,43 +1120,43 @@ extension Ghostty.Input.Key: AppEnum { return [ // Letters (A-Z) .a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z, - + // Numbers (0-9) .digit0, .digit1, .digit2, .digit3, .digit4, .digit5, .digit6, .digit7, .digit8, .digit9, - + // Common Control Keys .space, .enter, .tab, .backspace, .escape, .delete, - + // Arrow Keys .arrowUp, .arrowDown, .arrowLeft, .arrowRight, - + // Navigation Keys .home, .end, .pageUp, .pageDown, .insert, - + // Function Keys (F1-F20) .f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12, .f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20, - + // Modifier Keys .shiftLeft, .shiftRight, .controlLeft, .controlRight, .altLeft, .altRight, .metaLeft, .metaRight, .capsLock, - + // Punctuation & Symbols .minus, .equal, .backquote, .bracketLeft, .bracketRight, .backslash, .semicolon, .quote, .comma, .period, .slash, - + // Numpad .numLock, .numpad0, .numpad1, .numpad2, .numpad3, .numpad4, .numpad5, .numpad6, .numpad7, .numpad8, .numpad9, .numpadAdd, .numpadSubtract, .numpadMultiply, .numpadDivide, .numpadDecimal, .numpadEqual, .numpadEnter, .numpadComma, - + // Media Keys .audioVolumeUp, .audioVolumeDown, .audioVolumeMute, - + // International Keys .intlBackslash, .intlRo, .intlYen, - + // Other .contextMenu ] @@ -1167,11 +1167,11 @@ extension Ghostty.Input.Key: AppEnum { .a: "A", .b: "B", .c: "C", .d: "D", .e: "E", .f: "F", .g: "G", .h: "H", .i: "I", .j: "J", .k: "K", .l: "L", .m: "M", .n: "N", .o: "O", .p: "P", .q: "Q", .r: "R", .s: "S", .t: "T", .u: "U", .v: "V", .w: "W", .x: "X", .y: "Y", .z: "Z", - + // Numbers (0-9) .digit0: "0", .digit1: "1", .digit2: "2", .digit3: "3", .digit4: "4", .digit5: "5", .digit6: "6", .digit7: "7", .digit8: "8", .digit9: "9", - + // Common Control Keys .space: "Space", .enter: "Enter", @@ -1179,26 +1179,26 @@ extension Ghostty.Input.Key: AppEnum { .backspace: "Backspace", .escape: "Escape", .delete: "Delete", - + // Arrow Keys .arrowUp: "Up Arrow", .arrowDown: "Down Arrow", .arrowLeft: "Left Arrow", .arrowRight: "Right Arrow", - + // Navigation Keys .home: "Home", .end: "End", .pageUp: "Page Up", .pageDown: "Page Down", .insert: "Insert", - + // Function Keys (F1-F20) .f1: "F1", .f2: "F2", .f3: "F3", .f4: "F4", .f5: "F5", .f6: "F6", .f7: "F7", .f8: "F8", .f9: "F9", .f10: "F10", .f11: "F11", .f12: "F12", .f13: "F13", .f14: "F14", .f15: "F15", .f16: "F16", .f17: "F17", .f18: "F18", .f19: "F19", .f20: "F20", - + // Modifier Keys .shiftLeft: "Left Shift", .shiftRight: "Right Shift", @@ -1209,7 +1209,7 @@ extension Ghostty.Input.Key: AppEnum { .metaLeft: "Left Command", .metaRight: "Right Command", .capsLock: "Caps Lock", - + // Punctuation & Symbols .minus: "Minus (-)", .equal: "Equal (=)", @@ -1222,7 +1222,7 @@ extension Ghostty.Input.Key: AppEnum { .comma: "Comma (,)", .period: "Period (.)", .slash: "Slash (/)", - + // Numpad .numLock: "Num Lock", .numpad0: "Numpad 0", .numpad1: "Numpad 1", .numpad2: "Numpad 2", @@ -1236,17 +1236,17 @@ extension Ghostty.Input.Key: AppEnum { .numpadEqual: "Numpad Equal", .numpadEnter: "Numpad Enter", .numpadComma: "Numpad Comma", - + // Media Keys .audioVolumeUp: "Volume Up", .audioVolumeDown: "Volume Down", .audioVolumeMute: "Volume Mute", - + // International Keys .intlBackslash: "International Backslash", .intlRo: "International Ro", .intlYen: "International Yen", - + // Other .contextMenu: "Context Menu" ] diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 78c967661..8ab476267 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -130,7 +130,7 @@ 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 } @@ -223,7 +223,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Being untitled let's our content take up the full frame. window.styleMask.remove(.titled) - // We dont' want the non-native fullscreen window to be resizable + // We don't want the non-native fullscreen window to be resizable // from the edges. window.styleMask.remove(.resizable) @@ -277,7 +277,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { if let window = window as? TerminalWindow, window.isTabBar(c) { continue } - + if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil { window.addTitlebarAccessoryViewController(c) } @@ -286,7 +286,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // Removing "titled" also clears our toolbar window.toolbar = savedState.toolbar window.toolbarStyle = savedState.toolbarStyle - + // If the window was previously in a tab group that isn't empty now, // we re-add it. We have to do this because our process of doing non-native // fullscreen removes the window from the tab group. @@ -412,7 +412,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.toolbar = window.toolbar self.toolbarStyle = window.toolbarStyle self.dock = window.screen?.hasDock ?? false - + self.titlebarAccessoryViewControllers = if (window.hasTitleBar) { // Accessing titlebarAccessoryViewControllers without a titlebar triggers a crash. window.titlebarAccessoryViewControllers diff --git a/src/Surface.zig b/src/Surface.zig index fd658a43b..c9223d0ad 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1222,7 +1222,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { break :gui false; }) return; - // If a native GUI notification was not showm. update our terminal to + // If a native GUI notification was not shown, update our terminal to // note the abnormal exit. self.childExitedAbnormally(info) catch |err| { log.err("error handling abnormal child exit err={}", .{err}); @@ -1232,7 +1232,7 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { return; } - // We output a message so that the user knows whats going on and + // We output a message so that the user knows what's going on and // doesn't think their terminal just froze. We show this unconditionally // on close even if `wait_after_command` is false and the surface closes // immediately because if a user does an `undo` to restore a closed diff --git a/src/config/theme.zig b/src/config/theme.zig index 7ba6e5885..8776fb1bf 100644 --- a/src/config/theme.zig +++ b/src/config/theme.zig @@ -221,7 +221,7 @@ pub fn open( // Unlikely scenario: the theme doesn't exist. In this case, we reset // our iterator, reiterate over in order to build a better error message. - // This does double allocate some memory but for errors I think thats + // This does double allocate some memory but for errors I think that's // fine. it.reset(); while (try it.next()) |loc| { diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 0648c0edf..7dcff8416 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -562,7 +562,7 @@ test "exact fit" { try testing.expectError(Error.AtlasFull, atlas.reserve(alloc, 1, 1)); } -test "doesnt fit" { +test "doesn't fit" { const alloc = testing.allocator; var atlas = try init(alloc, 32, .grayscale); defer atlas.deinit(alloc); diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 6b01d79aa..17d7801ff 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -52,7 +52,7 @@ pub const Shaper = struct { /// Cached attributes dict for creating CTTypesetter objects. /// The values in this never change so we can avoid overhead - /// by just creating it once and saving it for re-use. + /// by just creating it once and saving it for reuse. typesetter_attr_dict: *macos.foundation.Dictionary, /// List where we cache fonts, so we don't have to remake them for diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index 94bfa2f0b..596a92044 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -405,7 +405,7 @@ fn testDrawRanges( const padding_x = width / 4; const padding_y = height / 4; - // Canvas to draw glyphs on, we'll re-use this for all glyphs. + // Canvas to draw glyphs on, we'll reuse this for all glyphs. var canvas = try font.sprite.Canvas.init( alloc, width, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 83c6ef38f..e7507b112 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2020,7 +2020,7 @@ pub const Set = struct { /// /// `buffer_stream` is a FixedBufferStream used for temporary storage /// that is shared between calls to nested levels of the set. - /// For example, 'a>b>c=x' and 'a>b>d=y' will re-use the 'a>b' written + /// For example, 'a>b>c=x' and 'a>b>d=y' will reuse the 'a>b' written /// to the buffer before flushing it to the formatter with 'c=x' and 'd=y'. pub fn formatEntries( self: Value, diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index 736df58a0..3716c226e 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -153,7 +153,7 @@ fn kitty( // 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 + // what's happening. See legacy()'s similar logic for more details // on how to verify this. if (event.utf8.len > 0) utf8: { switch (event.key) { diff --git a/src/input/mouse.zig b/src/input/mouse.zig index 2be2b9a26..bdf967ed2 100644 --- a/src/input/mouse.zig +++ b/src/input/mouse.zig @@ -10,7 +10,7 @@ pub const ButtonState = enum(c_int) { press, }; -/// Possible mouse buttons. We only track up to 11 because thats the maximum +/// Possible mouse buttons. We only track up to 11 because that's the maximum /// button input that terminal mouse tracking handles without becoming /// ambiguous. /// diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 86a7b473c..dc498b58d 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -1213,7 +1213,7 @@ fn renderTermioWindow(self: *Inspector) void { cimgui.c.igText("%s", ev.str.ptr); // If the event is selected, we render info about it. For now - // we put this in the last column because thats the widest and + // we put this in the last column because that's the widest and // imgui has no way to make a column span. if (ev.imgui_selected) { { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 39eec7b43..4ebe501f7 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2099,7 +2099,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // We also need to reset the shaper cache so shaper info - // from the previous font isn't re-used for the new font. + // from the previous font isn't reused for the new font. const font_shaper_cache = font.ShaperCache.init(); self.font_shaper_cache.deinit(self.alloc); self.font_shaper_cache = font_shaper_cache; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 6c9d8b585..8bb167cd1 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -675,7 +675,7 @@ fn printCell( // TODO: this case was not handled in the old terminal implementation // but it feels like we should do something. investigate other - // terminals (xterm mainly) and see whats up. + // terminals (xterm mainly) and see what's up. .spacer_head => {}, } } diff --git a/src/terminal/kitty/graphics_unicode.zig b/src/terminal/kitty/graphics_unicode.zig index ceadf63ee..a223797ba 100644 --- a/src/terminal/kitty/graphics_unicode.zig +++ b/src/terminal/kitty/graphics_unicode.zig @@ -256,7 +256,7 @@ pub const Placement = struct { if (img_scale_source.y < img_scaled.y_offset) { // If our source rect y is within the offset area, we need to // adjust our source rect and destination since the source texture - // doesnt actually have the offset area blank. + // doesn't actually have the offset area blank. const offset: f64 = img_scaled.y_offset - img_scale_source.y; img_scale_source.height -= offset; y_offset = offset; @@ -286,7 +286,7 @@ pub const Placement = struct { if (img_scale_source.x < img_scaled.x_offset) { // If our source rect x is within the offset area, we need to // adjust our source rect and destination since the source texture - // doesnt actually have the offset area blank. + // doesn't actually have the offset area blank. const offset: f64 = img_scaled.x_offset - img_scale_source.x; img_scale_source.width -= offset; x_offset = offset; diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index e67682ff5..883dd2f0d 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -215,7 +215,7 @@ pub fn RefCountedSet( OutOfMemory, /// The set needs to be rehashed, as there are many dead - /// items with lower IDs which are inaccessible for re-use. + /// items with lower IDs which are inaccessible for reuse. NeedsRehash, }; @@ -437,7 +437,7 @@ pub fn RefCountedSet( } /// Delete an item, removing any references from - /// the table, and freeing its ID to be re-used. + /// the table, and freeing its ID to be reused. fn deleteItem(self: *Self, base: anytype, id: Id, ctx: Context) void { const table = self.table.ptr(base); const items = self.items.ptr(base); @@ -585,7 +585,7 @@ pub fn RefCountedSet( const item = &items[id]; // If there's a dead item then we resurrect it - // for our value so that we can re-use its ID, + // for our value so that we can reuse its ID, // unless its ID is greater than the one we're // given (i.e. prefer smaller IDs). if (item.meta.ref == 0) { @@ -645,7 +645,7 @@ pub fn RefCountedSet( } // Our chosen ID may have changed if we decided - // to re-use a dead item's ID, so we make sure + // to reuse a dead item's ID, so we make sure // the chosen bucket contains the correct ID. table[new_item.meta.bucket] = chosen_id; diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 0fcaaf207..62a0f1d00 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -208,7 +208,7 @@ pub const Viewer = struct { /// 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. + /// never reuses window IDs within a server process lifetime. windows: []const Window, pub fn format(self: Action, writer: *std.Io.Writer) !void { From f54ac110802a71ac26b7e6c62008085425a7da09 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 25 Dec 2025 13:36:43 -0800 Subject: [PATCH 88/96] terminal: search will re-scroll to navigate to a single match Fixes #9958 Replaces #9989 This changes the search navigation logic to always scroll if there is a selected search result so long as the search result isn't already within the viewport. --- src/terminal/PageList.zig | 9 +++++++ src/terminal/search/Thread.zig | 48 +++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9e14e2a75..07b264ef5 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3821,6 +3821,15 @@ pub const PageIterator = struct { pub fn fullPage(self: Chunk) bool { return self.start == 0 and self.end == self.node.data.size.rows; } + + /// Returns true if this chunk overlaps with the given other chunk + /// in any way. + pub fn overlaps(self: Chunk, other: Chunk) bool { + if (self.node != other.node) return false; + if (self.end <= other.start) return false; + if (self.start >= other.end) return false; + return true; + } }; }; diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 8f2d73f16..3f5377417 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -257,18 +257,46 @@ fn select(self: *Thread, sel: ScreenSearch.Select) !void { self.opts.mutex.lock(); defer self.opts.mutex.unlock(); - // The selection will trigger a selection change notification - // if it did change. - 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.* }); + // Make the selection. Ignore the result because we don't + // care if the selection didn't change. + _ = try screen_search.select(sel); + + // Grab our match if we have one. If we don't have a selection + // then we do nothing. + const flattened = screen_search.selectedMatch() orelse return; + + // No matter what we reset our selected match cache. This will + // trigger a callback which will trigger the renderer to wake up + // so it can be notified the screen scrolled. + s.last_screen.selected = null; + + // Grab the current screen and see if this match is visible within + // the viewport already. If it is, we do nothing. + const screen = self.opts.terminal.screens.get( + s.last_screen.key, + ) orelse return; + + // Grab the viewport. Viewports and selections are usually small + // so this check isn't very expensive, despite appearing O(N^2), + // both Ns are usually equal to 1. + var it = screen.pages.pageIterator( + .right_down, + .{ .viewport = .{} }, + null, + ); + const hl_chunks = flattened.chunks.slice(); + while (it.next()) |chunk| { + for (0..hl_chunks.len) |i| { + const hl_chunk = hl_chunks.get(i); + if (chunk.overlaps(.{ + .node = hl_chunk.node, + .start = hl_chunk.start, + .end = hl_chunk.end, + })) return; } } + + screen.scroll(.{ .pin = flattened.startPin() }); } /// Change the search term to the given value. From 2415116ad0680c9ae2d18f8445e067b24830ca49 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 25 Dec 2025 13:52:59 -0800 Subject: [PATCH 89/96] Revert "macOS: move `NSGlassEffectView` into `TerminalViewContainer` (#10046)" This reverts commit b8490f40c5a88d3ef879043d05a5493f24e123d4, reversing changes made to 050278feaeb79fe9d849fca48e445acadcd74397. --- macos/Ghostty.xcodeproj/project.pbxproj | 1 - .../QuickTerminalController.swift | 13 +- .../Terminal/BaseTerminalController.swift | 2 +- .../Terminal/TerminalController.swift | 4 +- .../Terminal/TerminalViewContainer.swift | 127 ------------------ .../Window Styles/TerminalWindow.swift | 62 ++++++++- macos/Sources/Ghostty/Ghostty.Config.swift | 12 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 - 8 files changed, 68 insertions(+), 156 deletions(-) delete mode 100644 macos/Sources/Features/Terminal/TerminalViewContainer.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index feda1bed0..91c2300cc 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -118,7 +118,6 @@ Features/Terminal/TerminalRestorable.swift, Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, - Features/Terminal/TerminalViewContainer.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", "Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib", diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 07c0c4c19..8a642034f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -137,11 +137,11 @@ class QuickTerminalController: BaseTerminalController { } // Setup our content - window.contentView = TerminalViewContainer( + window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, viewModel: self, delegate: self - ) + )) // Clear out our frame at this point, the fixup from above is complete. if let qtWindow = window as? QuickTerminalWindow { @@ -609,7 +609,7 @@ class QuickTerminalController: BaseTerminalController { // If we have window transparency then set it transparent. Otherwise set it opaque. // Also check if the user has overridden transparency to be fully opaque. - if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) { + if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -617,9 +617,7 @@ class QuickTerminalController: BaseTerminalController { // Terminal.app more easily. window.backgroundColor = .white.withAlphaComponent(0.001) - if !derivedConfig.backgroundBlur.isGlassStyle { - ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) - } + ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) } else { window.isOpaque = true window.backgroundColor = .windowBackgroundColor @@ -724,7 +722,6 @@ class QuickTerminalController: BaseTerminalController { let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior let quickTerminalSize: QuickTerminalSize let backgroundOpacity: Double - let backgroundBlur: Ghostty.Config.BackgroundBlur init() { self.quickTerminalScreen = .main @@ -733,7 +730,6 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = .move self.quickTerminalSize = QuickTerminalSize() self.backgroundOpacity = 1.0 - self.backgroundBlur = .disabled } init(_ config: Ghostty.Config) { @@ -743,7 +739,6 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior self.quickTerminalSize = config.quickTerminalSize self.backgroundOpacity = config.backgroundOpacity - self.backgroundBlur = config.backgroundBlur } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1750e949d..d79c89d2d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1298,7 +1298,7 @@ extension BaseTerminalController: NSMenuItemValidation { } else { scheme = GHOSTTY_COLOR_SCHEME_LIGHT } - guard scheme != appliedColorScheme, !surfaceTree.isEmpty else { + guard scheme != appliedColorScheme else { return } for surfaceView in surfaceTree { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c5481851b..bccdd9c69 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -936,11 +936,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Initialize our content view to the SwiftUI root - window.contentView = TerminalViewContainer( + window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, viewModel: self, delegate: self, - ) + )) // If we have a default size, we want to apply it. if let defaultSize { diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift deleted file mode 100644 index f4e2fc080..000000000 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ /dev/null @@ -1,127 +0,0 @@ -import AppKit -import SwiftUI - -/// Use this container to achieve a glass effect at the window level. -/// Modifying `NSThemeFrame` can sometimes be unpredictable. -class TerminalViewContainer: NSView { - private let terminalView: NSView - - /// Glass effect view for liquid glass background when transparency is enabled - private var glassEffectView: NSView? - private var derivedConfig: DerivedConfig - - init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) { - self.derivedConfig = DerivedConfig(config: ghostty.config) - self.terminalView = NSHostingView(rootView: TerminalView( - ghostty: ghostty, - viewModel: viewModel, - delegate: delegate - )) - super.init(frame: .zero) - setup() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setup() { - addSubview(terminalView) - terminalView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - terminalView.topAnchor.constraint(equalTo: topAnchor), - terminalView.leadingAnchor.constraint(equalTo: leadingAnchor), - terminalView.bottomAnchor.constraint(equalTo: bottomAnchor), - terminalView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - - NotificationCenter.default.addObserver( - self, - selector: #selector(ghosttyConfigDidChange(_:)), - name: .ghosttyConfigDidChange, - object: nil - ) - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - updateGlassEffectIfNeeded() - } - - @objc private func ghosttyConfigDidChange(_ notification: Notification) { - guard let config = notification.userInfo?[ - Notification.Name.GhosttyConfigChangeKey - ] as? Ghostty.Config else { return } - let newValue = DerivedConfig(config: config) - guard newValue != derivedConfig else { return } - derivedConfig = newValue - DispatchQueue.main.async(execute: updateGlassEffectIfNeeded) - } -} - -// MARK: Glass - -private extension TerminalViewContainer { -#if compiler(>=6.2) - @available(macOS 26.0, *) - func addGlassEffectViewIfNeeded() -> NSGlassEffectView? { - if let existed = glassEffectView as? NSGlassEffectView { - return existed - } - guard let themeFrameView = window?.contentView?.superview else { - return nil - } - let effectView = NSGlassEffectView() - addSubview(effectView, positioned: .below, relativeTo: terminalView) - effectView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - effectView.topAnchor.constraint(equalTo: topAnchor, constant: -themeFrameView.safeAreaInsets.top), - effectView.leadingAnchor.constraint(equalTo: leadingAnchor), - effectView.bottomAnchor.constraint(equalTo: bottomAnchor), - effectView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - glassEffectView = effectView - return effectView - } -#endif // compiler(>=6.2) - - func updateGlassEffectIfNeeded() { -#if compiler(>=6.2) - guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { - glassEffectView?.removeFromSuperview() - glassEffectView = nil - return - } - guard let effectView = addGlassEffectViewIfNeeded() else { - return - } - switch derivedConfig.backgroundBlur { - case .macosGlassRegular: - effectView.style = NSGlassEffectView.Style.regular - case .macosGlassClear: - effectView.style = NSGlassEffectView.Style.clear - default: - break - } - let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) - effectView.tintColor = backgroundColor - .withAlphaComponent(derivedConfig.backgroundOpacity) - if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat { - effectView.cornerRadius = cornerRadius - } -#endif // compiler(>=6.2) - } - - struct DerivedConfig: Equatable { - var backgroundOpacity: Double = 0 - var backgroundBlur: Ghostty.Config.BackgroundBlur - var backgroundColor: Color = .clear - - init(config: Ghostty.Config) { - self.backgroundBlur = config.backgroundBlur - self.backgroundOpacity = config.backgroundOpacity - self.backgroundColor = config.backgroundColor - } - } -} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 9debd2cb3..4196df97f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -474,7 +474,7 @@ class TerminalWindow: NSWindow { let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && !forceOpaque && - (surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle) + surfaceConfig.backgroundOpacity < 1 { isOpaque = false @@ -483,8 +483,15 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) - // We don't need to set blur when using glass - if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate { + // Add liquid glass behind terminal content + 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()) @@ -492,6 +499,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) } @@ -569,6 +581,50 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } + +#if compiler(>=6.2) + // MARK: Glass + + @available(macOS 26.0, *) + private func setupGlassLayer() { + // Remove existing glass effect view + 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 + let effectView = NSGlassEffectView() + + // Map Ghostty config to NSGlassEffectView style + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: + effectView.style = NSGlassEffectView.Style.regular + case .macosGlassClear: + effectView.style = NSGlassEffectView.Style.clear + default: + // 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] + + // 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 + } +#endif // compiler(>=6.2) // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index b3a8700e9..5aa79a149 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -658,17 +658,9 @@ extension Ghostty.Config { case 0: self = .disabled case -1: - if #available(macOS 26.0, *) { - self = .macosGlassRegular - } else { - self = .disabled - } + self = .macosGlassRegular case -2: - if #available(macOS 26.0, *) { - self = .macosGlassClear - } else { - self = .disabled - } + self = .macosGlassClear default: self = .radius(Int(value)) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 77e1c43d4..37cc9282e 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1654,7 +1654,6 @@ extension Ghostty { struct DerivedConfig { let backgroundColor: Color let backgroundOpacity: Double - let backgroundBlur: Ghostty.Config.BackgroundBlur let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? @@ -1663,7 +1662,6 @@ extension Ghostty { init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundOpacity = 1 - self.backgroundBlur = .disabled self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil @@ -1673,7 +1671,6 @@ extension Ghostty { init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor self.backgroundOpacity = config.backgroundOpacity - self.backgroundBlur = config.backgroundBlur self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) From 88e471e015083e6970b81e66e3e6b4719004d5ed Mon Sep 17 00:00:00 2001 From: Zongyuan Li Date: Fri, 26 Dec 2025 18:33:00 +0800 Subject: [PATCH 90/96] fix(iOS): fix iOS app startup failure Fixes #7643 This commit address the issue with 3 minor fixes: 1. Initialize ghostty lib before app start, or global allocator will be null. 2. `addSublayer` should be called on CALayer object, which is the property 'layer' of UIView 3. According to apple's [document](https://developer.apple.com/documentation/metal/mtlstoragemode/managed?language=objc), managed storage mode is not supported by iOS. So always use shared mode. FYI, another [fix](https://github.com/mitchellh/libxev/pull/204) in libxev is also required to make iOS app work. --- macos/Sources/App/iOS/iOSApp.swift | 10 +++++++++- src/renderer/Metal.zig | 10 +++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/macos/Sources/App/iOS/iOSApp.swift b/macos/Sources/App/iOS/iOSApp.swift index 4af94491c..a1aafcc7d 100644 --- a/macos/Sources/App/iOS/iOSApp.swift +++ b/macos/Sources/App/iOS/iOSApp.swift @@ -1,8 +1,16 @@ import SwiftUI +import GhosttyKit @main struct Ghostty_iOSApp: App { - @StateObject private var ghostty_app = Ghostty.App() + @StateObject private var ghostty_app: Ghostty.App + + init() { + if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCESS { + preconditionFailure("Initialize ghostty backend failed") + } + _ghostty_app = StateObject(wrappedValue: Ghostty.App()) + } var body: some Scene { WindowGroup { diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 2aac285c6..6c7432d21 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -76,8 +76,11 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { errdefer queue.release(); // Grab metadata about the device. - const default_storage_mode: mtl.MTLResourceOptions.StorageMode = - if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed; + const default_storage_mode: mtl.MTLResourceOptions.StorageMode = switch (comptime builtin.os.tag) { + // manage mode is not supported by iOS + .ios => .shared, + else => if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed, + }; const max_texture_size = queryMaxTextureSize(device); log.debug( "device properties default_storage_mode={} max_texture_size={}", @@ -123,7 +126,8 @@ pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { }, .ios => { - info.view.msgSend(void, objc.sel("addSublayer"), .{layer.layer.value}); + const view_layer = objc.Object.fromId(info.view.getProperty(?*anyopaque, "layer")); + view_layer.msgSend(void, objc.sel("addSublayer:"), .{layer.layer.value}); }, else => @compileError("unsupported target for Metal"), From 79cc22e18685709aa2d8e1657a7492e5f9030d6d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 07:28:41 -0800 Subject: [PATCH 91/96] terminal: fix crash when sliding window sees empty node Related to #10063 This fixes a crash that can happen if the SlidingWindow search portion sees a zero-byte page. We have more fixes to implement in the circular buffer handling but putting the fix at this layer also prevents some unnecessary allocations for zero-byte data. --- src/terminal/search/sliding_window.zig | 39 +++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 3d64042ce..c3c29e085 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -575,9 +575,16 @@ pub const SlidingWindow = struct { ); } + // If our written data is empty, then there is nothing to + // add to our data set. + const written = encoded.written(); + if (written.len == 0) { + self.assertIntegrity(); + return 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 => { @@ -1637,3 +1644,33 @@ test "SlidingWindow single append reversed soft wrapped" { try testing.expect(w.next() == null); try testing.expect(w.next() == null); } + +// This tests a real bug that occurred where a whitespace-only page +// that encodes to zero bytes would crash. +test "SlidingWindow append whitespace only node" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "x"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }); + defer s.deinit(); + + // By setting the empty page to wrap we get a zero-byte page. + // This is invasive but its otherwise hard to reproduce naturally + // without creating a slow test. + const node: *PageList.List.Node = s.pages.pages.first.?; + const last_row = node.data.getRow(node.data.size.rows - 1); + last_row.wrap = true; + + try testing.expect(s.pages.pages.first == s.pages.pages.last); + _ = try w.append(node); + + // No matches expected + try testing.expect(w.next() == null); +} From eb5d2e034bed4875deb84278aa3db9d34b29c243 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 10:33:50 -0800 Subject: [PATCH 92/96] datastruct/circ_buf: fix n=0 edge cases From #10063 This fixes and tests various edge cases around noop operations. --- src/datastruct/circ_buf.zig | 85 +++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index 0caa9e85d..3e373cb94 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -217,6 +217,13 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { pub fn deleteOldest(self: *Self, n: usize) void { assert(n <= self.storage.len); + // Special case n == 0 otherwise we will accidentally break + // our circular buffer. + if (n == 0) { + @branchHint(.cold); + return; + } + // Clear the values back to default const slices = self.getPtrSlice(0, n); inline for (slices) |slice| @memset(slice, default); @@ -233,6 +240,12 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { /// the end of our buffer. This never "rotates" the buffer because /// the offset can only be within the size of the buffer. pub fn getPtrSlice(self: *Self, offset: usize, slice_len: usize) [2][]T { + // Special case the empty slice fast-path. + if (slice_len == 0) { + @branchHint(.cold); + return .{ &.{}, &.{} }; + } + // Note: this assertion is very important, it hints the compiler // which generates ~10% faster code than without it. assert(offset + slice_len <= self.capacity()); @@ -779,3 +792,75 @@ test "CircBuf resize shrink" { try testing.expectEqual(@as(u8, 3), slices[0][2]); } } + +test "CircBuf append empty slice" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 5); + defer buf.deinit(alloc); + + // Appending an empty slice to empty buffer should be a no-op + buf.appendSliceAssumeCapacity(""); + try testing.expectEqual(@as(usize, 0), buf.len()); + try testing.expect(!buf.full); + + // Buffer should still work normally after appending empty slice + buf.appendSliceAssumeCapacity("hi"); + try testing.expectEqual(@as(usize, 2), buf.len()); + + // Appending an empty slice to non-empty buffer should also be a no-op + buf.appendSliceAssumeCapacity(""); + try testing.expectEqual(@as(usize, 2), buf.len()); +} + +test "CircBuf getPtrSlice zero length" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 5); + defer buf.deinit(alloc); + + // getPtrSlice with zero length on empty buffer should return empty slices + const slices = buf.getPtrSlice(0, 0); + try testing.expectEqual(@as(usize, 0), slices[0].len); + try testing.expectEqual(@as(usize, 0), slices[1].len); + try testing.expectEqual(@as(usize, 0), buf.len()); + + // Fill buffer partially + buf.appendSliceAssumeCapacity("abc"); + try testing.expectEqual(@as(usize, 3), buf.len()); + + // getPtrSlice with zero length on non-empty buffer should also work + const slices2 = buf.getPtrSlice(0, 0); + try testing.expectEqual(@as(usize, 0), slices2[0].len); + try testing.expectEqual(@as(usize, 0), slices2[1].len); + try testing.expectEqual(@as(usize, 3), buf.len()); +} + +test "CircBuf deleteOldest zero" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 5); + defer buf.deinit(alloc); + + // deleteOldest(0) on empty buffer should be a no-op + buf.deleteOldest(0); + try testing.expectEqual(@as(usize, 0), buf.len()); + + // Fill buffer + buf.appendSliceAssumeCapacity("hello"); + try testing.expectEqual(@as(usize, 5), buf.len()); + + // deleteOldest(0) on non-empty buffer should be a no-op + buf.deleteOldest(0); + try testing.expectEqual(@as(usize, 5), buf.len()); + + // Verify data is unchanged + var it = buf.iterator(.forward); + try testing.expectEqual(@as(u8, 'h'), it.next().?.*); +} From 26b104c9e0cc2c013480cc378cf62836cc24d64f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 10:43:35 -0800 Subject: [PATCH 93/96] terminal: Fix possible crash on RenderState with invalid mouse point Fixes #10032 --- src/terminal/render.zig | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 093476f2c..9d75fe4b7 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -816,6 +816,12 @@ pub const RenderState = struct { const row_pins = row_slice.items(.pin); const row_cells = row_slice.items(.cells); + // Our viewport point is sent in by the caller and can't be trusted. + // If it is outside the valid area then just return empty because + // we can't possibly have a link there. + if (viewport_point.x >= self.cols or + viewport_point.y >= row_pins.len) return result; + // Grab our link ID const link_pin: PageList.Pin = row_pins[viewport_point.y]; const link_page: *page.Page = &link_pin.node.data; @@ -1360,6 +1366,44 @@ test "linkCells with scrollback spanning pages" { try testing.expectEqual(@as(usize, 4), cells.count()); } +test "linkCells with invalid viewport point" { + 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); + try state.update(alloc, &t); + + // Row out of bound + { + var cells = try state.linkCells( + alloc, + .{ .x = 0, .y = t.rows + 10 }, + ); + defer cells.deinit(alloc); + try testing.expectEqual(0, cells.count()); + } + + // Col out of bound + { + var cells = try state.linkCells( + alloc, + .{ .x = t.cols + 10, .y = 0 }, + ); + defer cells.deinit(alloc); + try testing.expectEqual(0, cells.count()); + } +} + test "dirty row resets highlights" { const testing = std.testing; const alloc = testing.allocator; From 14f592b8d4651c1257763fbcb7ab04f96988bb8d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 26 Dec 2025 11:01:44 -0800 Subject: [PATCH 94/96] macOS: Don't duplicate command palette entries for terminal commands This is a regression introduced when we added macOS support for custom entries. I mistakingly thought that only custom entries were in the config, but we do initialize it with all! --- include/ghostty.h | 1 - .../App Intents/Entities/CommandEntity.swift | 23 ++++++------- .../TerminalCommandPalette.swift | 32 ++++--------------- macos/Sources/Ghostty/Ghostty.Surface.swift | 11 ------- src/apprt/embedded.zig | 17 ---------- 5 files changed, 19 insertions(+), 65 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index d6e6fba70..0ad15cf69 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -1050,7 +1050,6 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_color_scheme_e); ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); -void ghostty_surface_commands(ghostty_surface_t, ghostty_command_s**, size_t*); bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); diff --git a/macos/Sources/Features/App Intents/Entities/CommandEntity.swift b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift index f7abcc6de..3c7745e7c 100644 --- a/macos/Sources/Features/App Intents/Entities/CommandEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift @@ -1,4 +1,5 @@ import AppIntents +import Cocoa // MARK: AppEntity @@ -94,23 +95,23 @@ struct CommandQuery: EntityQuery { @MainActor func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] { + guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] } + let commands = appDelegate.ghostty.config.commandPaletteEntries + // Extract unique terminal IDs to avoid fetching duplicates let terminalIds = Set(identifiers.map(\.terminalId)) let terminals = try await TerminalEntity.defaultQuery.entities(for: Array(terminalIds)) - // Build a cache of terminals and their available commands - // This avoids repeated command fetching for the same terminal - typealias Tuple = (terminal: TerminalEntity, commands: [Ghostty.Command]) - let commandMap: [TerminalEntity.ID: Tuple] = + // Build a lookup from terminal ID to terminal entity + let terminalMap: [TerminalEntity.ID: TerminalEntity] = terminals.reduce(into: [:]) { result, terminal in - guard let commands = try? terminal.surfaceModel?.commands() else { return } - result[terminal.id] = (terminal: terminal, commands: commands) + result[terminal.id] = terminal } - + // Map each identifier to its corresponding CommandEntity. If a command doesn't // exist it maps to nil and is removed via compactMap. return identifiers.compactMap { id in - guard let (terminal, commands) = commandMap[id.terminalId], + guard let terminal = terminalMap[id.terminalId], let command = commands.first(where: { $0.actionKey == id.actionKey }) else { return nil } @@ -121,8 +122,8 @@ struct CommandQuery: EntityQuery { @MainActor func suggestedEntities() async throws -> [CommandEntity] { - guard let terminal = commandPaletteIntent?.terminal, - let surface = terminal.surfaceModel else { return [] } - return try surface.commands().map { CommandEntity($0, for: terminal) } + guard let appDelegate = NSApp.delegate as? AppDelegate, + let terminal = commandPaletteIntent?.terminal else { return [] } + return appDelegate.ghostty.config.commandPaletteEntries.map { CommandEntity($0, for: terminal) } } } diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 6efb588cd..e0237f257 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -64,7 +64,7 @@ struct TerminalCommandPaletteView: View { // Sort the rest. We replace ":" with a character that sorts before space // so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker // for stable ordering when titles are equal. - options.append(contentsOf: (jumpOptions + terminalOptions + customEntries).sorted { a, b in + options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized) @@ -83,11 +83,11 @@ struct TerminalCommandPaletteView: View { /// Commands for installing or canceling available updates. private var updateOptions: [CommandOption] { var options: [CommandOption] = [] - + guard let updateViewModel, updateViewModel.state.isInstallable else { return options } - + // We override the update available one only because we want to properly // convey it'll go all the way through. let title: String @@ -96,7 +96,7 @@ struct TerminalCommandPaletteView: View { } else { title = updateViewModel.text } - + options.append(CommandOption( title: title, description: updateViewModel.description, @@ -106,37 +106,19 @@ struct TerminalCommandPaletteView: View { ) { (NSApp.delegate as? AppDelegate)?.updateController.installUpdate() }) - + options.append(CommandOption( title: "Cancel or Skip Update", description: "Dismiss the current update process" ) { updateViewModel.state.cancel() }) - + return options } - /// Commands exposed by the terminal surface. - private var terminalOptions: [CommandOption] { - guard let surface = surfaceView.surfaceModel else { return [] } - do { - return try surface.commands().map { c in - CommandOption( - title: c.title, - description: c.description, - symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, - ) { - onAction(c.action) - } - } - } catch { - return [] - } - } - /// Custom commands from the command-palette-entry configuration. - private var customEntries: [CommandOption] { + private var terminalOptions: [CommandOption] { guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] } return appDelegate.ghostty.config.commandPaletteEntries.map { c in CommandOption( diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index c7198e147..e86952e50 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -134,16 +134,5 @@ extension Ghostty { ghostty_surface_binding_action(surface, cString, UInt(len - 1)) } } - - /// Command options for this surface. - @MainActor - func commands() throws -> [Command] { - var ptr: UnsafeMutablePointer? = nil - var count: Int = 0 - ghostty_surface_commands(surface, &ptr, &count) - guard let ptr else { throw Error.apiFailed } - let buffer = UnsafeBufferPointer(start: ptr, count: count) - return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported } - } } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 1cb9231bc..64900cef1 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1700,23 +1700,6 @@ pub const CAPI = struct { return @intCast(@as(input.Mods.Backing, @bitCast(result))); } - /// Returns the current possible commands for a surface - /// in the output parameter. The memory is owned by libghostty - /// and doesn't need to be freed. - export fn ghostty_surface_commands( - surface: *Surface, - out: *[*]const input.Command.C, - len: *usize, - ) void { - // In the future we may use this information to filter - // some commands. - _ = surface; - - const commands = input.command.defaultsC; - out.* = commands.ptr; - len.* = commands.len; - } - /// Send this for raw keypresses (i.e. the keyDown event on macOS). /// This will handle the keymap translation and send the appropriate /// key and char events. From 03ecc9fdbfb7e36f4d6beea2b8db6e5baab96c83 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 28 Dec 2025 00:16:19 +0000 Subject: [PATCH 95/96] 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 373c97aba..fc94c081c 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/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", - .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz", + .hash = "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 9ad3c39da..a8d21dd96 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-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": { + "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", - "hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz", + "hash": "sha256-cMIEDZFYdilCavhL5pWQ6jRerGUFSZIjLwbxNossSeg=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 9627286dd..eb9b90bea 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-"; + name = "N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz"; - hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz"; + hash = "sha256-cMIEDZFYdilCavhL5pWQ6jRerGUFSZIjLwbxNossSeg="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 484e1949b..5b1946049 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -5,7 +5,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 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/ghostty-themes-release-20251201-150531-bfb3ee1.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz 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 diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 3b17b64a6..0edc6ab43 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251201-150531-bfb3ee1.tgz", - "dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", - "sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20251222-150520-0add1e1.tgz", + "dest": "vendor/p/N-V-__8AAIdIAwAOceDblkuOARUyuTKbDdGPjPClPLhMeIfU", + "sha256": "70c2040d91587629426af84be69590ea345eac65054992232f06f1368b2c49e8" }, { "type": "archive", From e63a4ab77464068ba1e6ad920664a7b39c41f5c0 Mon Sep 17 00:00:00 2001 From: -k Date: Sun, 28 Dec 2025 07:21:58 -0500 Subject: [PATCH 96/96] build: fix pkgs for FBSD port runs --- pkg/cimgui/build.zig | 4 ++++ pkg/freetype/build.zig | 4 ++++ pkg/glslang/build.zig | 4 ++++ pkg/spirv-cross/build.zig | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/pkg/cimgui/build.zig b/pkg/cimgui/build.zig index b94f11943..890873ef9 100644 --- a/pkg/cimgui/build.zig +++ b/pkg/cimgui/build.zig @@ -72,6 +72,10 @@ pub fn build(b: *std.Build) !void { }); } + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + if (imgui_) |imgui| { lib.addCSourceFile(.{ .file = b.path("vendor/cimgui.cpp"), .flags = flags.items }); lib.addCSourceFile(.{ .file = imgui.path("imgui.cpp"), .flags = flags.items }); diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index a25dc18da..e0a041be7 100644 --- a/pkg/freetype/build.zig +++ b/pkg/freetype/build.zig @@ -90,6 +90,10 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu "-fno-sanitize=undefined", }); + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + const dynamic_link_opts = options.dynamic_link_opts; // Zlib diff --git a/pkg/glslang/build.zig b/pkg/glslang/build.zig index 746a41497..da9a82e31 100644 --- a/pkg/glslang/build.zig +++ b/pkg/glslang/build.zig @@ -66,6 +66,10 @@ fn buildGlslang( "-fno-sanitize-trap=undefined", }); + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + if (upstream_) |upstream| { lib.addCSourceFiles(.{ .root = upstream.path(""), diff --git a/pkg/spirv-cross/build.zig b/pkg/spirv-cross/build.zig index 003ec43cf..31af1974e 100644 --- a/pkg/spirv-cross/build.zig +++ b/pkg/spirv-cross/build.zig @@ -74,6 +74,10 @@ fn buildSpirvCross( "-fno-sanitize-trap=undefined", }); + if (target.result.os.tag == .freebsd) { + try flags.append(b.allocator, "-fPIC"); + } + if (b.lazyDependency("spirv_cross", .{})) |upstream| { lib.addIncludePath(upstream.path("")); module.addIncludePath(upstream.path(""));