From 6afdf3400e52d4eb83a058c9e43e8e1de755eb64 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 24 Nov 2025 04:47:23 -0500 Subject: [PATCH 01/79] unicode: change cell to wide when grapheme width changes --- src/build/uucode_config.zig | 1 + src/terminal/Terminal.zig | 412 ++++++++++++++++++++++++++++++----- src/terminal/page.zig | 2 +- src/unicode/props.zig | 7 + src/unicode/props_uucode.zig | 2 + 5 files changed, 371 insertions(+), 53 deletions(-) diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index 6ddfd487f..7010d4b78 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -94,6 +94,7 @@ pub const tables = [_]config.Table{ }, .fields = &.{ width.field("width"), + wcwidth.field("wcwidth_zero_in_grapheme"), grapheme_break_no_control.field("grapheme_break_no_control"), is_symbol.field("is_symbol"), d.field("is_emoji_vs_text"), diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index bd605ef2d..055ab95f4 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -322,10 +322,13 @@ pub fn print(self: *Terminal, c: u21) !void { // break or not. If we are NOT, then we are still combining the // same grapheme. Otherwise, we can stay in this cell. const Prev = struct { cell: *Cell, left: size.CellCountInt }; - const prev: Prev = prev: { + var prev: Prev = prev: { const left: size.CellCountInt = left: { - // If we have wraparound, then we always use the prev col - if (self.modes.get(.wraparound)) break :left 1; + // If we have wraparound, then we use the prev col unless + // there's a pending wrap, in which case we use the current. + if (self.modes.get(.wraparound)) { + break :left @intFromBool(!self.screens.active.cursor.pending_wrap); + } // If we do not have wraparound, the logic is trickier. If // we're not on the last column, then we just use the previous @@ -371,6 +374,8 @@ pub fn print(self: *Terminal, c: u21) !void { // If we can NOT break, this means that "c" is part of a grapheme // with the previous char. if (!grapheme_break) { + var desired_wide: enum { no_change, wide, narrow } = .no_change; + // If this is an emoji variation selector then we need to modify // the cell width accordingly. VS16 makes the character wide and // VS15 makes it narrow. @@ -392,71 +397,121 @@ pub fn print(self: *Terminal, c: u21) !void { } switch (c) { - 0xFE0F => wide: { - if (prev.cell.wide == .wide) break :wide; + 0xFE0F => desired_wide = .wide, + 0xFE0E => desired_wide = .narrow, + else => unreachable, + } + } else if (!unicode.table.get(c).width_zero_in_grapheme) { + desired_wide = .wide; + } - // Move our cursor back to the previous. We'll move - // the cursor within this block to the proper location. - self.screens.active.cursorLeft(prev.left); + switch (desired_wide) { + .wide => wide: { + if (prev.cell.wide == .wide) break :wide; - // If we don't have space for the wide char, we need - // to insert spacers and wrap. Then we just print the wide - // char as normal. - if (self.screens.active.cursor.x == right_limit - 1) { - if (!self.modes.get(.wraparound)) return; + // Move our cursor back to the previous. We'll move + // the cursor within this block to the proper location. + self.screens.active.cursorLeft(prev.left); + + // If we don't have space for the wide char, we need to + // insert spacers and wrap. We need special handling if the + // previous cell has grapheme data. + if (self.screens.active.cursor.x == right_limit - 1) { + if (!self.modes.get(.wraparound)) return; + + const prev_cp = prev.cell.content.codepoint; + + if (prev.cell.hasGrapheme()) { + // This is like printCell but without clearing the + // grapheme data from the cell, so we can move it + // later. + prev.cell.wide = if (right_limit == self.cols) .spacer_head else .narrow; + prev.cell.content.codepoint = 0; + + try self.printWrap(); + self.printCell(prev_cp, .wide); + + const new_pin = self.screens.active.cursor.page_pin.*; + const new_rac = new_pin.rowAndCell(); + + transfer_graphemes: { + var old_pin = self.screens.active.cursor.page_pin.up(1) orelse break :transfer_graphemes; + old_pin.x = right_limit - 1; + const old_rac = old_pin.rowAndCell(); + + if (new_pin.node == old_pin.node) { + new_pin.node.data.moveGrapheme(prev.cell, new_rac.cell); + prev.cell.content_tag = .codepoint; + new_rac.cell.content_tag = .codepoint_grapheme; + new_rac.row.grapheme = true; + } else { + const cps = old_pin.node.data.lookupGrapheme(old_rac.cell).?; + for (cps) |cp| { + try self.screens.active.appendGrapheme(new_rac.cell, cp); + } + old_pin.node.data.clearGrapheme(old_rac.cell); + } + + old_pin.node.data.updateRowGraphemeFlag(old_rac.row); + } + + prev.cell = new_rac.cell; + } else { self.printCell( 0, if (right_limit == self.cols) .spacer_head else .narrow, ); try self.printWrap(); + self.printCell(prev_cp, .wide); + prev.cell = self.screens.active.cursor.page_cell; } + } else { + prev.cell.wide = .wide; + } - self.printCell(prev.cell.content.codepoint, .wide); + // Write our spacer + self.screens.active.cursorRight(1); + self.printCell(0, .spacer_tail); - // Write our spacer + // Move the cursor again so we're beyond our spacer + if (self.screens.active.cursor.x == right_limit - 1) { + self.screens.active.cursor.pending_wrap = true; + } else { self.screens.active.cursorRight(1); - self.printCell(0, .spacer_tail); + } + }, - // Move the cursor again so we're beyond our spacer - if (self.screens.active.cursor.x == right_limit - 1) { - self.screens.active.cursor.pending_wrap = true; - } else { - self.screens.active.cursorRight(1); - } - }, + .narrow => narrow: { + // Prev cell is no longer wide + if (prev.cell.wide != .wide) break :narrow; + prev.cell.wide = .narrow; - 0xFE0E => narrow: { - // Prev cell is no longer wide - if (prev.cell.wide != .wide) break :narrow; - prev.cell.wide = .narrow; + // Remove the wide spacer tail + const cell = self.screens.active.cursorCellLeft(prev.left - 1); + cell.wide = .narrow; - // Remove the wide spacer tail - const cell = self.screens.active.cursorCellLeft(prev.left - 1); - cell.wide = .narrow; + // Back track the cursor so that we don't end up with + // an extra space after the character. Since xterm is + // not VS aware, it cannot be used as a reference for + // this behavior; but it does follow the principle of + // least surprise, and also matches the behavior that + // can be observed in Kitty, which is one of the only + // other VS aware terminals. + if (self.screens.active.cursor.x == right_limit - 1) { + // If we're already at the right edge, we stay + // here and set the pending wrap to false since + // when we pend a wrap, we only move our cursor once + // even for wide chars (tests verify). + self.screens.active.cursor.pending_wrap = false; + } else { + // Otherwise, move back. + self.screens.active.cursorLeft(1); + } - // Back track the cursor so that we don't end up with - // an extra space after the character. Since xterm is - // not VS aware, it cannot be used as a reference for - // this behavior; but it does follow the principle of - // least surprise, and also matches the behavior that - // can be observed in Kitty, which is one of the only - // other VS aware terminals. - if (self.screens.active.cursor.x == right_limit - 1) { - // If we're already at the right edge, we stay - // here and set the pending wrap to false since - // when we pend a wrap, we only move our cursor once - // even for wide chars (tests verify). - self.screens.active.cursor.pending_wrap = false; - } else { - // Otherwise, move back. - self.screens.active.cursorLeft(1); - } + break :narrow; + }, - break :narrow; - }, - - else => unreachable, - } + else => {}, } log.debug("c={X} grapheme attach to left={} primary_cp={X}", .{ @@ -3645,6 +3700,92 @@ test "Terminal: VS15 to make narrow character with pending wrap" { } } +test "Terminal: VS16 to make wide character on next line" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(2); + try t.print('#'); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 0 } })); + t.clearDirty(); + + try t.print(0xFE0F); // VS16 to make wide + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 0 } })); + t.clearDirty(); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // Previous cell turns into spacer_head + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // '#' cell is wide + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + // spacer_tail + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: VS16 to make wide character with pending wrap" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(1); + try t.print('#'); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + try t.print(0xFE0F); // VS16 to make wide + + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expect(t.screens.active.cursor.pending_wrap); + + { + // '#' cell is wide + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + // spacer_tail + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + test "Terminal: VS16 to make wide character with mode 2027" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); @@ -3783,6 +3924,173 @@ test "Terminal: print invalid VS16 with second char" { } } +test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print('o'); + try t.print(0x0300); // combining grave accent + + // We should have 1 cell taken up. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'o'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // क्‍ष + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide on next line" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(2); + + // क्‍ष + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + // This one increases the width to wide + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // Previous cell turns into spacer_head + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // Devanagari grapheme is wide + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide on next page" { + const rows = pagepkg.std_capacity.rows; + const cols = pagepkg.std_capacity.cols; + var t = try init(testing.allocator, .{ .rows = rows, .cols = cols }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorDown(rows - 1); + + for (rows..t.screens.active.pages.pages.first.?.data.capacity.rows) |_| { + try t.index(); + } + + t.cursorRight(cols - 1); + + try testing.expectEqual(cols - 1, t.screens.active.cursor.x); + try testing.expectEqual(rows - 1, t.screens.active.cursor.y); + + // क्‍ष + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try testing.expectEqual(cols - 1, t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + // This one increases the width to wide + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(rows - 1, t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // Previous cell turns into spacer_head + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = cols - 1, .y = rows - 2 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // Devanagari grapheme is wide + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = rows - 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1, .y = rows - 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + test "Terminal: overwrite grapheme should clear grapheme data" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index bf40d2353..6c464e619 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1448,7 +1448,7 @@ pub const Page = struct { /// WARNING: This will NOT change the content_tag on the cells because /// there are scenarios where we want to move graphemes without changing /// the content tag. Callers beware but assertIntegrity should catch this. - inline fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { + pub inline fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { if (build_options.slow_runtime_safety) { assert(src.hasGrapheme()); assert(!dst.hasGrapheme()); diff --git a/src/unicode/props.zig b/src/unicode/props.zig index 5f6b88936..90b62ed20 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -13,6 +13,10 @@ pub const Properties = packed struct { /// becomes a 2-em dash). width: u2 = 0, + /// Whether the code point does not contribute to the width of a grapheme + /// cluster (not used for single code point cells). + width_zero_in_grapheme: bool = false, + /// Grapheme break property. grapheme_break: uucode.x.types.GraphemeBreakNoControl = .other, @@ -23,6 +27,7 @@ pub const Properties = packed struct { // Needed for lut.Generator pub fn eql(a: Properties, b: Properties) bool { return a.width == b.width and + a.width_zero_in_grapheme == b.width_zero_in_grapheme and a.grapheme_break == b.grapheme_break and a.emoji_vs_text == b.emoji_vs_text and a.emoji_vs_emoji == b.emoji_vs_emoji; @@ -36,12 +41,14 @@ pub const Properties = packed struct { try writer.print( \\.{{ \\ .width= {}, + \\ .width_zero_in_grapheme= {}, \\ .grapheme_break= .{s}, \\ .emoji_vs_text= {}, \\ .emoji_vs_emoji= {}, \\}} , .{ self.width, + self.width_zero_in_grapheme, @tagName(self.grapheme_break), self.emoji_vs_text, self.emoji_vs_emoji, diff --git a/src/unicode/props_uucode.zig b/src/unicode/props_uucode.zig index d0052b739..ebbe4e25a 100644 --- a/src/unicode/props_uucode.zig +++ b/src/unicode/props_uucode.zig @@ -8,6 +8,7 @@ const Properties = @import("props.zig").Properties; pub fn get(cp: u21) Properties { if (cp > uucode.config.max_code_point) return .{ .width = 1, + .width_zero_in_grapheme = true, .grapheme_break = .other, .emoji_vs_text = false, .emoji_vs_emoji = false, @@ -15,6 +16,7 @@ pub fn get(cp: u21) Properties { return .{ .width = uucode.get(.width, cp), + .width_zero_in_grapheme = uucode.get(.wcwidth_zero_in_grapheme, cp), .grapheme_break = uucode.get(.grapheme_break_no_control, cp), .emoji_vs_text = uucode.get(.is_emoji_vs_text, cp), .emoji_vs_emoji = uucode.get(.is_emoji_vs_emoji, cp), From 96c69c9f9b92651d024107f311967dd2dd88dae0 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 24 Nov 2025 10:07:50 -0500 Subject: [PATCH 02/79] Add comment for desired_wide = .wide when !width_zero_in_grapheme --- src/terminal/Terminal.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 055ab95f4..b6cd8343e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -402,6 +402,12 @@ pub fn print(self: *Terminal, c: u21) !void { else => unreachable, } } else if (!unicode.table.get(c).width_zero_in_grapheme) { + // If we have a code point that contributes to the width of a + // grapheme, it necessarily means that we're at least at width + // 2, since the first code point must be at least width 1 to + // start. (Note that Prepend code points could effectively mean + // the first code point should be width 0, but we don't handle + // that yet.) desired_wide = .wide; } From a7080b6fab66d1586fe4e0b30e340f62282dbdd6 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Tue, 27 Jan 2026 10:23:53 -0500 Subject: [PATCH 03/79] Make VS15 test check that previous grapheme is not affected --- src/terminal/Terminal.zig | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index c3a82a4bf..faba7c4b0 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -3736,19 +3736,23 @@ test "Terminal: print invalid VS15 in emoji ZWJ sequence" { } test "Terminal: VS15 to make narrow character with pending wrap" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 2 }); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 4 }); defer t.deinit(testing.allocator); // Enable grapheme clustering t.modes.set(.grapheme_cluster, true); + try testing.expect(t.modes.get(.wraparound)); + + try t.print(0x1F34B); // Lemon, width=2 try t.print(0x2614); // Umbrella with rain drops, width=2 try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); t.clearDirty(); - // We only move one because we're in a pending wrap state. + // We only move to the end of the line because we're in a pending wrap + // state. try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); try testing.expect(t.screens.active.cursor.pending_wrap); try t.print(0xFE0E); // VS15 to make narrow @@ -3757,17 +3761,17 @@ test "Terminal: VS15 to make narrow character with pending wrap" { // VS15 should clear the pending wrap state try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); try testing.expect(!t.screens.active.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("☔︎", str); + try testing.expectEqualStrings("🍋☔︎", str); } { - const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3775,6 +3779,16 @@ test "Terminal: VS15 to make narrow character with pending wrap" { const cps = list_cell.node.data.lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } + + // VS15 should not affect the previous grapheme + { + const lemon_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?.cell; + try testing.expectEqual(@as(u21, 0x1F34B), lemon_cell.content.codepoint); + try testing.expectEqual(Cell.Wide.wide, lemon_cell.wide); + const spacer_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?.cell; + try testing.expectEqual(@as(u21, 0), spacer_cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_tail, spacer_cell.wide); + } } test "Terminal: VS16 to make wide character on next line" { From 77957aa319e99a3ecfedb2300bf83bd382c7740d Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Wed, 28 Jan 2026 09:49:07 -0500 Subject: [PATCH 04/79] Fix Bengali test due to wider grapheme --- src/font/shaper/coretext.zig | 2 +- src/font/shaper/harfbuzz.zig | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index ab3c6aaab..5a8a6ccbf 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1895,7 +1895,7 @@ test "shape Bengali ligatures with out of order vowels" { try testing.expectEqual(@as(u16, 0), cells[0].x); try testing.expectEqual(@as(u16, 0), cells[1].x); // See the giant "We need to reset the `cell_offset`" comment, but here - // we should technically have the rest of these be `x` of 1, but that + // we should technically have the rest of these be `x` of 2, but that // would require going back in the stream to adjust past cells, and // we don't take on that complexity. try testing.expectEqual(@as(u16, 0), cells[2].x); diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 946611e79..b1126dd4e 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1447,12 +1447,12 @@ test "shape Bengali ligatures with out of order vowels" { // Whereas CoreText puts everything all into the first cell (see the // corresponding test), HarfBuzz splits into two clusters. - try testing.expectEqual(@as(u16, 1), cells[2].x); - try testing.expectEqual(@as(u16, 1), cells[3].x); - try testing.expectEqual(@as(u16, 1), cells[4].x); - try testing.expectEqual(@as(u16, 1), cells[5].x); - try testing.expectEqual(@as(u16, 1), cells[6].x); - try testing.expectEqual(@as(u16, 1), cells[7].x); + try testing.expectEqual(@as(u16, 2), cells[2].x); + try testing.expectEqual(@as(u16, 2), cells[3].x); + try testing.expectEqual(@as(u16, 2), cells[4].x); + try testing.expectEqual(@as(u16, 2), cells[5].x); + try testing.expectEqual(@as(u16, 2), cells[6].x); + try testing.expectEqual(@as(u16, 2), cells[7].x); // The vowel sign E renders before the SSA: try testing.expect(cells[2].x_offset < cells[3].x_offset); From 1c3fc062e1efcf9d8c28e11c5cc6c48421264f2f Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Wed, 28 Jan 2026 10:08:21 -0500 Subject: [PATCH 05/79] clarify comments --- src/terminal/Terminal.zig | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index faba7c4b0..df711af0b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -329,7 +329,8 @@ pub fn print(self: *Terminal, c: u21) !void { @branchHint(.unlikely); // We need the previous cell to determine if we're at a grapheme // break or not. If we are NOT, then we are still combining the - // same grapheme. Otherwise, we can stay in this cell. + // same grapheme, and will be appending to prev.cell. Otherwise, we are + // in a new cell. const Prev = struct { cell: *Cell, left: size.CellCountInt }; var prev: Prev = prev: { const left: size.CellCountInt = left: { @@ -459,6 +460,8 @@ pub fn print(self: *Terminal, c: u21) !void { old_pin.node.data.updateRowGraphemeFlag(old_rac.row); } + // Point prev.cell to our new previous cell that + // we'll be appending graphemes to prev.cell = new_rac.cell; } else { self.printCell( @@ -467,13 +470,16 @@ pub fn print(self: *Terminal, c: u21) !void { ); try self.printWrap(); self.printCell(prev_cp, .wide); + + // Point prev.cell to our new previous cell that + // we'll be appending graphemes to prev.cell = self.screens.active.cursor.page_cell; } } else { prev.cell.wide = .wide; } - // Write our spacer + // Write our spacer, since prev.cell is now wide self.screens.active.cursorRight(1); self.printCell(0, .spacer_tail); From 27180d560c6fa2094f54677246adaac760acf6d7 Mon Sep 17 00:00:00 2001 From: Giacomo Bettini Date: Sat, 14 Feb 2026 01:26:26 +0100 Subject: [PATCH 06/79] i18n: add 1.3 it_IT translations --- po/it_IT.UTF-8.po | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/po/it_IT.UTF-8.po b/po/it_IT.UTF-8.po index 6c270a9cf..ad98709f4 100644 --- a/po/it_IT.UTF-8.po +++ b/po/it_IT.UTF-8.po @@ -1,4 +1,4 @@ -# Italian translations for com.mitchellh.ghostty package +# Italian translations for com.mitchellh.ghostty package. # Traduzioni italiane per il pacchetto com.mitchellh.ghostty. # Copyright (C) 2025 "Mitchell Hashimoto, Ghostty contributors" # This file is distributed under the same license as the com.mitchellh.ghostty package. @@ -89,23 +89,23 @@ msgstr "Ghostty: Ispettore del terminale" #: src/apprt/gtk/ui/1.2/search-overlay.blp:29 msgid "Find…" -msgstr "" +msgstr "Cerca…" #: src/apprt/gtk/ui/1.2/search-overlay.blp:64 msgid "Previous Match" -msgstr "" +msgstr "Corrispondenza precedente" #: src/apprt/gtk/ui/1.2/search-overlay.blp:74 msgid "Next Match" -msgstr "" +msgstr "Corrispondenza successiva" #: src/apprt/gtk/ui/1.2/surface.blp:6 msgid "Oh, no." -msgstr "" +msgstr "Oh no!" #: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Unable to acquire an OpenGL context for rendering." -msgstr "" +msgstr "Impossibile ottenere un contesto OpenGL per il rendering." #: src/apprt/gtk/ui/1.2/surface.blp:97 msgid "" @@ -113,10 +113,13 @@ msgid "" "through the content, but no input events will be sent to the running " "application." msgstr "" +"Questo terminale è in modalità di sola lettura. Puoi ancora vedere, " +"selezionare e scorrere il contenuto, ma non verrà inviato alcun evento " +"di input all'applicazione in esecuzione." #: src/apprt/gtk/ui/1.2/surface.blp:107 msgid "Read-only" -msgstr "" +msgstr "Sola lettura" #: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:198 msgid "Copy" @@ -128,7 +131,7 @@ msgstr "Incolla" #: src/apprt/gtk/ui/1.2/surface.blp:270 msgid "Notify on Next Command Finish" -msgstr "" +msgstr "Notifica al termine del prossimo comando" #: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:266 msgid "Clear" @@ -306,15 +309,15 @@ msgstr "" #: src/apprt/gtk/class/surface.zig:1108 msgid "Command Finished" -msgstr "" +msgstr "Il comando è terminato" #: src/apprt/gtk/class/surface.zig:1109 msgid "Command Succeeded" -msgstr "" +msgstr "Il comando è riuscito" #: src/apprt/gtk/class/surface.zig:1110 msgid "Command Failed" -msgstr "" +msgstr "Il comando è fallito" #: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command succeeded" From b28b0b02376a2a361d5ebcd22a3d6d7a47964685 Mon Sep 17 00:00:00 2001 From: Giacomo Bettini Date: Mon, 16 Feb 2026 22:44:08 +0100 Subject: [PATCH 07/79] Apply suggestions --- po/it_IT.UTF-8.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/po/it_IT.UTF-8.po b/po/it_IT.UTF-8.po index ad98709f4..21d0762a6 100644 --- a/po/it_IT.UTF-8.po +++ b/po/it_IT.UTF-8.po @@ -309,15 +309,15 @@ msgstr "" #: src/apprt/gtk/class/surface.zig:1108 msgid "Command Finished" -msgstr "Il comando è terminato" +msgstr "Comando terminato" #: src/apprt/gtk/class/surface.zig:1109 msgid "Command Succeeded" -msgstr "Il comando è riuscito" +msgstr "Comando riuscito" #: src/apprt/gtk/class/surface.zig:1110 msgid "Command Failed" -msgstr "Il comando è fallito" +msgstr "Comando fallito" #: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command succeeded" From 3fde7842935deff7cf4328d4ce0e066f2e82cb67 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Wed, 18 Feb 2026 13:59:17 +0800 Subject: [PATCH 08/79] i18n: update zh_TW translation --- po/zh_TW.UTF-8.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/po/zh_TW.UTF-8.po b/po/zh_TW.UTF-8.po index 25dacd566..cacdc8acb 100644 --- a/po/zh_TW.UTF-8.po +++ b/po/zh_TW.UTF-8.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-10 15:32+0800\n" +"PO-Revision-Date: 2026-02-18 13:58+0800\n" "Last-Translator: Yi-Jyun Pan \n" "Language-Team: Chinese (traditional)\n" "Language: zh_TW\n" @@ -18,7 +18,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "在 Ghostty 中開啟" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 From 4989f1c0121e42324e4ee9b7c85e31a2a0b02a80 Mon Sep 17 00:00:00 2001 From: Nico Geesink Date: Wed, 18 Feb 2026 15:32:52 +0100 Subject: [PATCH 09/79] i18n: Add new translations for nl_NL --- po/nl_NL.UTF-8.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/po/nl_NL.UTF-8.po b/po/nl_NL.UTF-8.po index 9caee41a4..5945c0cd9 100644 --- a/po/nl_NL.UTF-8.po +++ b/po/nl_NL.UTF-8.po @@ -9,7 +9,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-09 20:39+0100\n" +"PO-Revision-Date: 2026-02-18 15:30+0100\n" "Last-Translator: Nico Geesink \n" "Language-Team: Dutch \n" "Language: nl\n" @@ -20,7 +20,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "In Ghostty openen" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -180,7 +180,7 @@ msgstr "Tabblad" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Titel van tabblad wijzigen…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -337,7 +337,7 @@ msgstr "Titel van de terminal wijzigen" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Titel van tablad wijzigen" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" From 791c94919059d73aa1df69eb034c9e105e85cbae Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 19 Feb 2026 01:54:52 +0800 Subject: [PATCH 10/79] i18n/zh: update strings --- po/zh_CN.UTF-8.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po index 92b79ee21..8e7e241fc 100644 --- a/po/zh_CN.UTF-8.po +++ b/po/zh_CN.UTF-8.po @@ -19,7 +19,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "在 Ghostty 中打开" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -175,7 +175,7 @@ msgstr "标签页" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "更改标签页标题…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -326,7 +326,7 @@ msgstr "更改终端标题" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "更改标签页标题" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" From d816e835a82f3050cae9de6d52ad9b45205f67fd Mon Sep 17 00:00:00 2001 From: Nico Geesink Date: Wed, 18 Feb 2026 21:01:13 +0100 Subject: [PATCH 11/79] Update translations to imperative form --- po/nl_NL.UTF-8.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/po/nl_NL.UTF-8.po b/po/nl_NL.UTF-8.po index 5945c0cd9..1d3e1014c 100644 --- a/po/nl_NL.UTF-8.po +++ b/po/nl_NL.UTF-8.po @@ -9,7 +9,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-18 15:30+0100\n" +"PO-Revision-Date: 2026-02-18 20:59+0100\n" "Last-Translator: Nico Geesink \n" "Language-Team: Dutch \n" "Language: nl\n" @@ -20,7 +20,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "In Ghostty openen" +msgstr "Open in Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -180,7 +180,7 @@ msgstr "Tabblad" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "Titel van tabblad wijzigen…" +msgstr "Wijzig tabbladtitel…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -337,7 +337,7 @@ msgstr "Titel van de terminal wijzigen" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "Titel van tablad wijzigen" +msgstr "Wijzig tabbladtitel" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" From a73c5b2835396abe5cafc1bee6718c5b86275c85 Mon Sep 17 00:00:00 2001 From: Giacomo Bettini Date: Wed, 18 Feb 2026 23:15:45 +0100 Subject: [PATCH 12/79] Translate 3 additional strings --- po/it_IT.UTF-8.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/po/it_IT.UTF-8.po b/po/it_IT.UTF-8.po index 0bbefd661..ea2031382 100644 --- a/po/it_IT.UTF-8.po +++ b/po/it_IT.UTF-8.po @@ -20,7 +20,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Apri in Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -180,7 +180,7 @@ msgstr "Scheda" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Cambia titolo scheda…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -338,7 +338,7 @@ msgstr "Cambia il titolo del terminale" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Cambia il titolo della scheda" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" From 7e90e26ae1a422d3e4b62fac5eb2928be3cc74b4 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Thu, 19 Feb 2026 22:03:54 -0800 Subject: [PATCH 13/79] macos: optimize secure input overlay animation Rendering the Secure Keyboard Input overlay using innerShadow() can strain the resources of the main thread, leading to elevated CPU load and in some cases extended disruptions to the main thread's DispatchQueue that result in lag or frozen frames. This change achieves the same animated visual effect with ~35% lower CPU usage and resolves most or all of the terminal rendering issues associated with the overlay. --- .../Secure Input/SecureInputOverlay.swift | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift index 96f309de5..08e02efaa 100644 --- a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift +++ b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift @@ -2,8 +2,8 @@ import SwiftUI struct SecureInputOverlay: View { // Animations - @State private var shadowAngle: Angle = .degrees(0) - @State private var shadowWidth: CGFloat = 6 + @State private var gradientAngle: Angle = .degrees(0) + @State private var gradientOpacity: CGFloat = 0.5 // Popover explainer text @State private var isPopover = false @@ -20,18 +20,32 @@ struct SecureInputOverlay: View { .foregroundColor(.primary) .padding(5) .background( - RoundedRectangle(cornerRadius: 12) + Rectangle() .fill(.background) - .innerShadow( - using: RoundedRectangle(cornerRadius: 12), - stroke: AngularGradient( - gradient: Gradient(colors: [.cyan, .blue, .yellow, .blue, .cyan]), - center: .center, - angle: shadowAngle - ), - width: shadowWidth + .overlay( + Rectangle() + .fill( + AngularGradient( + gradient: Gradient( + colors: [.cyan, .blue, .yellow, .blue, .cyan] + ), + center: .center, + angle: gradientAngle + ) + ) + .blur(radius: 4, opaque: true) + .mask( + RadialGradient( + colors: [.clear, .black], + center: .center, + startRadius: 0, + endRadius: 25 + ) + ) + .opacity(gradientOpacity) ) ) + .mask(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.gray, lineWidth: 1) @@ -57,11 +71,11 @@ struct SecureInputOverlay: View { } .onAppear { withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) { - shadowAngle = .degrees(360) + gradientAngle = .degrees(360) } withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: true)) { - shadowWidth = 12 + gradientOpacity = 1 } } } From d991372bc8958f30114eee47a5a083fd9f9e31e4 Mon Sep 17 00:00:00 2001 From: tlotuzas_nepgroup Date: Fri, 20 Feb 2026 12:17:57 +0100 Subject: [PATCH 14/79] translation update for lt_LT - filled in missing strings added translations for: - Open in Ghostty (Nautilus) - Change Tab Title menu/dialog all 74 messages done now --- po/lt_LT.UTF-8.po | 97 +++++++++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/po/lt_LT.UTF-8.po b/po/lt_LT.UTF-8.po index b2c243d5d..ba4995ddc 100644 --- a/po/lt_LT.UTF-8.po +++ b/po/lt_LT.UTF-8.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2026-02-05 10:23+0800\n" -"PO-Revision-Date: 2026-02-10 08:14+0100\n" +"POT-Creation-Date: 2026-02-17 23:16+0100\n" +"PO-Revision-Date: 2026-02-20 12:13+0100\n" "Last-Translator: Tadas Lotuzas \n" "Language-Team: Language LT\n" "Language: LT\n" @@ -16,6 +16,10 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +#: dist/linux/ghostty_nautilus.py:53 +msgid "Open in Ghostty" +msgstr "Atidaryti su Ghostty" + #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197 @@ -42,7 +46,7 @@ msgid "Reload configuration to show this prompt again" msgstr "Iš naujo įkelkite konfigūraciją, kad vėl būtų rodoma ši užuomina" #: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7 -#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:9 +#: src/apprt/gtk/ui/1.5/title-dialog.blp:8 msgid "Cancel" msgstr "Atšaukti" @@ -70,7 +74,7 @@ msgid "Ignore" msgstr "Ignoruoti" #: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 -#: src/apprt/gtk/ui/1.2/surface.blp:361 src/apprt/gtk/ui/1.5/window.blp:293 +#: src/apprt/gtk/ui/1.2/surface.blp:366 src/apprt/gtk/ui/1.5/window.blp:300 msgid "Reload Configuration" msgstr "Iš naujo įkelti konfigūraciją" @@ -111,18 +115,18 @@ msgid "" "application." msgstr "" "Šis terminalas yra tik skaitymui. Vis tiek galite peržiūrėti, pasirinkti ir " -"slinkti per turinį, tačiau jokie įvesties įvykiai nebus siunčiami veikiančiai " -"programai." +"slinkti per turinį, tačiau jokie įvesties įvykiai nebus siunčiami " +"veikiančiai programai." #: src/apprt/gtk/ui/1.2/surface.blp:107 msgid "Read-only" msgstr "Tik skaitymui" -#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:198 +#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200 msgid "Copy" msgstr "Kopijuoti" -#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:203 +#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:205 msgid "Paste" msgstr "Įklijuoti" @@ -130,39 +134,39 @@ msgstr "Įklijuoti" msgid "Notify on Next Command Finish" msgstr "Pranešti apie sekančios komandos užbaigimą" -#: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:266 +#: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273 msgid "Clear" msgstr "Išvalyti" -#: src/apprt/gtk/ui/1.2/surface.blp:282 src/apprt/gtk/ui/1.5/window.blp:271 +#: src/apprt/gtk/ui/1.2/surface.blp:282 src/apprt/gtk/ui/1.5/window.blp:278 msgid "Reset" msgstr "Atstatyti" -#: src/apprt/gtk/ui/1.2/surface.blp:289 src/apprt/gtk/ui/1.5/window.blp:235 +#: src/apprt/gtk/ui/1.2/surface.blp:289 src/apprt/gtk/ui/1.5/window.blp:242 msgid "Split" msgstr "Padalinti" -#: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:238 +#: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:245 msgid "Change Title…" msgstr "Keisti pavadinimą…" -#: src/apprt/gtk/ui/1.2/surface.blp:297 src/apprt/gtk/ui/1.5/window.blp:175 -#: src/apprt/gtk/ui/1.5/window.blp:243 +#: src/apprt/gtk/ui/1.2/surface.blp:297 src/apprt/gtk/ui/1.5/window.blp:177 +#: src/apprt/gtk/ui/1.5/window.blp:250 msgid "Split Up" msgstr "Padalinti aukštyn" -#: src/apprt/gtk/ui/1.2/surface.blp:303 src/apprt/gtk/ui/1.5/window.blp:180 -#: src/apprt/gtk/ui/1.5/window.blp:248 +#: src/apprt/gtk/ui/1.2/surface.blp:303 src/apprt/gtk/ui/1.5/window.blp:182 +#: src/apprt/gtk/ui/1.5/window.blp:255 msgid "Split Down" msgstr "Padalinti žemyn" -#: src/apprt/gtk/ui/1.2/surface.blp:309 src/apprt/gtk/ui/1.5/window.blp:185 -#: src/apprt/gtk/ui/1.5/window.blp:253 +#: src/apprt/gtk/ui/1.2/surface.blp:309 src/apprt/gtk/ui/1.5/window.blp:187 +#: src/apprt/gtk/ui/1.5/window.blp:260 msgid "Split Left" msgstr "Padalinti kairėn" -#: src/apprt/gtk/ui/1.2/surface.blp:315 src/apprt/gtk/ui/1.5/window.blp:190 -#: src/apprt/gtk/ui/1.5/window.blp:258 +#: src/apprt/gtk/ui/1.2/surface.blp:315 src/apprt/gtk/ui/1.5/window.blp:192 +#: src/apprt/gtk/ui/1.5/window.blp:265 msgid "Split Right" msgstr "Padalinti dešinėn" @@ -170,44 +174,45 @@ msgstr "Padalinti dešinėn" msgid "Tab" msgstr "Kortelė" -#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:57 -#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:222 +#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 +#: src/apprt/gtk/ui/1.5/window.blp:320 +msgid "Change Tab Title…" +msgstr "Keisti kortelės pavadinimą…" + +#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 +#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 msgid "New Tab" msgstr "Nauja kortelė" -#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:227 +#: src/apprt/gtk/ui/1.2/surface.blp:335 src/apprt/gtk/ui/1.5/window.blp:234 msgid "Close Tab" msgstr "Uždaryti kortelę" -#: src/apprt/gtk/ui/1.2/surface.blp:337 +#: src/apprt/gtk/ui/1.2/surface.blp:342 msgid "Window" msgstr "Langas" -#: src/apprt/gtk/ui/1.2/surface.blp:340 src/apprt/gtk/ui/1.5/window.blp:210 +#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:212 msgid "New Window" msgstr "Naujas langas" -#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:215 +#: src/apprt/gtk/ui/1.2/surface.blp:350 src/apprt/gtk/ui/1.5/window.blp:217 msgid "Close Window" msgstr "Uždaryti langą" -#: src/apprt/gtk/ui/1.2/surface.blp:353 +#: src/apprt/gtk/ui/1.2/surface.blp:358 msgid "Config" msgstr "Konfigūracija" -#: src/apprt/gtk/ui/1.2/surface.blp:356 src/apprt/gtk/ui/1.5/window.blp:288 +#: src/apprt/gtk/ui/1.2/surface.blp:361 src/apprt/gtk/ui/1.5/window.blp:295 msgid "Open Configuration" msgstr "Atidaryti konfigūraciją" -#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:5 -msgid "Change Terminal Title" -msgstr "Keisti terminalo pavadinimą" - -#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:6 +#: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." msgstr "Palikite tuščią, kad atkurtumėte numatytąjį pavadinimą." -#: src/apprt/gtk/ui/1.5/surface-title-dialog.blp:10 +#: src/apprt/gtk/ui/1.5/title-dialog.blp:9 msgid "OK" msgstr "Gerai" @@ -223,19 +228,19 @@ msgstr "Peržiūrėti atidarytas korteles" msgid "Main Menu" msgstr "Pagrindinis meniu" -#: src/apprt/gtk/ui/1.5/window.blp:278 +#: src/apprt/gtk/ui/1.5/window.blp:285 msgid "Command Palette" msgstr "Komandų paletė" -#: src/apprt/gtk/ui/1.5/window.blp:283 +#: src/apprt/gtk/ui/1.5/window.blp:290 msgid "Terminal Inspector" msgstr "Terminalo inspektorius" -#: src/apprt/gtk/ui/1.5/window.blp:300 src/apprt/gtk/class/window.zig:1714 +#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1727 msgid "About Ghostty" msgstr "Apie Ghostty" -#: src/apprt/gtk/ui/1.5/window.blp:305 +#: src/apprt/gtk/ui/1.5/window.blp:312 msgid "Quit" msgstr "Išeiti" @@ -323,18 +328,26 @@ msgstr "Komanda sėkminga" msgid "Command failed" msgstr "Komanda nepavyko" -#: src/apprt/gtk/class/window.zig:1001 +#: src/apprt/gtk/class/title_dialog.zig:225 +msgid "Change Terminal Title" +msgstr "Keisti terminalo pavadinimą" + +#: src/apprt/gtk/class/title_dialog.zig:226 +msgid "Change Tab Title" +msgstr "Keisti kortelės pavadinimą" + +#: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" msgstr "Konfigūracija įkelta iš naujo" -#: src/apprt/gtk/class/window.zig:1553 +#: src/apprt/gtk/class/window.zig:1566 msgid "Copied to clipboard" msgstr "Nukopijuota į iškarpinę" -#: src/apprt/gtk/class/window.zig:1555 +#: src/apprt/gtk/class/window.zig:1568 msgid "Cleared clipboard" msgstr "Iškarpinė išvalyta" -#: src/apprt/gtk/class/window.zig:1695 +#: src/apprt/gtk/class/window.zig:1708 msgid "Ghostty Developers" msgstr "Ghostty kūrėjai" From 3d3ea3fa596a27075a1cef601217a0780edca20c Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Fri, 20 Feb 2026 13:01:30 -0500 Subject: [PATCH 15/79] macos: swiftlint 'no_fallthrough_only' rule This rule is generally trying to be helpful, but it doesn't like a few places in our code base where we're intentionally listing out all of the well-known cases. Given that, just disable it. https://realm.github.io/SwiftLint/no_fallthrough_only.html --- macos/.swiftlint.yml | 2 +- macos/Sources/App/macOS/AppDelegate.swift | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/macos/.swiftlint.yml b/macos/.swiftlint.yml index 4cbaf0fac..7a27b45da 100644 --- a/macos/.swiftlint.yml +++ b/macos/.swiftlint.yml @@ -11,6 +11,7 @@ disabled_rules: - function_body_length - line_length - nesting + - no_fallthrough_only - todo - trailing_comma - trailing_newline @@ -20,7 +21,6 @@ disabled_rules: - for_where - force_cast - multiple_closures_with_trailing_closure - - no_fallthrough_only - switch_case_alignment identifier_name: diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1aa597a25..1b740e369 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -380,13 +380,7 @@ class AppDelegate: NSObject, if let why = event.attributeDescriptor(forKeyword: keyword) { switch why.typeCodeValue { - case kAEShutDown: - fallthrough - - case kAERestart: - fallthrough - - case kAEReallyLogOut: + case kAEShutDown, kAERestart, kAEReallyLogOut: return .terminateNow default: From f7e6639c43b9537f0fb4ebfa1544652c24a715ff Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Fri, 20 Feb 2026 19:18:40 -0500 Subject: [PATCH 16/79] macos: swiftlint 'switch_case_alignment' rule --- macos/.swiftlint.yml | 1 - .../QuickTerminalSpaceBehavior.swift | 24 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/macos/.swiftlint.yml b/macos/.swiftlint.yml index 7a27b45da..379fe9303 100644 --- a/macos/.swiftlint.yml +++ b/macos/.swiftlint.yml @@ -21,7 +21,6 @@ disabled_rules: - for_where - force_cast - multiple_closures_with_trailing_closure - - switch_case_alignment identifier_name: min_length: 1 diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift index 9f544b7e6..176cbf160 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift @@ -7,14 +7,14 @@ enum QuickTerminalSpaceBehavior { init?(fromGhosttyConfig string: String) { switch string { - case "move": - self = .move + case "move": + self = .move - case "remain": - self = .remain + case "remain": + self = .remain - default: - return nil + default: + return nil } } @@ -25,12 +25,12 @@ enum QuickTerminalSpaceBehavior { ] switch self { - case .move: - // We want this to move the window to the active space. - return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior) - case .remain: - // We want this to remain the window in the current space. - return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior) + case .move: + // We want this to move the window to the active space. + return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior) + case .remain: + // We want this to remain the window in the current space. + return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior) } } } From 2d6fa92d7837f3e19db495da9c159138380188a0 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Fri, 20 Feb 2026 19:39:25 -0500 Subject: [PATCH 17/79] macos: swiftlint 'for_where' rule --- macos/.swiftlint.yml | 1 - macos/Sources/App/macOS/AppDelegate+Ghostty.swift | 6 ++---- macos/Sources/App/macOS/AppDelegate.swift | 6 ++---- .../Features/Terminal/TerminalRestorable.swift | 8 +++----- .../Helpers/Extensions/NSView+Extension.swift | 12 ++++-------- 5 files changed, 11 insertions(+), 22 deletions(-) diff --git a/macos/.swiftlint.yml b/macos/.swiftlint.yml index 7a27b45da..4f5c55a9b 100644 --- a/macos/.swiftlint.yml +++ b/macos/.swiftlint.yml @@ -18,7 +18,6 @@ disabled_rules: - type_body_length # TODO - - for_where - force_cast - multiple_closures_with_trailing_closure - switch_case_alignment diff --git a/macos/Sources/App/macOS/AppDelegate+Ghostty.swift b/macos/Sources/App/macOS/AppDelegate+Ghostty.swift index 66b95e06e..fc9a49067 100644 --- a/macos/Sources/App/macOS/AppDelegate+Ghostty.swift +++ b/macos/Sources/App/macOS/AppDelegate+Ghostty.swift @@ -11,10 +11,8 @@ extension AppDelegate: Ghostty.Delegate { continue } - for surface in controller.surfaceTree { - if surface.id == id { - return surface - } + for surface in controller.surfaceTree where surface.id == id { + return surface } } diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1b740e369..582af1746 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -1076,10 +1076,8 @@ class AppDelegate: NSObject, func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { for c in TerminalController.all { - for view in c.surfaceTree { - if view.id == uuid { - return view - } + for view in c.surfaceTree where view.id == uuid { + return view } } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index a42d4c2f6..aab51f6bd 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -131,11 +131,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { var foundView: Ghostty.SurfaceView? - for view in c.surfaceTree { - if view.id.uuidString == focusedStr { - foundView = view - break - } + for view in c.surfaceTree where view.id.uuidString == focusedStr { + foundView = view + break } if let view = foundView { diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index 030de0d1d..b5cd2853b 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -52,10 +52,8 @@ extension NSView { return true } - for subview in subviews { - if subview.contains(view) { - return true - } + for subview in subviews where subview.contains(view) { + return true } return false @@ -67,10 +65,8 @@ extension NSView { return true } - for subview in subviews { - if subview.contains(className: name) { - return true - } + for subview in subviews where contains(className: name) { + return true } return false From 3404595c72d755d34c01fdb252a4b7fe8917c179 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:15:38 +0000 Subject: [PATCH 18/79] Update VOUCHED list (#10912) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/10906) from @mitchellh. Vouch: @NateSmyth Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 0e376c780..92bb1f13b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -82,6 +82,7 @@ misairuzame mitchellh miupa mtak +natesmyth nicosuave nwehg oshdubh From 07a68b3e6521e74922fcc099ffb9e34d8f6a44ad Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Feb 2026 20:23:44 -0800 Subject: [PATCH 19/79] ci: use `every` to filter vouch paths The prior filter wasn't working because the default quantifier is `any`. --- .github/workflows/test.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf171d42a..b52410e19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,22 +22,27 @@ jobs: # signaling that all other jobs can be skipped entirely. skip: ${{ steps.determine.outputs.skip }} # Path-based filters to gate specific linter/formatter jobs. - actions_pins: ${{ steps.filter.outputs.actions_pins }} - blueprints: ${{ steps.filter.outputs.blueprints }} - macos: ${{ steps.filter.outputs.macos }} - nix: ${{ steps.filter.outputs.nix }} - shell: ${{ steps.filter.outputs.shell }} - zig: ${{ steps.filter.outputs.zig }} + actions_pins: ${{ steps.filter_any.outputs.actions_pins }} + blueprints: ${{ steps.filter_any.outputs.blueprints }} + macos: ${{ steps.filter_any.outputs.macos }} + nix: ${{ steps.filter_any.outputs.nix }} + shell: ${{ steps.filter_any.outputs.shell }} + zig: ${{ steps.filter_any.outputs.zig }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - id: filter + id: filter_every with: + predicate-quantifier: "every" filters: | code: - '**' - '!.github/VOUCHED.td' + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter_any + with: + filters: | macos: - '.swiftlint.yml' - 'macos/**' @@ -64,7 +69,7 @@ jobs: - id: determine name: Determine skip run: | - if [ "${{ steps.filter.outputs.code }}" = "false" ]; then + if [ "${{ steps.filter_every.outputs.code }}" = "false" ]; then echo "skip=true" >> "$GITHUB_OUTPUT" else echo "skip=false" >> "$GITHUB_OUTPUT" From e7b8e731eb60733cc09a04d9ddec383244f97d0e Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:31:43 +0000 Subject: [PATCH 20/79] Update VOUCHED list (#10914) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/10581) from @mitchellh. Vouch: @neo773 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 92bb1f13b..61797178c 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -83,6 +83,7 @@ mitchellh miupa mtak natesmyth +neo773 nicosuave nwehg oshdubh From b65261eb6643ace961e5ea548c329b3cbd646c40 Mon Sep 17 00:00:00 2001 From: Alex Feijoo Date: Thu, 19 Feb 2026 11:39:12 -0500 Subject: [PATCH 21/79] macOS: expand tilde in file paths before opening `URL(filePath:)` treats `~` as a literal directory name, so cmd-clicking a path like `~/Documents/file.txt` would fail to open because the resulting file URL doesn't point to a real file. Use `NSString.expandingTildeInPath` to resolve `~` to the user's home directory before constructing the file URL. Co-Authored-By: Claude Opus 4.6 --- macos/Sources/Ghostty/Ghostty.App.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index e3441257f..6fb5118e8 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -695,7 +695,10 @@ extension Ghostty { if let candidate = URL(string: action.url), candidate.scheme != nil { url = candidate } else { - url = URL(filePath: action.url) + // Expand ~ to the user's home directory so that file paths + // like ~/Documents/file.txt resolve correctly. + let expandedPath = NSString(string: action.url).standardizingPath + url = URL(filePath: expandedPath) } switch action.kind { From 7c504649fd0b2b6d12a9a60a3e9c073315d09d64 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Feb 2026 21:05:53 -0800 Subject: [PATCH 22/79] ci: use explicit PAT with path-filter for higher rate limits --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b52410e19..16e6604ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,7 @@ jobs: - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter_every with: + token: ${{ secrets.GITHUB_TOKEN }} predicate-quantifier: "every" filters: | code: @@ -42,6 +43,7 @@ jobs: - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter_any with: + token: ${{ secrets.GITHUB_TOKEN }} filters: | macos: - '.swiftlint.yml' From bd9611650fd1c8e72367cf781270ddf3494ff451 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Sat, 21 Feb 2026 18:07:03 +0800 Subject: [PATCH 23/79] build: add support for Android NDK path configuration --- build.zig.zon | 1 + pkg/android-ndk/build.zig | 189 ++++++++++++++++++++++++++++++++++ pkg/android-ndk/build.zig.zon | 7 ++ pkg/highway/build.zig | 5 + pkg/highway/build.zig.zon | 1 + pkg/simdutf/build.zig | 5 + pkg/simdutf/build.zig.zon | 1 + pkg/utfcpp/build.zig | 5 + pkg/utfcpp/build.zig.zon | 1 + src/build/GhosttyLibVt.zig | 4 + 10 files changed, 219 insertions(+) create mode 100644 pkg/android-ndk/build.zig create mode 100644 pkg/android-ndk/build.zig.zon diff --git a/build.zig.zon b/build.zig.zon index fad3500d5..d2247bcc3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -115,6 +115,7 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, + .android_ndk = .{ .path = "./pkg/android-ndk" }, .iterm2_themes = .{ .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz", .hash = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z", diff --git a/pkg/android-ndk/build.zig b/pkg/android-ndk/build.zig new file mode 100644 index 000000000..8008c5aed --- /dev/null +++ b/pkg/android-ndk/build.zig @@ -0,0 +1,189 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +pub fn build(_: *std.Build) !void {} + +// Configure the step to point to the Android NDK for libc and include +// paths. This requires the Android NDK installed in the system and +// setting the appropriate environment variables or installing the NDK +// in the default location. +// +// The environment variables can be set as follows: +// - `ANDROID_NDK_HOME`: Directly points to the NDK path. +// - `ANDROID_HOME` or `ANDROID_SDK_ROOT`: Points to the Android SDK path; +// latest NDK will be automatically selected. +// +// NB: This is a workaround until zig natively supports bionic +// cross-compilation (ziglang/zig#23906). +pub fn addPaths(b: *std.Build, step: *std.Build.Step.Compile) !void { + const Cache = struct { + const Key = struct { + arch: std.Target.Cpu.Arch, + abi: std.Target.Abi, + api_level: u32, + }; + + var map: std.AutoHashMapUnmanaged(Key, ?struct { + libc: std.Build.LazyPath, + cpp_include: []const u8, + lib: []const u8, + }) = .{}; + }; + + const target = step.rootModuleTarget(); + const gop = try Cache.map.getOrPut(b.allocator, .{ + .arch = target.cpu.arch, + .abi = target.abi, + .api_level = target.os.version_range.linux.android, + }); + + if (!gop.found_existing) { + const ndk_path = findNDKPath(b.allocator) orelse { + gop.value_ptr.* = null; + return error.AndroidNDKNotFound; + }; + + var ndk_dir = std.fs.openDirAbsolute(ndk_path, .{}) catch { + gop.value_ptr.* = null; + return error.AndroidNDKNotFound; + }; + defer ndk_dir.close(); + + const ndk_triple = ndkTriple(target) orelse { + gop.value_ptr.* = null; + return error.AndroidNDKUnsupportedTarget; + }; + + const host = hostTag() orelse { + gop.value_ptr.* = null; + return error.AndroidNDKUnsupportedHost; + }; + + const sysroot = try std.fs.path.join(b.allocator, &.{ + ndk_path, "toolchains", "llvm", "prebuilt", host, "sysroot", + }); + const include_dir = try std.fs.path.join( + b.allocator, + &.{ sysroot, "usr", "include" }, + ); + const sys_include_dir = try std.fs.path.join( + b.allocator, + &.{ sysroot, "usr", "include", ndk_triple }, + ); + + var api_buf: [10]u8 = undefined; + const api_level = target.os.version_range.linux.android; + const api_level_str = std.fmt.bufPrint(&api_buf, "{d}", .{api_level}) catch unreachable; + const c_runtime_dir = try std.fs.path.join( + b.allocator, + &.{ sysroot, "usr", "lib", ndk_triple, api_level_str }, + ); + + const libc_txt = try std.fmt.allocPrint(b.allocator, + \\include_dir={s} + \\sys_include_dir={s} + \\crt_dir={s} + \\msvc_lib_dir= + \\kernel32_lib_dir= + \\gcc_dir= + , .{ include_dir, sys_include_dir, c_runtime_dir }); + + const wf = b.addWriteFiles(); + const libc_path = wf.add("libc.txt", libc_txt); + const lib = try std.fs.path.join(b.allocator, &.{ sysroot, "usr", "lib", ndk_triple }); + const cpp_include = try std.fs.path.join(b.allocator, &.{ sysroot, "usr", "include", "c++", "v1" }); + + gop.value_ptr.* = .{ + .lib = lib, + .libc = libc_path, + .cpp_include = cpp_include, + }; + } + + const value = gop.value_ptr.* orelse return error.AndroidNDKNotFound; + + step.setLibCFile(value.libc); + step.root_module.addSystemIncludePath(.{ .cwd_relative = value.cpp_include }); + step.root_module.addLibraryPath(.{ .cwd_relative = value.lib }); +} + +fn findNDKPath(allocator: std.mem.Allocator) ?[]const u8 { + // Check if user has set the environment variable for the NDK path. + if (std.process.getEnvVarOwned(allocator, "ANDROID_NDK_HOME") catch null) |value| { + if (value.len > 0) return value; + } + + // Check the common environment variables for the Android SDK path and look for the NDK inside it. + inline for (.{ "ANDROID_HOME", "ANDROID_SDK_ROOT" }) |env| { + if (std.process.getEnvVarOwned(allocator, env) catch null) |sdk| { + if (sdk.len > 0) { + if (findLatestNDK(allocator, sdk)) |ndk| return ndk; + } + } + } + + // As a fallback, we assume the most common/default SDK path based on the OS. + const home = std.process.getEnvVarOwned( + allocator, + if (builtin.os.tag == .windows) "LOCALAPPDATA" else "HOME", + ) catch return null; + + const default_sdk_path = std.fs.path.join(allocator, &.{ + home, switch (builtin.os.tag) { + .linux => "Android/sdk", + .macos => "Library/Android/Sdk", + .windows => "Android/Sdk", + else => return null, + }, + }) catch return null; + return findLatestNDK(allocator, default_sdk_path); +} + +fn findLatestNDK(allocator: std.mem.Allocator, sdk_path: []const u8) ?[]const u8 { + const ndk_dir = std.fs.path.join(allocator, &.{ sdk_path, "ndk" }) catch return null; + var dir = std.fs.openDirAbsolute(ndk_dir, .{ .iterate = true }) catch return null; + defer dir.close(); + + var latest_version: ?[]const u8 = null; + var latest_parsed: ?std.SemanticVersion = null; + var iterator = dir.iterate(); + + while (iterator.next() catch null) |file| { + if (file.kind != .directory) continue; + const parsed = std.SemanticVersion.parse(file.name) catch continue; + if (latest_version == null or parsed.order(latest_parsed.?) == .gt) { + if (latest_version) |old| allocator.free(old); + latest_version = allocator.dupe(u8, file.name) catch return null; + latest_parsed = parsed; + } + } + + if (latest_version) |version| { + return std.fs.path.join(allocator, &.{ sdk_path, "ndk", version }) catch return null; + } + + return null; +} + +fn hostTag() ?[]const u8 { + return switch (builtin.os.tag) { + .linux => "linux-x86_64", + // All darwin hosts use the same prebuilt binaries + // (https://developer.android.com/ndk/guides/other_build_systems). + .macos => "darwin-x86_64", + .windows => "windows-x86_64", + else => null, + }; +} + +// We must map the target architecture to the corresponding NDK triple following the NDK +// documentation: https://android.googlesource.com/platform/ndk/+/master/docs/BuildSystemMaintainers.md#architectures +fn ndkTriple(target: std.Target) ?[]const u8 { + return switch (target.cpu.arch) { + .arm => "arm-linux-androideabi", + .aarch64 => "aarch64-linux-android", + .x86 => "i686-linux-android", + .x86_64 => "x86_64-linux-android", + else => null, + }; +} diff --git a/pkg/android-ndk/build.zig.zon b/pkg/android-ndk/build.zig.zon new file mode 100644 index 000000000..97febcefb --- /dev/null +++ b/pkg/android-ndk/build.zig.zon @@ -0,0 +1,7 @@ +.{ + .name = .android_ndk, + .version = "0.0.1", + .dependencies = .{}, + .fingerprint = 0xee68d62c5a97b68b, + .paths = .{""}, +} diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index 3715baf4a..b6e188b13 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -31,6 +31,11 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, lib); } + if (target.result.abi.isAndroid()) { + const android_ndk = @import("android_ndk"); + try android_ndk.addPaths(b, lib); + } + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); try flags.appendSlice(b.allocator, &.{ diff --git a/pkg/highway/build.zig.zon b/pkg/highway/build.zig.zon index 0777fcb7a..4870d1db5 100644 --- a/pkg/highway/build.zig.zon +++ b/pkg/highway/build.zig.zon @@ -12,5 +12,6 @@ }, .apple_sdk = .{ .path = "../apple-sdk" }, + .android_ndk = .{ .path = "../android-ndk" }, }, } diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index 3123cab21..8dcd141c1 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -20,6 +20,11 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, lib); } + if (target.result.abi.isAndroid()) { + const android_ndk = @import("android_ndk"); + try android_ndk.addPaths(b, lib); + } + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414 diff --git a/pkg/simdutf/build.zig.zon b/pkg/simdutf/build.zig.zon index cd81c841e..afbef5418 100644 --- a/pkg/simdutf/build.zig.zon +++ b/pkg/simdutf/build.zig.zon @@ -5,5 +5,6 @@ .paths = .{""}, .dependencies = .{ .apple_sdk = .{ .path = "../apple-sdk" }, + .android_ndk = .{ .path = "../android-ndk" }, }, } diff --git a/pkg/utfcpp/build.zig b/pkg/utfcpp/build.zig index e06813b83..08efb4ac8 100644 --- a/pkg/utfcpp/build.zig +++ b/pkg/utfcpp/build.zig @@ -19,6 +19,11 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, lib); } + if (target.result.abi.isAndroid()) { + const android_ndk = @import("android_ndk"); + try android_ndk.addPaths(b, lib); + } + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); diff --git a/pkg/utfcpp/build.zig.zon b/pkg/utfcpp/build.zig.zon index eff395a60..1077e9655 100644 --- a/pkg/utfcpp/build.zig.zon +++ b/pkg/utfcpp/build.zig.zon @@ -12,5 +12,6 @@ }, .apple_sdk = .{ .path = "../apple-sdk" }, + .android_ndk = .{ .path = "../android-ndk" }, }, } diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 2f3d4a124..99c603275 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -62,6 +62,10 @@ pub fn initShared( .{ .include_extensions = &.{".h"} }, ); + if (lib.rootModuleTarget().abi.isAndroid()) { + try @import("android_ndk").addPaths(b, lib); + } + if (lib.rootModuleTarget().os.tag.isDarwin()) { // Self-hosted x86_64 doesn't work for darwin. It may not work // for other platforms too but definitely darwin. From e7cfb17d5a28c5eebe33c0f733de1d80a51773f2 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Sat, 21 Feb 2026 22:48:36 +0800 Subject: [PATCH 24/79] build: support 16kb page sizes for Android 15+ --- src/build/GhosttyLibVt.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 99c603275..6d44c62b6 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -63,6 +63,9 @@ pub fn initShared( ); if (lib.rootModuleTarget().abi.isAndroid()) { + // Support 16kb page sizes, required for Android 15+. + lib.link_z_max_page_size = 16384; // 16kb + try @import("android_ndk").addPaths(b, lib); } From 407b3c082fb700be57ebf5c114b5d4f686c72c30 Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Sat, 21 Feb 2026 09:03:58 -0600 Subject: [PATCH 25/79] macos: fix new tab crash It was introduced in 2a81d8cd2910b12fe007f0bc5fb5d6be57f0f0fe[0]. We lost the subview. prefix of from the contains() call. Co-authored-by: Brent Schroeter Fixes: https://github.com/ghostty-org/ghostty/issues/10923 Link: https://github.com/ghostty-org/ghostty/commit/2a81d8cd2910b12fe007f0bc5fb5d6be57f0f0fe [0] --- macos/Sources/Helpers/Extensions/NSView+Extension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index b5cd2853b..2546caa38 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -65,7 +65,7 @@ extension NSView { return true } - for subview in subviews where contains(className: name) { + for subview in subviews where subview.contains(className: name) { return true } From b728e41d77617188f38a20b10dfc5698b2ffe297 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Sat, 21 Feb 2026 23:43:34 +0800 Subject: [PATCH 26/79] build: clarify ANDROID_NDK_HOME variable description --- pkg/android-ndk/build.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/android-ndk/build.zig b/pkg/android-ndk/build.zig index 8008c5aed..8d4668fe5 100644 --- a/pkg/android-ndk/build.zig +++ b/pkg/android-ndk/build.zig @@ -9,9 +9,9 @@ pub fn build(_: *std.Build) !void {} // in the default location. // // The environment variables can be set as follows: -// - `ANDROID_NDK_HOME`: Directly points to the NDK path. +// - `ANDROID_NDK_HOME`: Directly points to the NDK path, including the version. // - `ANDROID_HOME` or `ANDROID_SDK_ROOT`: Points to the Android SDK path; -// latest NDK will be automatically selected. +// latest available NDK will be automatically selected. // // NB: This is a workaround until zig natively supports bionic // cross-compilation (ziglang/zig#23906). From 88a6e8ae4b4030cacf41c25a8790e0dbf0f02698 Mon Sep 17 00:00:00 2001 From: Elias Andualem Date: Sun, 22 Feb 2026 00:01:03 +0800 Subject: [PATCH 27/79] build: add Android build target for libghostty-vt --- .github/workflows/test.yml | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 16e6604ed..4d392266e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,6 +88,7 @@ jobs: - build-examples - build-flatpak - build-libghostty-vt + - build-libghostty-vt-android - build-libghostty-vt-macos - build-linux - build-linux-libghostty @@ -350,6 +351,55 @@ jobs: nix develop -c zig build lib-vt \ -Dtarget=${{ matrix.target }} + # lib-vt requires the Android NDK for Android builds + build-libghostty-vt-android: + strategy: + matrix: + target: [aarch64-linux-android, x86_64-linux-android, arm-linux-androideabi] + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + ANDROID_NDK_VERSION: r29 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + with: + path: | + /nix + /zig + /opt/android-ndk + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Setup Android NDK + run: | + NDK_ROOT="$HOME/Android/ndk" + NDK_DIR = "$NDK_DIR/android-ndk-${{ env.ANDROID_NDK_VERSION }}" + if [ ! -d "$NDK_DIR" ]; then + curl -fsSL -o /tmp/ndk.zip "https://dl.google.com/android/repository/android-ndk-${ANDROID_NDK_VERSION}-linux.zip" + mkdir -p $NDK_DIR + unzip -q /tmp/ndk.zip -d $NDK_DIR + rm /tmp/ndk.zip + fi + echo "ANDROID_NDK_HOME=$NDK_DIR" >> "$GITHUB_ENV" + + - name: Build + run: | + nix develop -c zig build lib-vt \ + -Dtarget=${{ matrix.target }} + build-linux: strategy: fail-fast: false From 2e172eeb60b0096f2946eb631b2e8bc294e45c62 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:18:28 +0000 Subject: [PATCH 28/79] Update VOUCHED list (#10927) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/10920) from @mitchellh. Vouch: @sunshine-syz Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 61797178c..602dc21e8 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -106,6 +106,7 @@ rpfaeffle secrus silveirapf slsrepo +sunshine-syz tdslot ticclick tnagatomi From dd29617cd33225b865a3ba0e1a865f0c98142f23 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Fri, 20 Feb 2026 20:28:48 -0500 Subject: [PATCH 29/79] macos: swiftlint 'multiple_closures_with_trailing_closure' rule Also, re-enable the 'force_cast' rule, which was addressed earlier. --- macos/.swiftlint.yml | 4 ---- macos/Sources/Features/Update/UpdatePill.swift | 4 ++-- macos/Sources/Ghostty/Surface View/SurfaceView.swift | 8 ++++---- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/macos/.swiftlint.yml b/macos/.swiftlint.yml index 701a037e8..d2b371cc1 100644 --- a/macos/.swiftlint.yml +++ b/macos/.swiftlint.yml @@ -17,10 +17,6 @@ disabled_rules: - trailing_newline - type_body_length - # TODO - - force_cast - - multiple_closures_with_trailing_closure - identifier_name: min_length: 1 allowed_symbols: ["_"] diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index 53dfbe842..b14cde1ac 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -47,7 +47,7 @@ struct UpdatePill: View { } else { showPopover.toggle() } - }) { + }, label: { HStack(spacing: 6) { UpdateBadge(model: model) .frame(width: 14, height: 14) @@ -66,7 +66,7 @@ struct UpdatePill: View { ) .foregroundColor(model.foregroundColor) .contentShape(Capsule()) - } + }) .buttonStyle(.plain) .help(model.text) .accessibilityLabel(model.text) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 221bc4c37..fb5a1a864 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -454,18 +454,18 @@ extension Ghostty { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) - }) { + }, label: { Image(systemName: "chevron.up") - } + }) .buttonStyle(SearchButtonStyle()) Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:previous" ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) - }) { + }, label: { Image(systemName: "chevron.down") - } + }) .buttonStyle(SearchButtonStyle()) Button(action: onClose) { From 2e102b015facfa82432d09bd5d8151d239552800 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:49:53 +0000 Subject: [PATCH 30/79] Update VOUCHED list (#10931) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/10840#issuecomment-3939561550) from @trag1c. Vouch: @JosephMart Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 602dc21e8..43d66d952 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -60,6 +60,7 @@ jacobsandlund jake-stewart jcollie johnslavik +josephmart jparise juniqlim kawarimidoll From 12c2f5c3590631cbfa37597d9ec3ca785592f3d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Feb 2026 13:56:53 -0800 Subject: [PATCH 31/79] prettier --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d392266e..eb41c1818 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -355,7 +355,8 @@ jobs: build-libghostty-vt-android: strategy: matrix: - target: [aarch64-linux-android, x86_64-linux-android, arm-linux-androideabi] + target: + [aarch64-linux-android, x86_64-linux-android, arm-linux-androideabi] runs-on: namespace-profile-ghostty-sm needs: test env: From caec9e04d21db3bb1dabe2186529b6e5e9baa1f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Feb 2026 14:28:38 -0800 Subject: [PATCH 32/79] renderer: kitty image update requires draw_mutex Fixes #10680 The image state is used for drawing, so when we update it, we need to acquire the draw mutex. All our other state updates already acquire the draw mutex but Kitty images are odd in that they happen in the critical area (due to their size). --- src/renderer/generic.zig | 4 ++++ src/renderer/image.zig | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 83417429e..f57339893 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1226,6 +1226,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // kitty state on every frame because any cell change can move // an image. if (self.images.kittyRequiresUpdate(state.terminal)) { + // We need to grab the draw mutex since this updates + // our image state that drawFrame uses. + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); self.images.kittyUpdate( self.alloc, state.terminal, diff --git a/src/renderer/image.zig b/src/renderer/image.zig index 85f3a01ed..c43d27981 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -844,7 +844,7 @@ pub const Image = union(enum) { /// Converts the image data to a format that can be uploaded to the GPU. /// If the data is already in a format that can be uploaded, this is a /// no-op. - pub fn convert(self: *Image, alloc: Allocator) wuffs.Error!void { + fn convert(self: *Image, alloc: Allocator) wuffs.Error!void { const p = self.getPendingPointer().?; // As things stand, we currently convert all images to RGBA before // uploading to the GPU. This just makes things easier. In the future @@ -867,7 +867,7 @@ pub const Image = union(enum) { /// Prepare the pending image data for upload to the GPU. /// This doesn't need GPU access so is safe to call any time. - pub fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void { + fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void { assert(self.isPending()); try self.convert(alloc); } From cdfa73b403d2c7c26201311c9a7706da4ef11129 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Feb 2026 14:37:58 -0800 Subject: [PATCH 33/79] config: selection-word-chars parses escape sequences Fixes #10548 Escaped characters in selection-word-chars are now correctly parsed, allowing for characters like `\t` to be included in the set of word characters. --- src/config/Config.zig | 64 +++++++++++++++++++++++++++++------ src/config/string.zig | 79 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 10 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 4e1ed1f4b..c1888dbe8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -39,6 +39,7 @@ pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); const KeyRemapSet = @import("../input/key_mods.zig").RemapSet; +const string = @import("string.zig"); // We do this instead of importing all of terminal/main.zig to // limit the dependency graph. This is important because some things @@ -5965,22 +5966,15 @@ pub const SelectionWordChars = struct { pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { const value = input orelse return error.ValueRequired; - // Parse UTF-8 string into codepoints + // Parse string with Zig escape sequence support into codepoints var list: std.ArrayList(u21) = .empty; defer list.deinit(alloc); // Always include null as first boundary try list.append(alloc, 0); - // Parse the UTF-8 string - const utf8_view = std.unicode.Utf8View.init(value) catch { - // Invalid UTF-8, just use null boundary - self.codepoints = try list.toOwnedSlice(alloc); - return; - }; - - var utf8_it = utf8_view.iterator(); - while (utf8_it.nextCodepoint()) |codepoint| { + var it = string.codepointIterator(value); + while (it.next() catch return error.InvalidValue) |codepoint| { try list.append(alloc, codepoint); } @@ -6033,6 +6027,56 @@ pub const SelectionWordChars = struct { try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]); try testing.expectEqual(@as(u21, ','), chars.codepoints[4]); } + + test "parseCLI escape sequences" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \t escape should be parsed as tab + var chars: Self = .{}; + try chars.parseCLI(alloc, " \\t;,"); + + try testing.expectEqual(@as(usize, 5), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, ' '), chars.codepoints[1]); + try testing.expectEqual(@as(u21, '\t'), chars.codepoints[2]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]); + try testing.expectEqual(@as(u21, ','), chars.codepoints[4]); + } + + test "parseCLI backslash escape" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \\ should be parsed as a single backslash + var chars: Self = .{}; + try chars.parseCLI(alloc, "\\\\;"); + + try testing.expectEqual(@as(usize, 3), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, '\\'), chars.codepoints[1]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]); + } + + test "parseCLI unicode escape" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \u{2502} should be parsed as │ + var chars: Self = .{}; + try chars.parseCLI(alloc, "\\u{2502};"); + + try testing.expectEqual(@as(usize, 3), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, '│'), chars.codepoints[1]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]); + } }; /// FontVariation is a repeatable configuration value that sets a single diff --git a/src/config/string.zig b/src/config/string.zig index 71826f005..450799373 100644 --- a/src/config/string.zig +++ b/src/config/string.zig @@ -36,6 +36,40 @@ pub fn parse(out: []u8, bytes: []const u8) ![]u8 { return out[0..dst_i]; } +/// Creates an iterator that requires no allocation to extract codepoints +/// from the string literal, parsing escape sequences as it goes. +pub fn codepointIterator(bytes: []const u8) CodepointIterator { + return .{ .bytes = bytes, .i = 0 }; +} + +pub const CodepointIterator = struct { + bytes: []const u8, + i: usize, + + pub fn next(self: *CodepointIterator) error{InvalidString}!?u21 { + if (self.i >= self.bytes.len) return null; + switch (self.bytes[self.i]) { + // An escape sequence + '\\' => return switch (std.zig.string_literal.parseEscapeSequence( + self.bytes, + &self.i, + )) { + .failure => error.InvalidString, + .success => |cp| cp, + }, + + // Not an escape, parse as UTF-8 + else => |start| { + const cp_len = std.unicode.utf8ByteSequenceLength(start) catch + return error.InvalidString; + defer self.i += cp_len; + return std.unicode.utf8Decode(self.bytes[self.i..][0..cp_len]) catch + return error.InvalidString; + }, + } + } +}; + test "parse: empty" { const testing = std.testing; @@ -65,3 +99,48 @@ test "parse: escapes" { try testing.expectEqualStrings("hello\u{1F601}world", result); } } + +test "codepointIterator: empty" { + var it = codepointIterator(""); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: ascii no escapes" { + var it = codepointIterator("abc"); + try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'c'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: multibyte utf8" { + // │ is U+2502 (3 bytes in UTF-8) + var it = codepointIterator("a│b"); + try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '│'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: escape sequences" { + var it = codepointIterator("a\\tb\\n\\\\"); + try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '\t'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '\n'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '\\'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: unicode escape" { + var it = codepointIterator("\\u{2502}x"); + try std.testing.expectEqual(@as(u21, '│'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'x'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: emoji unicode escape" { + var it = codepointIterator("\\u{1F601}"); + try std.testing.expectEqual(@as(u21, 0x1F601), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} From 3de6922295782cec35e155cfb43635c1da8704ab Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:07:39 +0000 Subject: [PATCH 34/79] Update VOUCHED list (#10936) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/10824) from @mitchellh. Vouch: @rgehan Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 43d66d952..82b72780d 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -100,6 +100,7 @@ priyans-hu prsweet qwerasd205 reo101 +rgehan rmengelbrecht rmunn rockorager From fad5599c32581d4bdfdf1fc3230b906e18c90500 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 22 Feb 2026 00:18:10 +0000 Subject: [PATCH 35/79] 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 fad3500d5..08082b409 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-20260202-151632-49169e9.tgz", - .hash = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", + .hash = "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index b30fdeddb..a7abe2611 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z": { + "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz", - "hash": "sha256-xN+3iQaN3uIJ/BzkgFxLojgHqeuz1htNcVjcjWR7Qjg=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", + "hash": "sha256-FCALuGoMgUq2lgnVALKAs5a20uuDXt8Gdt5KeJwKqP0=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 65c8c555b..5bee4ebb3 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -171,11 +171,11 @@ in }; } { - name = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z"; + name = "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz"; - hash = "sha256-xN+3iQaN3uIJ/BzkgFxLojgHqeuz1htNcVjcjWR7Qjg="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz"; + hash = "sha256-FCALuGoMgUq2lgnVALKAs5a20uuDXt8Gdt5KeJwKqP0="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 1477ff010..5548749d4 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,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-20260202-151632-49169e9.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.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 38a7e6dbd..cf348f6d1 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz", - "dest": "vendor/p/N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z", - "sha256": "c4dfb789068ddee209fc1ce4805c4ba23807a9ebb3d61b4d7158dc8d647b4238" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", + "dest": "vendor/p/N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF", + "sha256": "14200bb86a0c814ab69609d500b280b396b6d2eb835edf0676de4a789c0aa8fd" }, { "type": "archive", From c4c58a9f584d269c2e991292c209e708a0ec2f60 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Feb 2026 20:35:38 -0800 Subject: [PATCH 36/79] update deps to mirror --- .gitignore | 1 + build.zig.zon | 4 +- build.zig.zon.bak | 124 -------------------------------------- build.zig.zon.json | 4 +- build.zig.zon.nix | 4 +- build.zig.zon.txt | 4 +- flatpak/zig-packages.json | 4 +- 7 files changed, 11 insertions(+), 134 deletions(-) delete mode 100644 build.zig.zon.bak diff --git a/.gitignore b/.gitignore index e521f8851..40a04dbae 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ zig-cache/ .zig-cache/ zig-out/ +/build.zig.zon.bak /result* /.nixos-test-history example/*.wasm diff --git a/build.zig.zon b/build.zig.zon index 08082b409..279954492 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -21,7 +21,7 @@ }, .z2d = .{ // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + .url = "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz", .hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ", .lazy = true, }, @@ -39,7 +39,7 @@ }, .uucode = .{ // jacobsandlund/uucode - .url = "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz", + .url = "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz", .hash = "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9", }, .zig_wayland = .{ diff --git a/build.zig.zon.bak b/build.zig.zon.bak deleted file mode 100644 index 191ae7fa9..000000000 --- a/build.zig.zon.bak +++ /dev/null @@ -1,124 +0,0 @@ -.{ - .name = .ghostty, - .version = "1.3.0-dev", - .paths = .{""}, - .fingerprint = 0x64407a2a0b4147e5, - .minimum_zig_version = "0.15.2", - .dependencies = .{ - // Zig libs - - .libxev = .{ - // mitchellh/libxev - .url = "https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz", - .hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs", - .lazy = true, - }, - .vaxis = .{ - // rockorager/libvaxis - .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", - .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", - .lazy = true, - }, - .z2d = .{ - // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", - .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", - .lazy = true, - }, - .zig_objc = .{ - // mitchellh/zig-objc - .url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz", - .hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK", - .lazy = true, - }, - .zig_js = .{ - // mitchellh/zig-js - .url = "https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz", - .hash = "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi", - .lazy = true, - }, - .uucode = .{ - // jacobsandlund/uucode - .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", - .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", - }, - .zig_wayland = .{ - // codeberg ifreund/zig-wayland - .url = "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz", - .hash = "wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe", - .lazy = true, - }, - .zf = .{ - // natecraddock/zf - .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", - .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", - .lazy = true, - }, - .gobject = .{ - // https://github.com/ghostty-org/zig-gobject based on zig_gobject - // Temporary until we generate them at build time automatically. - .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", - .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", - .lazy = true, - }, - - // C libs - .cimgui = .{ .path = "./pkg/cimgui", .lazy = true }, - .fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true }, - .freetype = .{ .path = "./pkg/freetype", .lazy = true }, - .gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true }, - .harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true }, - .highway = .{ .path = "./pkg/highway", .lazy = true }, - .libintl = .{ .path = "./pkg/libintl", .lazy = true }, - .libpng = .{ .path = "./pkg/libpng", .lazy = true }, - .macos = .{ .path = "./pkg/macos", .lazy = true }, - .oniguruma = .{ .path = "./pkg/oniguruma", .lazy = true }, - .opengl = .{ .path = "./pkg/opengl", .lazy = true }, - .sentry = .{ .path = "./pkg/sentry", .lazy = true }, - .simdutf = .{ .path = "./pkg/simdutf", .lazy = true }, - .utfcpp = .{ .path = "./pkg/utfcpp", .lazy = true }, - .wuffs = .{ .path = "./pkg/wuffs", .lazy = true }, - .zlib = .{ .path = "./pkg/zlib", .lazy = true }, - - // Shader translation - .glslang = .{ .path = "./pkg/glslang", .lazy = true }, - .spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true }, - - // Wayland - .wayland = .{ - .url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", - .hash = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t", - .lazy = true, - }, - .wayland_protocols = .{ - .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", - .hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S", - .lazy = true, - }, - .plasma_wayland_protocols = .{ - .url = "https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz", - .hash = "N-V-__8AAKYZBAB-CFHBKs3u4JkeiT4BMvyHu3Y5aaWF3Bbs", - .lazy = true, - }, - - // Fonts - .jetbrains_mono = .{ - .url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz", - .hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x", - .lazy = true, - }, - .nerd_fonts_symbols_only = .{ - .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", - .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", - .lazy = true, - }, - - // Other - .apple_sdk = .{ .path = "./pkg/apple-sdk" }, - .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", - .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", - .lazy = true, - }, - }, -} diff --git a/build.zig.zon.json b/build.zig.zon.json index a7abe2611..4a88e2017 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -121,7 +121,7 @@ }, "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9": { "name": "uucode", - "url": "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz", "hash": "sha256-0KvuD0+L1urjwFF3fhbnxC2JZKqqAVWRxOVlcD9GX5U=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { @@ -146,7 +146,7 @@ }, "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ": { "name": "z2d", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz", "hash": "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c=" }, "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 5bee4ebb3..53e1b6c02 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -278,7 +278,7 @@ in name = "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9"; path = fetchZigArtifact { name = "uucode"; - url = "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz"; + url = "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz"; hash = "sha256-0KvuD0+L1urjwFF3fhbnxC2JZKqqAVWRxOVlcD9GX5U="; }; } @@ -318,7 +318,7 @@ in name = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ"; path = fetchZigArtifact { name = "z2d"; - url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz"; + url = "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz"; hash = "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 5548749d4..4ac9e6592 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -21,16 +21,16 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz +https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz +https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz -https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz -https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index cf348f6d1..e58ecd448 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -145,7 +145,7 @@ }, { "type": "archive", - "url": "https://github.com/jacobsandlund/uucode/archive/refs/tags/v0.2.0.tar.gz", + "url": "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz", "dest": "vendor/p/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9", "sha256": "d0abee0f4f8bd6eae3c051777e16e7c42d8964aaaa015591c4e565703f465f95" }, @@ -175,7 +175,7 @@ }, { "type": "archive", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz", "dest": "vendor/p/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ", "sha256": "69f21da2efd5ee0937fe55c4d09e48afc4fb2f91a01ef167c8c275ae046797f7" }, From 79e530a0f3e2aa86062a6b15da6984cfa4abba6b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Feb 2026 14:05:47 -0800 Subject: [PATCH 37/79] ci: fix CI for NDK --- .github/workflows/test.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb41c1818..713076438 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -373,7 +373,7 @@ jobs: path: | /nix /zig - /opt/android-ndk + ~/Android/ndk # Install Nix and use that to run our tests so our environment matches exactly. - uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1 @@ -387,11 +387,12 @@ jobs: - name: Setup Android NDK run: | NDK_ROOT="$HOME/Android/ndk" - NDK_DIR = "$NDK_DIR/android-ndk-${{ env.ANDROID_NDK_VERSION }}" + NDK_DIR="$NDK_ROOT/android-ndk-${{ env.ANDROID_NDK_VERSION }}" if [ ! -d "$NDK_DIR" ]; then - curl -fsSL -o /tmp/ndk.zip "https://dl.google.com/android/repository/android-ndk-${ANDROID_NDK_VERSION}-linux.zip" - mkdir -p $NDK_DIR - unzip -q /tmp/ndk.zip -d $NDK_DIR + curl -fsSL -o /tmp/ndk.zip \ + "https://dl.google.com/android/repository/android-ndk-${{ env.ANDROID_NDK_VERSION }}-linux.zip" + mkdir -p $NDK_ROOT + unzip -q /tmp/ndk.zip -d $NDK_ROOT rm /tmp/ndk.zip fi echo "ANDROID_NDK_HOME=$NDK_DIR" >> "$GITHUB_ENV" From 504a3611f6f2d9a1cb1a4eb419b41aea3c8049ca Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:04:37 +0000 Subject: [PATCH 38/79] Update VOUCHED list (#10947) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/10946) from @00-kat. Vouch: @Laxystem Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 82b72780d..4bb8d652a 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -71,6 +71,7 @@ kjvdven kloneets kristina8888 kristofersoler +laxystem liby lonsagisawa mahnokropotkinvich From c6e7a7b85ad8cc721e8986ca4033313714f5c3f7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Feb 2026 13:49:34 -0800 Subject: [PATCH 39/79] input: Disallow table/chain= and make chain apply to the most recent table Fixes #10039 (Context is all there) --- src/config/Config.zig | 97 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index c1888dbe8..9b0e6cc0f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1858,6 +1858,12 @@ class: ?[:0]const u8 = null, /// If an invalid key is pressed, the sequence ends but the table remains /// active. /// +/// * Chain actions work within tables, the `chain` keyword applies to +/// the most recently defined binding in the table. e.g. if you set +/// `table/ctrl+a=new_window` you can chain by using `chain=text:hello`. +/// Important: chain itself doesn't get prefixed with the table name, +/// since it applies to the most recent binding in any table. +/// /// * Prefixes like `global:` work within tables: /// `foo/global:ctrl+a=new_window`. /// @@ -6213,6 +6219,15 @@ pub const Keybinds = struct { /// which allows all table names to be available without reservation. tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty, + /// The most recent binding target for `chain=` additions. + /// + /// This is intentionally tracked at the Keybinds level so that chains can + /// apply across table boundaries according to parse order. + chain_target: union(enum) { + root, + table: []const u8, + } = .root, + 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 @@ -6220,6 +6235,7 @@ pub const Keybinds = struct { // will be freed when the config is freed. self.set = .{}; self.tables = .empty; + self.chain_target = .root; // keybinds for opening and reloading config try self.set.put( @@ -7002,6 +7018,7 @@ pub const Keybinds = struct { log.info("config has 'keybind = clear', all keybinds cleared", .{}); self.set = .{}; self.tables = .empty; + self.chain_target = .root; return; } @@ -7039,16 +7056,39 @@ pub const Keybinds = struct { if (binding.len == 0) { log.debug("config has 'keybind = {s}/', table cleared", .{table_name}); gop.value_ptr.* = .{}; + self.chain_target = .root; return; } + // Chains are only allowed at the root level. Their target is + // tracked globally by parse order in `self.chain_target`. + if (std.mem.startsWith(u8, binding, "chain=")) { + return error.InvalidFormat; + } + // Parse and add the binding to the table try gop.value_ptr.parseAndPut(alloc, binding); + self.chain_target = .{ .table = gop.key_ptr.* }; + return; + } + + if (std.mem.startsWith(u8, value, "chain=")) { + switch (self.chain_target) { + .root => try self.set.parseAndPut(alloc, value), + .table => |table_name| { + const table = self.tables.getPtr(table_name) orelse { + self.chain_target = .root; + return error.InvalidFormat; + }; + try table.parseAndPut(alloc, value); + }, + } return; } // Parse into default set try self.set.parseAndPut(alloc, value); + self.chain_target = .root; } /// Deep copy of the struct. Required by Config. @@ -7490,6 +7530,63 @@ pub const Keybinds = struct { try testing.expect(keybinds.tables.contains("mytable")); } + test "parseCLI chain without prior parsed binding 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, "chain=new_tab"), + ); + } + + test "parseCLI table chain syntax is invalid" { + 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/a=text:hello"); + try testing.expectError( + error.InvalidFormat, + keybinds.parseCLI(alloc, "foo/chain=deactivate_key_table"), + ); + } + + test "parseCLI chain applies to most recent table binding" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "ctrl+n=activate_key_table:foo"); + try keybinds.parseCLI(alloc, "foo/a=text:hello"); + try keybinds.parseCLI(alloc, "chain=deactivate_key_table"); + + const root_entry = keybinds.set.get(.{ + .mods = .{ .ctrl = true }, + .key = .{ .unicode = 'n' }, + }).?.value_ptr.*; + try testing.expect(root_entry == .leaf); + try testing.expect(root_entry.leaf.action == .activate_key_table); + + const foo_entry = keybinds.tables.get("foo").?.get(.{ + .key = .{ .unicode = 'a' }, + }).?.value_ptr.*; + try testing.expect(foo_entry == .leaf_chained); + try testing.expectEqual(@as(usize, 2), foo_entry.leaf_chained.actions.items.len); + try testing.expect(foo_entry.leaf_chained.actions.items[0] == .text); + try testing.expect(foo_entry.leaf_chained.actions.items[1] == .deactivate_key_table); + } + test "clone with tables" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); From 2a02b8f0efb6a73eef2faba070283ea3752cd245 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 22 Feb 2026 16:26:22 -0600 Subject: [PATCH 40/79] android: build improvements * Use a GitHub action to download the Android NDK * Use helper functions available on `std.Build` to simplify the build script. * Use various Zig-isms to simplify the code. FYI, using Nix to seems to be a non-starter as getting any Android development kits from nixpkgs requires accepting the Android license agreement and allowing many packages to use unfree licenses. And since the packages are unfree they are not cached by NixOS so the build triggers massive memory-hungry builds. --- .github/workflows/test.yml | 21 +++-- pkg/android-ndk/build.zig | 148 +++++++++++++++++++--------------- pkg/android-ndk/build.zig.zon | 9 ++- 3 files changed, 98 insertions(+), 80 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 713076438..b9058b395 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -373,7 +373,6 @@ jobs: path: | /nix /zig - ~/Android/ndk # Install Nix and use that to run our tests so our environment matches exactly. - uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1 @@ -385,22 +384,20 @@ jobs: authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - name: Setup Android NDK - run: | - NDK_ROOT="$HOME/Android/ndk" - NDK_DIR="$NDK_ROOT/android-ndk-${{ env.ANDROID_NDK_VERSION }}" - if [ ! -d "$NDK_DIR" ]; then - curl -fsSL -o /tmp/ndk.zip \ - "https://dl.google.com/android/repository/android-ndk-${{ env.ANDROID_NDK_VERSION }}-linux.zip" - mkdir -p $NDK_ROOT - unzip -q /tmp/ndk.zip -d $NDK_ROOT - rm /tmp/ndk.zip - fi - echo "ANDROID_NDK_HOME=$NDK_DIR" >> "$GITHUB_ENV" + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1.6.0 + id: setup-ndk + with: + ndk-version: r29 + add-to-path: false + link-to-sdk: false + local-cache: true - name: Build run: | nix develop -c zig build lib-vt \ -Dtarget=${{ matrix.target }} + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} build-linux: strategy: diff --git a/pkg/android-ndk/build.zig b/pkg/android-ndk/build.zig index 8d4668fe5..5b005665b 100644 --- a/pkg/android-ndk/build.zig +++ b/pkg/android-ndk/build.zig @@ -25,9 +25,9 @@ pub fn addPaths(b: *std.Build, step: *std.Build.Step.Compile) !void { var map: std.AutoHashMapUnmanaged(Key, ?struct { libc: std.Build.LazyPath, - cpp_include: []const u8, - lib: []const u8, - }) = .{}; + cpp_include: std.Build.LazyPath, + lib: std.Build.LazyPath, + }) = .empty; }; const target = step.rootModuleTarget(); @@ -38,16 +38,7 @@ pub fn addPaths(b: *std.Build, step: *std.Build.Step.Compile) !void { }); if (!gop.found_existing) { - const ndk_path = findNDKPath(b.allocator) orelse { - gop.value_ptr.* = null; - return error.AndroidNDKNotFound; - }; - - var ndk_dir = std.fs.openDirAbsolute(ndk_path, .{}) catch { - gop.value_ptr.* = null; - return error.AndroidNDKNotFound; - }; - defer ndk_dir.close(); + const ndk_path = findNDKPath(b) orelse return error.AndroidNDKNotFound; const ndk_triple = ndkTriple(target) orelse { gop.value_ptr.* = null; @@ -59,27 +50,47 @@ pub fn addPaths(b: *std.Build, step: *std.Build.Step.Compile) !void { return error.AndroidNDKUnsupportedHost; }; - const sysroot = try std.fs.path.join(b.allocator, &.{ - ndk_path, "toolchains", "llvm", "prebuilt", host, "sysroot", + const sysroot = b.pathJoin(&.{ + ndk_path, + "toolchains", + "llvm", + "prebuilt", + host, + "sysroot", + }); + const include_dir = b.pathJoin(&.{ + sysroot, + "usr", + "include", + }); + const sys_include_dir = b.pathJoin(&.{ + sysroot, + "usr", + "include", + ndk_triple, + }); + const c_runtime_dir = b.pathJoin(&.{ + sysroot, + "usr", + "lib", + ndk_triple, + b.fmt("{d}", .{target.os.version_range.linux.android}), + }); + const lib = b.pathJoin(&.{ + sysroot, + "usr", + "lib", + ndk_triple, + }); + const cpp_include = b.pathJoin(&.{ + sysroot, + "usr", + "include", + "c++", + "v1", }); - const include_dir = try std.fs.path.join( - b.allocator, - &.{ sysroot, "usr", "include" }, - ); - const sys_include_dir = try std.fs.path.join( - b.allocator, - &.{ sysroot, "usr", "include", ndk_triple }, - ); - var api_buf: [10]u8 = undefined; - const api_level = target.os.version_range.linux.android; - const api_level_str = std.fmt.bufPrint(&api_buf, "{d}", .{api_level}) catch unreachable; - const c_runtime_dir = try std.fs.path.join( - b.allocator, - &.{ sysroot, "usr", "lib", ndk_triple, api_level_str }, - ); - - const libc_txt = try std.fmt.allocPrint(b.allocator, + const libc_txt = b.fmt( \\include_dir={s} \\sys_include_dir={s} \\crt_dir={s} @@ -90,79 +101,86 @@ pub fn addPaths(b: *std.Build, step: *std.Build.Step.Compile) !void { const wf = b.addWriteFiles(); const libc_path = wf.add("libc.txt", libc_txt); - const lib = try std.fs.path.join(b.allocator, &.{ sysroot, "usr", "lib", ndk_triple }); - const cpp_include = try std.fs.path.join(b.allocator, &.{ sysroot, "usr", "include", "c++", "v1" }); gop.value_ptr.* = .{ - .lib = lib, .libc = libc_path, - .cpp_include = cpp_include, + .cpp_include = .{ .cwd_relative = cpp_include }, + .lib = .{ .cwd_relative = lib }, }; } const value = gop.value_ptr.* orelse return error.AndroidNDKNotFound; step.setLibCFile(value.libc); - step.root_module.addSystemIncludePath(.{ .cwd_relative = value.cpp_include }); - step.root_module.addLibraryPath(.{ .cwd_relative = value.lib }); + step.root_module.addSystemIncludePath(value.cpp_include); + step.root_module.addLibraryPath(value.lib); } -fn findNDKPath(allocator: std.mem.Allocator) ?[]const u8 { +fn findNDKPath(b: *std.Build) ?[]const u8 { // Check if user has set the environment variable for the NDK path. - if (std.process.getEnvVarOwned(allocator, "ANDROID_NDK_HOME") catch null) |value| { - if (value.len > 0) return value; + if (std.process.getEnvVarOwned(b.allocator, "ANDROID_NDK_HOME") catch null) |value| { + if (value.len == 0) return null; + var dir = std.fs.openDirAbsolute(value, .{}) catch return null; + defer dir.close(); + return value; } // Check the common environment variables for the Android SDK path and look for the NDK inside it. inline for (.{ "ANDROID_HOME", "ANDROID_SDK_ROOT" }) |env| { - if (std.process.getEnvVarOwned(allocator, env) catch null) |sdk| { + if (std.process.getEnvVarOwned(b.allocator, env) catch null) |sdk| { if (sdk.len > 0) { - if (findLatestNDK(allocator, sdk)) |ndk| return ndk; + if (findLatestNDK(b, sdk)) |ndk| return ndk; } } } // As a fallback, we assume the most common/default SDK path based on the OS. const home = std.process.getEnvVarOwned( - allocator, + b.allocator, if (builtin.os.tag == .windows) "LOCALAPPDATA" else "HOME", ) catch return null; - const default_sdk_path = std.fs.path.join(allocator, &.{ - home, switch (builtin.os.tag) { - .linux => "Android/sdk", - .macos => "Library/Android/Sdk", - .windows => "Android/Sdk", - else => return null, + const default_sdk_path = b.pathJoin( + &.{ + home, + switch (builtin.os.tag) { + .linux => "Android/sdk", + .macos => "Library/Android/Sdk", + .windows => "Android/Sdk", + else => return null, + }, }, - }) catch return null; - return findLatestNDK(allocator, default_sdk_path); + ); + + return findLatestNDK(b, default_sdk_path); } -fn findLatestNDK(allocator: std.mem.Allocator, sdk_path: []const u8) ?[]const u8 { - const ndk_dir = std.fs.path.join(allocator, &.{ sdk_path, "ndk" }) catch return null; +fn findLatestNDK(b: *std.Build, sdk_path: []const u8) ?[]const u8 { + const ndk_dir = b.pathJoin(&.{ sdk_path, "ndk" }); var dir = std.fs.openDirAbsolute(ndk_dir, .{ .iterate = true }) catch return null; defer dir.close(); - var latest_version: ?[]const u8 = null; - var latest_parsed: ?std.SemanticVersion = null; + var latest_: ?struct { + name: []const u8, + version: std.SemanticVersion, + } = null; var iterator = dir.iterate(); while (iterator.next() catch null) |file| { if (file.kind != .directory) continue; - const parsed = std.SemanticVersion.parse(file.name) catch continue; - if (latest_version == null or parsed.order(latest_parsed.?) == .gt) { - if (latest_version) |old| allocator.free(old); - latest_version = allocator.dupe(u8, file.name) catch return null; - latest_parsed = parsed; + const version = std.SemanticVersion.parse(file.name) catch continue; + if (latest_) |latest| { + if (version.order(latest.version) != .gt) continue; } + latest_ = .{ + .name = file.name, + .version = version, + }; } - if (latest_version) |version| { - return std.fs.path.join(allocator, &.{ sdk_path, "ndk", version }) catch return null; - } + const latest = latest_ orelse return null; - return null; + return b.pathJoin(&.{ sdk_path, "ndk", latest.name }); } fn hostTag() ?[]const u8 { diff --git a/pkg/android-ndk/build.zig.zon b/pkg/android-ndk/build.zig.zon index 97febcefb..eb0de6820 100644 --- a/pkg/android-ndk/build.zig.zon +++ b/pkg/android-ndk/build.zig.zon @@ -1,7 +1,10 @@ .{ .name = .android_ndk, - .version = "0.0.1", - .dependencies = .{}, + .version = "0.0.2", .fingerprint = 0xee68d62c5a97b68b, - .paths = .{""}, + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + }, } From c61f184069336c61f7840e2268c6f4dc183b60af Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:02:44 +0000 Subject: [PATCH 41/79] Sync CODEOWNERS vouch list (#10959) Sync CODEOWNERS owners with vouch list. ## Added Users - @Atomk Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 4bb8d652a..9228a26d5 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -24,6 +24,7 @@ aindriu80 alanmoyano alexfeijoo44 andrejdaskalov +atomk balazs-szucs bennettp123 benodiwal From 4f6fc324f1043bca4e7123e45d660c3600107ccd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:22:04 +0000 Subject: [PATCH 42/79] build(deps): bump namespacelabs/nscloud-cache-action from 1.4.1 to 1.4.2 Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.4.1 to 1.4.2. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/v1.4.1...a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.4.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b9058b395..d0270dd61 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -368,7 +368,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix From 81c9c81ae3df165d239c73d739b71245ddc8b32d Mon Sep 17 00:00:00 2001 From: miracles Date: Sat, 21 Feb 2026 23:46:09 -0800 Subject: [PATCH 43/79] Refactor glass effect into TerminalGlassView and add inactive window tint overlay --- .../Terminal/TerminalViewContainer.swift | 192 +++++++++++++++--- .../Extensions/NSColor+Extension.swift | 8 + 2 files changed, 173 insertions(+), 27 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index c65dca1d2..ef4aff5b9 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -6,9 +6,8 @@ import SwiftUI class TerminalViewContainer: NSView { private let terminalView: NSView - /// Glass effect view for liquid glass background when transparency is enabled + /// Combined glass effect and inactive tint overlay view private var glassEffectView: NSView? - private var glassTopConstraint: NSLayoutConstraint? private var derivedConfig: DerivedConfig init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) { @@ -27,6 +26,10 @@ class TerminalViewContainer: NSView { fatalError("init(coder:) has not been implemented") } + deinit { + NotificationCenter.default.removeObserver(self) + } + /// To make ``TerminalController/DefaultSize/contentIntrinsicSize`` /// work in ``TerminalController/windowDidLoad()``, /// we override this to provide the correct size. @@ -50,6 +53,20 @@ class TerminalViewContainer: NSView { name: .ghosttyConfigDidChange, object: nil ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidBecomeKey(_:)), + name: NSWindow.didBecomeKeyNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidResignKey(_:)), + name: NSWindow.didResignKeyNotification, + object: nil + ) } override func viewDidMoveToWindow() { @@ -72,36 +89,139 @@ class TerminalViewContainer: NSView { derivedConfig = newValue DispatchQueue.main.async(execute: updateGlassEffectIfNeeded) } + + @objc private func windowDidBecomeKey(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + window == self.window else { return } + updateGlassTintOverlay(isKeyWindow: true) + } + + @objc private func windowDidResignKey(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + window == self.window else { return } + updateGlassTintOverlay(isKeyWindow: false) + } } // MARK: Glass +/// An `NSView` that contains a liquid glass background effect and +/// an inactive-window tint overlay. +#if compiler(>=6.2) +@available(macOS 26.0, *) +private class TerminalGlassView: NSView { + private let glassEffectView: NSGlassEffectView + private var glassTopConstraint: NSLayoutConstraint? + private let tintOverlay: NSView + private var tintTopConstraint: NSLayoutConstraint? + + init(topOffset: CGFloat) { + self.glassEffectView = NSGlassEffectView() + self.tintOverlay = NSView() + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + + // Glass effect view fills this view. + glassEffectView.translatesAutoresizingMaskIntoConstraints = false + addSubview(glassEffectView) + glassTopConstraint = glassEffectView.topAnchor.constraint( + equalTo: topAnchor, + constant: topOffset + ) + if let glassTopConstraint { + NSLayoutConstraint.activate([ + glassTopConstraint, + glassEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), + glassEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), + glassEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + + // Tint overlay sits above the glass effect. + tintOverlay.translatesAutoresizingMaskIntoConstraints = false + tintOverlay.wantsLayer = true + tintOverlay.alphaValue = 0 + addSubview(tintOverlay, positioned: .above, relativeTo: glassEffectView) + tintTopConstraint = tintOverlay.topAnchor.constraint( + equalTo: topAnchor, + constant: topOffset + ) + if let tintTopConstraint { + NSLayoutConstraint.activate([ + tintTopConstraint, + tintOverlay.leadingAnchor.constraint(equalTo: leadingAnchor), + tintOverlay.bottomAnchor.constraint(equalTo: bottomAnchor), + tintOverlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Configures the glass effect style, tint color, corner radius, and + /// updates the inactive tint overlay based on window key status. + func configure( + style: NSGlassEffectView.Style, + backgroundColor: NSColor, + backgroundOpacity: Double, + cornerRadius: CGFloat?, + isKeyWindow: Bool + ) { + glassEffectView.style = style + glassEffectView.tintColor = backgroundColor.withAlphaComponent(backgroundOpacity) + if let cornerRadius { + glassEffectView.cornerRadius = cornerRadius + } + updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor) + } + + /// Updates the top inset offset for both the glass effect and tint overlay. + /// Call this when the safe area insets change (e.g., during layout). + func updateTopInset(_ offset: CGFloat) { + glassTopConstraint?.constant = offset + tintTopConstraint?.constant = offset + } + + /// Updates the tint overlay visibility based on window key status. + func updateKeyStatus(_ isKeyWindow: Bool, backgroundColor: NSColor) { + let tint = tintProperties(for: backgroundColor) + tintOverlay.layer?.backgroundColor = tint.color.cgColor + tintOverlay.alphaValue = isKeyWindow ? 0 : tint.opacity + } + + /// Computes a saturation-boosted tint color and opacity for the inactive overlay. + private func tintProperties(for color: NSColor) -> (color: NSColor, opacity: CGFloat) { + let isLight = color.isLightColor + let vibrant = color.adjustingSaturation(by: 1.2) + let overlayOpacity: CGFloat = isLight ? 0.35 : 0.85 + return (vibrant, overlayOpacity) + } +} +#endif // compiler(>=6.2) + private extension TerminalViewContainer { #if compiler(>=6.2) @available(macOS 26.0, *) - func addGlassEffectViewIfNeeded() -> NSGlassEffectView? { - if let existed = glassEffectView as? NSGlassEffectView { + func addGlassEffectViewIfNeeded() -> TerminalGlassView? { + if let existed = glassEffectView as? TerminalGlassView { updateGlassEffectTopInsetIfNeeded() return existed } guard let themeFrameView = window?.contentView?.superview else { return nil } - let effectView = NSGlassEffectView() + let effectView = TerminalGlassView(topOffset: -themeFrameView.safeAreaInsets.top) addSubview(effectView, positioned: .below, relativeTo: terminalView) - effectView.translatesAutoresizingMaskIntoConstraints = false - glassTopConstraint = effectView.topAnchor.constraint( - equalTo: topAnchor, - constant: -themeFrameView.safeAreaInsets.top - ) - if let glassTopConstraint { - NSLayoutConstraint.activate([ - glassTopConstraint, - effectView.leadingAnchor.constraint(equalTo: leadingAnchor), - effectView.bottomAnchor.constraint(equalTo: bottomAnchor), - effectView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - } + NSLayoutConstraint.activate([ + effectView.topAnchor.constraint(equalTo: topAnchor), + effectView.leadingAnchor.constraint(equalTo: leadingAnchor), + effectView.bottomAnchor.constraint(equalTo: bottomAnchor), + effectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) glassEffectView = effectView return effectView } @@ -112,26 +232,35 @@ private extension TerminalViewContainer { guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { glassEffectView?.removeFromSuperview() glassEffectView = nil - glassTopConstraint = nil return } guard let effectView = addGlassEffectViewIfNeeded() else { return } + + let style: NSGlassEffectView.Style switch derivedConfig.backgroundBlur { case .macosGlassRegular: - effectView.style = NSGlassEffectView.Style.regular + style = NSGlassEffectView.Style.regular case .macosGlassClear: - effectView.style = NSGlassEffectView.Style.clear + style = NSGlassEffectView.Style.clear default: - break + style = NSGlassEffectView.Style.regular } 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 + + var cornerRadius: CGFloat? + if let window, window.responds(to: Selector(("_cornerRadius"))) { + cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat } + + effectView.configure( + style: style, + backgroundColor: backgroundColor, + backgroundOpacity: derivedConfig.backgroundOpacity, + cornerRadius: cornerRadius, + isKeyWindow: window?.isKeyWindow ?? true + ) #endif // compiler(>=6.2) } @@ -142,7 +271,16 @@ private extension TerminalViewContainer { } guard glassEffectView != nil else { return } guard let themeFrameView = window?.contentView?.superview else { return } - glassTopConstraint?.constant = -themeFrameView.safeAreaInsets.top + (glassEffectView as? TerminalGlassView)?.updateTopInset(-themeFrameView.safeAreaInsets.top) +#endif // compiler(>=6.2) + } + + func updateGlassTintOverlay(isKeyWindow: Bool) { +#if compiler(>=6.2) + guard #available(macOS 26.0, *) else { return } + guard glassEffectView != nil else { return } + let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) + (glassEffectView as? TerminalGlassView)?.updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor) #endif // compiler(>=6.2) } diff --git a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift index 63cf02ed4..ed2177325 100644 --- a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift @@ -24,6 +24,14 @@ extension NSColor { appleColorList?.allKeys.map { $0.lowercased() } ?? [] } + /// Returns a new color with its saturation multiplied by the given factor, clamped to [0, 1]. + func adjustingSaturation(by factor: CGFloat) -> NSColor { + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + let hsbColor = self.usingColorSpace(.sRGB) ?? self + hsbColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return NSColor(hue: h, saturation: min(max(s * factor, 0), 1), brightness: b, alpha: a) + } + /// Calculates the perceptual distance to another color in RGB space. func distance(to other: NSColor) -> Double { guard let a = self.usingColorSpace(.sRGB), From 79f0bfe374c0a324cf3158f351ecce5aeb36770f Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Mon, 23 Feb 2026 09:23:42 -0500 Subject: [PATCH 44/79] nix: update ucs-detect to latest master --- flake.nix | 1 + nix/pkgs/blessed.nix | 17 ++++++++++------- nix/pkgs/ucs-detect.nix | 24 +++++++++++++++--------- nix/pkgs/wcwidth.nix | 27 +++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 16 deletions(-) create mode 100644 nix/pkgs/wcwidth.nix diff --git a/flake.nix b/flake.nix index d892dbd2f..e063f2d70 100644 --- a/flake.nix +++ b/flake.nix @@ -80,6 +80,7 @@ packageOverrides = pyfinal: pyprev: { blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {}; ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {}; + wcwidth = pyfinal.callPackage ./nix/pkgs/wcwidth.nix {}; }; }; }; diff --git a/nix/pkgs/blessed.nix b/nix/pkgs/blessed.nix index 8b6728f43..a015e70b6 100644 --- a/nix/pkgs/blessed.nix +++ b/nix/pkgs/blessed.nix @@ -1,22 +1,24 @@ { lib, buildPythonPackage, - fetchPypi, + fetchFromGitHub, pythonOlder, flit-core, six, wcwidth, }: -buildPythonPackage rec { +buildPythonPackage { pname = "blessed"; - version = "1.23.0"; + version = "unstable-2026-02-23"; pyproject = true; - disabled = pythonOlder "3.7"; + disabled = pythonOlder "3.8"; - src = fetchPypi { - inherit pname version; - hash = "sha256-VlkaMpZvcE9hMfFACvQVHZ6PX0FEEzpcoDQBl2Pe53s="; + src = fetchFromGitHub { + owner = "jquast"; + repo = "blessed"; + rev = "master"; + hash = "sha256-ROd/O9pfqnF5DHXqoz+tkl1jQJSZad3Ta1h+oC3+gvY="; }; build-system = [flit-core]; @@ -27,6 +29,7 @@ buildPythonPackage rec { ]; doCheck = false; + dontCheckRuntimeDeps = true; meta = with lib; { homepage = "https://github.com/jquast/blessed"; diff --git a/nix/pkgs/ucs-detect.nix b/nix/pkgs/ucs-detect.nix index 07ec6c2fc..73721b62a 100644 --- a/nix/pkgs/ucs-detect.nix +++ b/nix/pkgs/ucs-detect.nix @@ -1,36 +1,42 @@ { lib, buildPythonPackage, - fetchPypi, + fetchFromGitHub, pythonOlder, - setuptools, + hatchling, # Dependencies blessed, wcwidth, pyyaml, + prettytable, + requests, }: -buildPythonPackage rec { +buildPythonPackage { pname = "ucs-detect"; - version = "1.0.8"; + version = "unstable-2026-02-23"; pyproject = true; disabled = pythonOlder "3.8"; - src = fetchPypi { - inherit version; - pname = "ucs_detect"; - hash = "sha256-ihB+tZCd6ykdeXYxc6V1Q6xALQ+xdCW5yqSL7oppqJc="; + src = fetchFromGitHub { + owner = "jquast"; + repo = "ucs-detect"; + rev = "master"; + hash = "sha256-x7BD14n1/mP9bzjM6DPqc5R1Fk/HLLycl4o41KV+xAE="; }; dependencies = [ blessed wcwidth pyyaml + prettytable + requests ]; - nativeBuildInputs = [setuptools]; + nativeBuildInputs = [hatchling]; doCheck = false; + dontCheckRuntimeDeps = true; meta = with lib; { description = "Measures number of Terminal column cells of wide-character codes"; diff --git a/nix/pkgs/wcwidth.nix b/nix/pkgs/wcwidth.nix new file mode 100644 index 000000000..4bbd1373b --- /dev/null +++ b/nix/pkgs/wcwidth.nix @@ -0,0 +1,27 @@ +{ + lib, + buildPythonPackage, + fetchPypi, + hatchling, +}: +buildPythonPackage rec { + pname = "wcwidth"; + version = "0.6.0"; + pyproject = true; + + src = fetchPypi { + inherit pname version; + hash = "sha256-zcTkJi1u+aGlfgGDhMvrEgjYq7xkF2An4sJFXIExMVk="; + }; + + build-system = [hatchling]; + + doCheck = false; + + meta = with lib; { + description = "Measures the displayed width of unicode strings in a terminal"; + homepage = "https://github.com/jquast/wcwidth"; + license = licenses.mit; + maintainers = []; + }; +} From daa2a9d0d506378b18ec246f3b7a5b90005966b4 Mon Sep 17 00:00:00 2001 From: MiUPa <66132846+MiUPa@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:18:16 +0900 Subject: [PATCH 45/79] macos: allow renaming tab title on double-click --- .../Window Styles/TerminalWindow.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index cde8d2747..ebf017eee 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -174,6 +174,14 @@ class TerminalWindow: NSWindow { override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } + override func sendEvent(_ event: NSEvent) { + if promptTabTitleForDoubleClick(event) { + return + } + + super.sendEvent(event) + } + override func close() { NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) super.close() @@ -207,6 +215,19 @@ class TerminalWindow: NSWindow { viewModel.isMainWindow = false } + private func promptTabTitleForDoubleClick(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, event.clickCount == 2 else { return false } + + let locationInScreen = convertPoint(toScreen: event.locationInWindow) + guard let tabIndex = tabIndex(atScreenPoint: locationInScreen) else { return false } + + guard let targetWindow = tabbedWindows?[safe: tabIndex] else { return false } + guard let targetController = targetWindow.windowController as? BaseTerminalController else { return false } + + targetController.promptTabTitle() + return true + } + override func mergeAllWindows(_ sender: Any?) { super.mergeAllWindows(sender) From feee4443da680e8f9077e9e11909b0172d72dbfa Mon Sep 17 00:00:00 2001 From: MiUPa <66132846+MiUPa@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:18:16 +0900 Subject: [PATCH 46/79] macOS: add inline tab title editing --- .../Terminal/BaseTerminalController.swift | 11 + .../Window Styles/TerminalWindow.swift | 260 +++++++++++++++++- .../Extensions/NSWindow+Extension.swift | 30 +- 3 files changed, 286 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1cff80c52..9f65d35ce 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1265,6 +1265,17 @@ class BaseTerminalController: NSWindowController, } @IBAction func changeTabTitle(_ sender: Any) { + if let targetWindow = window { + let inlineHostWindow = + targetWindow.tabbedWindows? + .first(where: { $0.tabBarView != nil }) as? TerminalWindow + ?? (targetWindow as? TerminalWindow) + + if let inlineHostWindow, inlineHostWindow.beginInlineTabTitleEdit(for: targetWindow) { + return + } + } + promptTabTitle() } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index ebf017eee..9a3415723 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -4,7 +4,7 @@ import GhosttyKit /// The base class for all standalone, "normal" terminal windows. This sets the basic /// style and configuration of the window based on the app configuration. -class TerminalWindow: NSWindow { +class TerminalWindow: NSWindow, NSTextFieldDelegate { /// Posted when a terminal window awakes from nib. static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake") @@ -37,6 +37,12 @@ class TerminalWindow: NSWindow { /// Sets up our tab context menu private var tabMenuObserver: NSObjectProtocol? + /// Active inline editor for renaming a tab title. + private weak var inlineTabTitleEditor: NSTextField? + private weak var inlineTabTitleEditorController: BaseTerminalController? + private var inlineTabTitleHiddenLabels: [(label: NSTextField, wasHidden: Bool)] = [] + private var inlineTabTitleButtonState: (button: NSButton, title: String, attributedTitle: NSAttributedString?)? + /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. var supportsUpdateAccessory: Bool { @@ -183,6 +189,7 @@ class TerminalWindow: NSWindow { } override func close() { + finishInlineTabTitleEdit(commit: true) NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) super.close() } @@ -219,6 +226,10 @@ class TerminalWindow: NSWindow { guard event.type == .leftMouseDown, event.clickCount == 2 else { return false } let locationInScreen = convertPoint(toScreen: event.locationInWindow) + if beginInlineTabTitleEdit(atScreenPoint: locationInScreen) { + return true + } + guard let tabIndex = tabIndex(atScreenPoint: locationInScreen) else { return false } guard let targetWindow = tabbedWindows?[safe: tabIndex] else { return false } @@ -228,6 +239,246 @@ class TerminalWindow: NSWindow { return true } + @discardableResult + func beginInlineTabTitleEdit(for targetWindow: NSWindow) -> Bool { + guard let tabbedWindows, + let tabIndex = tabbedWindows.firstIndex(of: targetWindow), + let tabButton = tabButtonsInVisualOrder()[safe: tabIndex], + let targetController = targetWindow.windowController as? BaseTerminalController + else { return false } + + return beginInlineTabTitleEdit( + tabButton: tabButton, + targetWindow: targetWindow, + targetController: targetController + ) + } + + private func beginInlineTabTitleEdit(atScreenPoint screenPoint: NSPoint) -> Bool { + guard let hit = tabButtonHit(atScreenPoint: screenPoint), + let targetWindow = tabbedWindows?[safe: hit.index], + let targetController = targetWindow.windowController as? BaseTerminalController + else { return false } + + return beginInlineTabTitleEdit( + tabButton: hit.tabButton, + targetWindow: targetWindow, + targetController: targetController + ) + } + + private func beginInlineTabTitleEdit( + tabButton: NSView, + targetWindow: NSWindow, + targetController: BaseTerminalController + ) -> Bool { + finishInlineTabTitleEdit(commit: true) + + let titleLabels = tabButton + .descendants(withClassName: "NSTextField") + .compactMap { $0 as? NSTextField } + let editedTitle = targetController.titleOverride ?? targetWindow.title + let sourceLabel = sourceTabTitleLabel(from: titleLabels, matching: editedTitle) + let editorFrame = tabTitleEditorFrame(for: tabButton, sourceLabel: sourceLabel) + guard editorFrame.width >= 20, editorFrame.height >= 14 else { return false } + + let editor = NSTextField(frame: editorFrame) + editor.delegate = self + editor.stringValue = editedTitle + editor.alignment = sourceLabel?.alignment ?? .center + editor.isBordered = false + editor.isBezeled = false + editor.drawsBackground = false + editor.focusRingType = .none + editor.lineBreakMode = .byTruncatingTail + if let sourceLabel { + applyTextStyle(to: editor, from: sourceLabel, title: editedTitle) + } + + tabButton.addSubview(editor) + inlineTabTitleEditor = editor + inlineTabTitleEditorController = targetController + inlineTabTitleHiddenLabels = titleLabels.map { ($0, $0.isHidden) } + for label in titleLabels { + label.isHidden = true + } + if let tabButton = tabButton as? NSButton { + inlineTabTitleButtonState = (tabButton, tabButton.title, tabButton.attributedTitle) + tabButton.title = "" + tabButton.attributedTitle = NSAttributedString(string: "") + } else { + inlineTabTitleButtonState = nil + } + + DispatchQueue.main.async { [weak self, weak editor] in + guard let self, let editor else { return } + self.makeFirstResponder(editor) + if let fieldEditor = editor.currentEditor() as? NSTextView, + let editorFont = editor.font { + fieldEditor.font = editorFont + var typingAttributes = fieldEditor.typingAttributes + typingAttributes[.font] = editorFont + fieldEditor.typingAttributes = typingAttributes + } + editor.currentEditor()?.selectAll(nil) + } + + return true + } + + private func tabTitleEditorFrame(for tabButton: NSView, sourceLabel: NSTextField?) -> NSRect { + let bounds = tabButton.bounds + let sideInset = min(24, max(10, bounds.width * 0.12)) + var frame = bounds.insetBy(dx: sideInset, dy: 0) + + if let sourceLabel { + let labelFrame = tabButton.convert(sourceLabel.bounds, from: sourceLabel) + let horizontalPadding: CGFloat = 6 + frame.origin.x = max(sideInset, labelFrame.minX - horizontalPadding) + frame.size.width = min( + labelFrame.width + (horizontalPadding * 2), + bounds.width - (sideInset * 2) + ) + frame.origin.y = labelFrame.minY + frame.size.height = labelFrame.height + } + + return frame.integral + } + + private func sourceTabTitleLabel(from labels: [NSTextField], matching title: String) -> NSTextField? { + let expected = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !expected.isEmpty { + if let exactVisible = labels.first(where: { + !$0.isHidden && + $0.alphaValue > 0.01 && + $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected + }) { + return exactVisible + } + + if let exactAny = labels.first(where: { + $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected + }) { + return exactAny + } + } + + let visibleNonEmpty = labels.filter { + !$0.isHidden && + $0.alphaValue > 0.01 && + !$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + if let centeredVisible = visibleNonEmpty + .filter({ $0.alignment == .center }) + .max(by: { $0.bounds.width < $1.bounds.width }) { + return centeredVisible + } + + if let visible = visibleNonEmpty.max(by: { $0.bounds.width < $1.bounds.width }) { + return visible + } + + return labels.max(by: { $0.bounds.width < $1.bounds.width }) + } + + private func applyTextStyle(to editor: NSTextField, from label: NSTextField, title: String) { + var attributes: [NSAttributedString.Key: Any] = [:] + if label.attributedStringValue.length > 0 { + attributes = label.attributedStringValue.attributes(at: 0, effectiveRange: nil) + } + + if attributes[.font] == nil, let font = label.font { + attributes[.font] = font + } + + if attributes[.foregroundColor] == nil { + attributes[.foregroundColor] = label.textColor + } + + if let font = attributes[.font] as? NSFont { + editor.font = font + } + + if let textColor = attributes[.foregroundColor] as? NSColor { + editor.textColor = textColor + } + + if !attributes.isEmpty { + editor.attributedStringValue = NSAttributedString(string: title, attributes: attributes) + } else { + editor.stringValue = title + } + } + + private func finishInlineTabTitleEdit(commit: Bool) { + guard let editor = inlineTabTitleEditor else { return } + let editedTitle = editor.stringValue + let targetController = inlineTabTitleEditorController + + editor.delegate = nil + inlineTabTitleEditor = nil + inlineTabTitleEditorController = nil + + if let currentEditor = editor.currentEditor(), firstResponder === currentEditor { + makeFirstResponder(nil) + } else if firstResponder === editor { + makeFirstResponder(nil) + } + + editor.removeFromSuperview() + for (label, wasHidden) in inlineTabTitleHiddenLabels { + label.isHidden = wasHidden + } + inlineTabTitleHiddenLabels.removeAll() + if let buttonState = inlineTabTitleButtonState { + buttonState.button.title = buttonState.title + buttonState.button.attributedTitle = buttonState.attributedTitle ?? NSAttributedString(string: buttonState.title) + } + inlineTabTitleButtonState = nil + + guard commit, let targetController else { return } + targetController.titleOverride = editedTitle.isEmpty ? nil : editedTitle + } + + @objc private func renameTabFromContextMenu(_ sender: NSMenuItem) { + let targetWindow = sender.representedObject as? NSWindow ?? self + if beginInlineTabTitleEdit(for: targetWindow) { + return + } + + guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } + targetController.promptTabTitle() + } + + // MARK: NSTextFieldDelegate + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard control === inlineTabTitleEditor else { return false } + + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + finishInlineTabTitleEdit(commit: true) + return true + } + + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + finishInlineTabTitleEdit(commit: false) + return true + } + + return false + } + + func controlTextDidEndEditing(_ obj: Notification) { + guard let inlineTabTitleEditor, + let finishedEditor = obj.object as? NSTextField, + finishedEditor === inlineTabTitleEditor + else { return } + + finishInlineTabTitleEdit(commit: true) + } + override func mergeAllWindows(_ sender: Any?) { super.mergeAllWindows(sender) @@ -752,10 +1003,11 @@ extension TerminalWindow { separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) - // Change Title... - let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") + // Rename Tab... + let changeTitleItem = NSMenuItem(title: "Rename Tab...", action: #selector(TerminalWindow.renameTabFromContextMenu(_:)), keyEquivalent: "") changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier - changeTitleItem.target = target + changeTitleItem.target = self + changeTitleItem.representedObject = target?.window changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line") menu.addItem(changeTitleItem) diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 0fa330f1b..ee941e3ac 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -58,25 +58,33 @@ extension NSWindow { titlebarView?.firstDescendant(withClassName: "NSTabBar") } - /// Returns the index of the tab button at the given screen point, if any. - func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? { + /// Returns tab button views in visual order from left to right. + func tabButtonsInVisualOrder() -> [NSView] { + guard let tabBarView else { return [] } + return tabBarView + .descendants(withClassName: "NSTabButton") + .sorted { $0.frame.minX < $1.frame.minX } + } + + /// Returns the visual tab index and matching tab button at the given screen point. + func tabButtonHit(atScreenPoint screenPoint: NSPoint) -> (index: Int, tabButton: NSView)? { guard let tabBarView else { return nil } let locationInWindow = convertPoint(fromScreen: screenPoint) let locationInTabBar = tabBarView.convert(locationInWindow, from: nil) guard tabBarView.bounds.contains(locationInTabBar) else { return nil } - // Find all tab buttons and sort by x position to get visual order. - // The view hierarchy order doesn't match the visual tab order. - let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton") - .sorted { $0.frame.origin.x < $1.frame.origin.x } - - for (index, tabItemView) in tabItemViews.enumerated() { - let locationInTab = tabItemView.convert(locationInWindow, from: nil) - if tabItemView.bounds.contains(locationInTab) { - return index + for (index, tabButton) in tabButtonsInVisualOrder().enumerated() { + let locationInTabButton = tabButton.convert(locationInWindow, from: nil) + if tabButton.bounds.contains(locationInTabButton) { + return (index, tabButton) } } return nil } + + /// Returns the index of the tab button at the given screen point, if any. + func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? { + tabButtonHit(atScreenPoint: screenPoint)?.index + } } From f6e9b19fd501b6354ffd471fa3bd626148635504 Mon Sep 17 00:00:00 2001 From: MiUPa <66132846+MiUPa@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:18:16 +0900 Subject: [PATCH 47/79] macOS: widen inline tab title editor --- .../Terminal/Window Styles/TerminalWindow.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 9a3415723..eaaa0ea6c 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -290,7 +290,12 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { editor.isBezeled = false editor.drawsBackground = false editor.focusRingType = .none - editor.lineBreakMode = .byTruncatingTail + editor.lineBreakMode = .byClipping + if let editorCell = editor.cell as? NSTextFieldCell { + editorCell.wraps = false + editorCell.usesSingleLineMode = true + editorCell.isScrollable = true + } if let sourceLabel { applyTextStyle(to: editor, from: sourceLabel, title: editedTitle) } @@ -328,17 +333,11 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { private func tabTitleEditorFrame(for tabButton: NSView, sourceLabel: NSTextField?) -> NSRect { let bounds = tabButton.bounds - let sideInset = min(24, max(10, bounds.width * 0.12)) - var frame = bounds.insetBy(dx: sideInset, dy: 0) + let horizontalInset: CGFloat = 6 + var frame = bounds.insetBy(dx: horizontalInset, dy: 0) if let sourceLabel { let labelFrame = tabButton.convert(sourceLabel.bounds, from: sourceLabel) - let horizontalPadding: CGFloat = 6 - frame.origin.x = max(sideInset, labelFrame.minX - horizontalPadding) - frame.size.width = min( - labelFrame.width + (horizontalPadding * 2), - bounds.width - (sideInset * 2) - ) frame.origin.y = labelFrame.minY frame.size.height = labelFrame.height } From 368e190a4165f3446364b5b91168d18e99bfacd4 Mon Sep 17 00:00:00 2001 From: MiUPa <66132846+MiUPa@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:58:57 +0900 Subject: [PATCH 48/79] macOS: defer inline tab rename start to reduce flicker --- .../Window Styles/TerminalWindow.swift | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index eaaa0ea6c..e255ccd74 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -42,6 +42,7 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { private weak var inlineTabTitleEditorController: BaseTerminalController? private var inlineTabTitleHiddenLabels: [(label: NSTextField, wasHidden: Bool)] = [] private var inlineTabTitleButtonState: (button: NSButton, title: String, attributedTitle: NSAttributedString?)? + private var pendingInlineTabTitleEditWorkItem: DispatchWorkItem? /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -226,16 +227,22 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { guard event.type == .leftMouseDown, event.clickCount == 2 else { return false } let locationInScreen = convertPoint(toScreen: event.locationInWindow) - if beginInlineTabTitleEdit(atScreenPoint: locationInScreen) { - return true + guard let tabIndex = tabIndex(atScreenPoint: locationInScreen), + let targetWindow = tabbedWindows?[safe: tabIndex], + let targetController = targetWindow.windowController as? BaseTerminalController + else { return false } + + pendingInlineTabTitleEditWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self, weak targetWindow, weak targetController] in + guard let self else { return } + if let targetWindow, self.beginInlineTabTitleEdit(for: targetWindow) { + return + } + + targetController?.promptTabTitle() } - - guard let tabIndex = tabIndex(atScreenPoint: locationInScreen) else { return false } - - guard let targetWindow = tabbedWindows?[safe: tabIndex] else { return false } - guard let targetController = targetWindow.windowController as? BaseTerminalController else { return false } - - targetController.promptTabTitle() + pendingInlineTabTitleEditWorkItem = workItem + DispatchQueue.main.async(execute: workItem) return true } @@ -272,6 +279,8 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { targetWindow: NSWindow, targetController: BaseTerminalController ) -> Bool { + pendingInlineTabTitleEditWorkItem?.cancel() + pendingInlineTabTitleEditWorkItem = nil finishInlineTabTitleEdit(commit: true) let titleLabels = tabButton @@ -299,10 +308,12 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { if let sourceLabel { applyTextStyle(to: editor, from: sourceLabel, title: editedTitle) } + editor.isHidden = true - tabButton.addSubview(editor) inlineTabTitleEditor = editor inlineTabTitleEditorController = targetController + CATransaction.begin() + CATransaction.setDisableActions(true) inlineTabTitleHiddenLabels = titleLabels.map { ($0, $0.isHidden) } for label in titleLabels { label.isHidden = true @@ -314,9 +325,14 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { } else { inlineTabTitleButtonState = nil } + tabButton.layoutSubtreeIfNeeded() + tabButton.displayIfNeeded() + tabButton.addSubview(editor) + CATransaction.commit() DispatchQueue.main.async { [weak self, weak editor] in guard let self, let editor else { return } + editor.isHidden = false self.makeFirstResponder(editor) if let fieldEditor = editor.currentEditor() as? NSTextView, let editorFont = editor.font { @@ -412,6 +428,9 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { } private func finishInlineTabTitleEdit(commit: Bool) { + pendingInlineTabTitleEditWorkItem?.cancel() + pendingInlineTabTitleEditWorkItem = nil + guard let editor = inlineTabTitleEditor else { return } let editedTitle = editor.stringValue let targetController = inlineTabTitleEditorController From 879d7cf337fa8a31703fe0bf417d5beb10295076 Mon Sep 17 00:00:00 2001 From: MiUPa <66132846+MiUPa@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:19:57 +0900 Subject: [PATCH 49/79] macOS: remove dead tab title edit helper --- .../Terminal/Window Styles/TerminalWindow.swift | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index e255ccd74..6ff1fe553 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -261,19 +261,6 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { ) } - private func beginInlineTabTitleEdit(atScreenPoint screenPoint: NSPoint) -> Bool { - guard let hit = tabButtonHit(atScreenPoint: screenPoint), - let targetWindow = tabbedWindows?[safe: hit.index], - let targetController = targetWindow.windowController as? BaseTerminalController - else { return false } - - return beginInlineTabTitleEdit( - tabButton: hit.tabButton, - targetWindow: targetWindow, - targetController: targetController - ) - } - private func beginInlineTabTitleEdit( tabButton: NSView, targetWindow: NSWindow, From b6a9d54e98d1c65c4d941ee53f389b03c67c8caf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 08:22:07 -0800 Subject: [PATCH 50/79] macos: extract inline title editing to standalone file --- .../Window Styles/TerminalWindow.swift | 304 +++------------- .../InlineTabTitleEditingCoordinator.swift | 336 ++++++++++++++++++ 2 files changed, 384 insertions(+), 256 deletions(-) create mode 100644 macos/Sources/Helpers/InlineTabTitleEditingCoordinator.swift diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 6ff1fe553..93982dc54 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -4,7 +4,7 @@ import GhosttyKit /// The base class for all standalone, "normal" terminal windows. This sets the basic /// style and configuration of the window based on the app configuration. -class TerminalWindow: NSWindow, NSTextFieldDelegate { +class TerminalWindow: NSWindow { /// Posted when a terminal window awakes from nib. static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake") @@ -37,12 +37,11 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { /// Sets up our tab context menu private var tabMenuObserver: NSObjectProtocol? - /// Active inline editor for renaming a tab title. - private weak var inlineTabTitleEditor: NSTextField? - private weak var inlineTabTitleEditorController: BaseTerminalController? - private var inlineTabTitleHiddenLabels: [(label: NSTextField, wasHidden: Bool)] = [] - private var inlineTabTitleButtonState: (button: NSButton, title: String, attributedTitle: NSAttributedString?)? - private var pendingInlineTabTitleEditWorkItem: DispatchWorkItem? + /// Coordinates inline tab title editing for this host window. + private lazy var inlineTabTitleEditingCoordinator = InlineTabTitleEditingCoordinator( + hostWindow: self, + delegate: self + ) /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -182,7 +181,7 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { override var canBecomeMain: Bool { return true } override func sendEvent(_ event: NSEvent) { - if promptTabTitleForDoubleClick(event) { + if inlineTabTitleEditingCoordinator.handleDoubleClick(event) { return } @@ -190,7 +189,7 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { } override func close() { - finishInlineTabTitleEdit(commit: true) + inlineTabTitleEditingCoordinator.finishEditing(commit: true) NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) super.close() } @@ -223,228 +222,9 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { viewModel.isMainWindow = false } - private func promptTabTitleForDoubleClick(_ event: NSEvent) -> Bool { - guard event.type == .leftMouseDown, event.clickCount == 2 else { return false } - - let locationInScreen = convertPoint(toScreen: event.locationInWindow) - guard let tabIndex = tabIndex(atScreenPoint: locationInScreen), - let targetWindow = tabbedWindows?[safe: tabIndex], - let targetController = targetWindow.windowController as? BaseTerminalController - else { return false } - - pendingInlineTabTitleEditWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self, weak targetWindow, weak targetController] in - guard let self else { return } - if let targetWindow, self.beginInlineTabTitleEdit(for: targetWindow) { - return - } - - targetController?.promptTabTitle() - } - pendingInlineTabTitleEditWorkItem = workItem - DispatchQueue.main.async(execute: workItem) - return true - } - @discardableResult func beginInlineTabTitleEdit(for targetWindow: NSWindow) -> Bool { - guard let tabbedWindows, - let tabIndex = tabbedWindows.firstIndex(of: targetWindow), - let tabButton = tabButtonsInVisualOrder()[safe: tabIndex], - let targetController = targetWindow.windowController as? BaseTerminalController - else { return false } - - return beginInlineTabTitleEdit( - tabButton: tabButton, - targetWindow: targetWindow, - targetController: targetController - ) - } - - private func beginInlineTabTitleEdit( - tabButton: NSView, - targetWindow: NSWindow, - targetController: BaseTerminalController - ) -> Bool { - pendingInlineTabTitleEditWorkItem?.cancel() - pendingInlineTabTitleEditWorkItem = nil - finishInlineTabTitleEdit(commit: true) - - let titleLabels = tabButton - .descendants(withClassName: "NSTextField") - .compactMap { $0 as? NSTextField } - let editedTitle = targetController.titleOverride ?? targetWindow.title - let sourceLabel = sourceTabTitleLabel(from: titleLabels, matching: editedTitle) - let editorFrame = tabTitleEditorFrame(for: tabButton, sourceLabel: sourceLabel) - guard editorFrame.width >= 20, editorFrame.height >= 14 else { return false } - - let editor = NSTextField(frame: editorFrame) - editor.delegate = self - editor.stringValue = editedTitle - editor.alignment = sourceLabel?.alignment ?? .center - editor.isBordered = false - editor.isBezeled = false - editor.drawsBackground = false - editor.focusRingType = .none - editor.lineBreakMode = .byClipping - if let editorCell = editor.cell as? NSTextFieldCell { - editorCell.wraps = false - editorCell.usesSingleLineMode = true - editorCell.isScrollable = true - } - if let sourceLabel { - applyTextStyle(to: editor, from: sourceLabel, title: editedTitle) - } - editor.isHidden = true - - inlineTabTitleEditor = editor - inlineTabTitleEditorController = targetController - CATransaction.begin() - CATransaction.setDisableActions(true) - inlineTabTitleHiddenLabels = titleLabels.map { ($0, $0.isHidden) } - for label in titleLabels { - label.isHidden = true - } - if let tabButton = tabButton as? NSButton { - inlineTabTitleButtonState = (tabButton, tabButton.title, tabButton.attributedTitle) - tabButton.title = "" - tabButton.attributedTitle = NSAttributedString(string: "") - } else { - inlineTabTitleButtonState = nil - } - tabButton.layoutSubtreeIfNeeded() - tabButton.displayIfNeeded() - tabButton.addSubview(editor) - CATransaction.commit() - - DispatchQueue.main.async { [weak self, weak editor] in - guard let self, let editor else { return } - editor.isHidden = false - self.makeFirstResponder(editor) - if let fieldEditor = editor.currentEditor() as? NSTextView, - let editorFont = editor.font { - fieldEditor.font = editorFont - var typingAttributes = fieldEditor.typingAttributes - typingAttributes[.font] = editorFont - fieldEditor.typingAttributes = typingAttributes - } - editor.currentEditor()?.selectAll(nil) - } - - return true - } - - private func tabTitleEditorFrame(for tabButton: NSView, sourceLabel: NSTextField?) -> NSRect { - let bounds = tabButton.bounds - let horizontalInset: CGFloat = 6 - var frame = bounds.insetBy(dx: horizontalInset, dy: 0) - - if let sourceLabel { - let labelFrame = tabButton.convert(sourceLabel.bounds, from: sourceLabel) - frame.origin.y = labelFrame.minY - frame.size.height = labelFrame.height - } - - return frame.integral - } - - private func sourceTabTitleLabel(from labels: [NSTextField], matching title: String) -> NSTextField? { - let expected = title.trimmingCharacters(in: .whitespacesAndNewlines) - if !expected.isEmpty { - if let exactVisible = labels.first(where: { - !$0.isHidden && - $0.alphaValue > 0.01 && - $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected - }) { - return exactVisible - } - - if let exactAny = labels.first(where: { - $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected - }) { - return exactAny - } - } - - let visibleNonEmpty = labels.filter { - !$0.isHidden && - $0.alphaValue > 0.01 && - !$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - if let centeredVisible = visibleNonEmpty - .filter({ $0.alignment == .center }) - .max(by: { $0.bounds.width < $1.bounds.width }) { - return centeredVisible - } - - if let visible = visibleNonEmpty.max(by: { $0.bounds.width < $1.bounds.width }) { - return visible - } - - return labels.max(by: { $0.bounds.width < $1.bounds.width }) - } - - private func applyTextStyle(to editor: NSTextField, from label: NSTextField, title: String) { - var attributes: [NSAttributedString.Key: Any] = [:] - if label.attributedStringValue.length > 0 { - attributes = label.attributedStringValue.attributes(at: 0, effectiveRange: nil) - } - - if attributes[.font] == nil, let font = label.font { - attributes[.font] = font - } - - if attributes[.foregroundColor] == nil { - attributes[.foregroundColor] = label.textColor - } - - if let font = attributes[.font] as? NSFont { - editor.font = font - } - - if let textColor = attributes[.foregroundColor] as? NSColor { - editor.textColor = textColor - } - - if !attributes.isEmpty { - editor.attributedStringValue = NSAttributedString(string: title, attributes: attributes) - } else { - editor.stringValue = title - } - } - - private func finishInlineTabTitleEdit(commit: Bool) { - pendingInlineTabTitleEditWorkItem?.cancel() - pendingInlineTabTitleEditWorkItem = nil - - guard let editor = inlineTabTitleEditor else { return } - let editedTitle = editor.stringValue - let targetController = inlineTabTitleEditorController - - editor.delegate = nil - inlineTabTitleEditor = nil - inlineTabTitleEditorController = nil - - if let currentEditor = editor.currentEditor(), firstResponder === currentEditor { - makeFirstResponder(nil) - } else if firstResponder === editor { - makeFirstResponder(nil) - } - - editor.removeFromSuperview() - for (label, wasHidden) in inlineTabTitleHiddenLabels { - label.isHidden = wasHidden - } - inlineTabTitleHiddenLabels.removeAll() - if let buttonState = inlineTabTitleButtonState { - buttonState.button.title = buttonState.title - buttonState.button.attributedTitle = buttonState.attributedTitle ?? NSAttributedString(string: buttonState.title) - } - inlineTabTitleButtonState = nil - - guard commit, let targetController else { return } - targetController.titleOverride = editedTitle.isEmpty ? nil : editedTitle + inlineTabTitleEditingCoordinator.beginEditing(for: targetWindow) } @objc private func renameTabFromContextMenu(_ sender: NSMenuItem) { @@ -457,33 +237,6 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate { targetController.promptTabTitle() } - // MARK: NSTextFieldDelegate - - func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - guard control === inlineTabTitleEditor else { return false } - - if commandSelector == #selector(NSResponder.insertNewline(_:)) { - finishInlineTabTitleEdit(commit: true) - return true - } - - if commandSelector == #selector(NSResponder.cancelOperation(_:)) { - finishInlineTabTitleEdit(commit: false) - return true - } - - return false - } - - func controlTextDidEndEditing(_ obj: Notification) { - guard let inlineTabTitleEditor, - let finishedEditor = obj.object as? NSTextField, - finishedEditor === inlineTabTitleEditor - else { return } - - finishInlineTabTitleEdit(commit: true) - } - override func mergeAllWindows(_ sender: Any?) { super.mergeAllWindows(sender) @@ -1038,3 +791,42 @@ private func makeTabColorPaletteView( hostingView.frame.size = hostingView.intrinsicContentSize return hostingView } + +// MARK: - Inline Tab Title Editing + +extension TerminalWindow: InlineTabTitleEditingCoordinatorDelegate { + func inlineTabTitleEditingCoordinator( + _ coordinator: InlineTabTitleEditingCoordinator, + canRenameTabFor targetWindow: NSWindow + ) -> Bool { + targetWindow.windowController is BaseTerminalController + } + + func inlineTabTitleEditingCoordinator( + _ coordinator: InlineTabTitleEditingCoordinator, + titleFor targetWindow: NSWindow + ) -> String { + guard let targetController = targetWindow.windowController as? BaseTerminalController else { + return targetWindow.title + } + + return targetController.titleOverride ?? targetWindow.title + } + + func inlineTabTitleEditingCoordinator( + _ coordinator: InlineTabTitleEditingCoordinator, + didCommitTitle editedTitle: String, + for targetWindow: NSWindow + ) { + guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } + targetController.titleOverride = editedTitle.isEmpty ? nil : editedTitle + } + + func inlineTabTitleEditingCoordinator( + _ coordinator: InlineTabTitleEditingCoordinator, + performFallbackRenameFor targetWindow: NSWindow + ) { + guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } + targetController.promptTabTitle() + } +} diff --git a/macos/Sources/Helpers/InlineTabTitleEditingCoordinator.swift b/macos/Sources/Helpers/InlineTabTitleEditingCoordinator.swift new file mode 100644 index 000000000..1cd10fb23 --- /dev/null +++ b/macos/Sources/Helpers/InlineTabTitleEditingCoordinator.swift @@ -0,0 +1,336 @@ +import AppKit + +/// Delegate used by ``InlineTabTitleEditingCoordinator`` to resolve tab-specific behavior. +protocol InlineTabTitleEditingCoordinatorDelegate: AnyObject { + /// Returns whether inline rename should be allowed for the given tab window. + func inlineTabTitleEditingCoordinator( + _ coordinator: InlineTabTitleEditingCoordinator, + canRenameTabFor targetWindow: NSWindow + ) -> Bool + + /// Returns the current title value to seed into the inline editor. + func inlineTabTitleEditingCoordinator( + _ coordinator: InlineTabTitleEditingCoordinator, + titleFor targetWindow: NSWindow + ) -> String + + /// Called when inline editing commits a title for a target tab window. + func inlineTabTitleEditingCoordinator( + _ coordinator: InlineTabTitleEditingCoordinator, + didCommitTitle editedTitle: String, + for targetWindow: NSWindow + ) + + /// Called when inline editing could not start and the host should show a fallback flow. + func inlineTabTitleEditingCoordinator( + _ coordinator: InlineTabTitleEditingCoordinator, + performFallbackRenameFor targetWindow: NSWindow + ) +} + +/// Handles inline tab title editing for native AppKit window tabs. +final class InlineTabTitleEditingCoordinator: NSObject, NSTextFieldDelegate { + /// Host window containing the tab bar where editing occurs. + private weak var hostWindow: NSWindow? + /// Delegate that provides and commits title data for target tab windows. + private weak var delegate: InlineTabTitleEditingCoordinatorDelegate? + + /// Active inline editor view, if editing is in progress. + private weak var inlineTitleEditor: NSTextField? + /// Tab window currently being edited. + private weak var inlineTitleTargetWindow: NSWindow? + /// Original hidden state for title labels that are temporarily hidden while editing. + private var hiddenLabels: [(label: NSTextField, wasHidden: Bool)] = [] + /// Original button title state restored once editing finishes. + private var buttonState: (button: NSButton, title: String, attributedTitle: NSAttributedString?)? + /// Deferred begin-editing work used to avoid visual flicker on double-click. + private var pendingEditWorkItem: DispatchWorkItem? + + /// Creates a coordinator bound to a host window and rename delegate. + init(hostWindow: NSWindow, delegate: InlineTabTitleEditingCoordinatorDelegate) { + self.hostWindow = hostWindow + self.delegate = delegate + } + + /// Handles double-click events from the host window and begins inline edit if possible. If this + /// returns true then the double click was handled by the coordinator. + func handleDoubleClick(_ event: NSEvent) -> Bool { + // We only want double-clicks + guard event.type == .leftMouseDown, event.clickCount == 2 else { return false } + + // If we don't have a host window to look up the click, we do nothing. + guard let hostWindow else { return false } + + // Find the tab window that is being clicked. + let locationInScreen = hostWindow.convertPoint(toScreen: event.locationInWindow) + guard let tabIndex = hostWindow.tabIndex(atScreenPoint: locationInScreen), + let targetWindow = hostWindow.tabbedWindows?[safe: tabIndex], + delegate?.inlineTabTitleEditingCoordinator(self, canRenameTabFor: targetWindow) == true + else { return false } + + // We need to start editing in a separate event loop tick, so set that up. + pendingEditWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self, weak targetWindow] in + guard let self, let targetWindow else { return } + if self.beginEditing(for: targetWindow) { + return + } + + // Inline editing failed, so trigger fallback rename whatever it is. + self.delegate?.inlineTabTitleEditingCoordinator(self, performFallbackRenameFor: targetWindow) + } + + pendingEditWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + return true + } + + /// Begins editing the given target tab window title. Returns true if we're able to start the + /// inline edit. + @discardableResult + func beginEditing(for targetWindow: NSWindow) -> Bool { + // Resolve the visual tab button for the target tab window. We rely on visual order + // since native tab view hierarchy order does not necessarily match what is on screen. + guard let hostWindow, + let tabbedWindows = hostWindow.tabbedWindows, + let tabIndex = tabbedWindows.firstIndex(of: targetWindow), + let tabButton = hostWindow.tabButtonsInVisualOrder()[safe: tabIndex], + delegate?.inlineTabTitleEditingCoordinator(self, canRenameTabFor: targetWindow) == true + else { return false } + + // If we have a pending edit, we need to cancel it because we got + // called to start edit explicitly. + pendingEditWorkItem?.cancel() + pendingEditWorkItem = nil + finishEditing(commit: true) + + // Build the editor using title text and style derived from the tab's existing label. + let titleLabels = tabButton + .descendants(withClassName: "NSTextField") + .compactMap { $0 as? NSTextField } + let editedTitle = delegate?.inlineTabTitleEditingCoordinator(self, titleFor: targetWindow) ?? targetWindow.title + let sourceLabel = sourceTabTitleLabel(from: titleLabels, matching: editedTitle) + let editorFrame = tabTitleEditorFrame(for: tabButton, sourceLabel: sourceLabel) + guard editorFrame.width >= 20, editorFrame.height >= 14 else { return false } + + let editor = NSTextField(frame: editorFrame) + editor.delegate = self + editor.stringValue = editedTitle + editor.alignment = sourceLabel?.alignment ?? .center + editor.isBordered = false + editor.isBezeled = false + editor.drawsBackground = false + editor.focusRingType = .none + editor.lineBreakMode = .byClipping + if let editorCell = editor.cell as? NSTextFieldCell { + editorCell.wraps = false + editorCell.usesSingleLineMode = true + editorCell.isScrollable = true + } + if let sourceLabel { + applyTextStyle(to: editor, from: sourceLabel, title: editedTitle) + } + + // Hide it until the tab button has finished layout so we can avoid flicker. + editor.isHidden = true + + inlineTitleEditor = editor + inlineTitleTargetWindow = targetWindow + + // Temporarily hide native title label views while editing so only the text field is visible. + CATransaction.begin() + CATransaction.setDisableActions(true) + hiddenLabels = titleLabels.map { ($0, $0.isHidden) } + for label in titleLabels { + label.isHidden = true + } + if let tabButton = tabButton as? NSButton { + buttonState = (tabButton, tabButton.title, tabButton.attributedTitle) + tabButton.title = "" + tabButton.attributedTitle = NSAttributedString(string: "") + } else { + buttonState = nil + } + tabButton.layoutSubtreeIfNeeded() + tabButton.displayIfNeeded() + tabButton.addSubview(editor) + CATransaction.commit() + + // Focus after insertion so AppKit has created the field editor for this text field. + DispatchQueue.main.async { [weak hostWindow, weak editor] in + guard let hostWindow, let editor else { return } + editor.isHidden = false + hostWindow.makeFirstResponder(editor) + if let fieldEditor = editor.currentEditor() as? NSTextView, + let editorFont = editor.font { + fieldEditor.font = editorFont + var typingAttributes = fieldEditor.typingAttributes + typingAttributes[.font] = editorFont + fieldEditor.typingAttributes = typingAttributes + } + editor.currentEditor()?.selectAll(nil) + } + + return true + } + + /// Finishes any in-flight inline edit and optionally commits the edited title. + func finishEditing(commit: Bool) { + // If we're pending starting a new edit, cancel it. + pendingEditWorkItem?.cancel() + pendingEditWorkItem = nil + + // To finish editing we need a current editor. + guard let editor = inlineTitleEditor else { return } + let editedTitle = editor.stringValue + let targetWindow = inlineTitleTargetWindow + + // Clear coordinator references first so re-entrant paths don't see stale state. + editor.delegate = nil + inlineTitleEditor = nil + inlineTitleTargetWindow = nil + + // Make sure the window grabs focus again + if let hostWindow { + if let currentEditor = editor.currentEditor(), hostWindow.firstResponder === currentEditor { + hostWindow.makeFirstResponder(nil) + } else if hostWindow.firstResponder === editor { + hostWindow.makeFirstResponder(nil) + } + } + + editor.removeFromSuperview() + + // Restore original tab title presentation. + for (label, wasHidden) in hiddenLabels { + label.isHidden = wasHidden + } + hiddenLabels.removeAll() + + if let buttonState { + buttonState.button.title = buttonState.title + buttonState.button.attributedTitle = buttonState.attributedTitle ?? NSAttributedString(string: buttonState.title) + } + self.buttonState = nil + + // Delegate owns title persistence semantics (including empty-title handling). + guard commit, let targetWindow else { return } + delegate?.inlineTabTitleEditingCoordinator(self, didCommitTitle: editedTitle, for: targetWindow) + } + + /// Chooses an editor frame that aligns with the tab title within the tab button. + private func tabTitleEditorFrame(for tabButton: NSView, sourceLabel: NSTextField?) -> NSRect { + let bounds = tabButton.bounds + let horizontalInset: CGFloat = 6 + var frame = bounds.insetBy(dx: horizontalInset, dy: 0) + + if let sourceLabel { + let labelFrame = tabButton.convert(sourceLabel.bounds, from: sourceLabel) + frame.origin.y = labelFrame.minY + frame.size.height = labelFrame.height + } + + return frame.integral + } + + /// Selects the best title label candidate from private tab button subviews. + private func sourceTabTitleLabel(from labels: [NSTextField], matching title: String) -> NSTextField? { + let expected = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !expected.isEmpty { + // Prefer a visible exact title match when we can find one. + if let exactVisible = labels.first(where: { + !$0.isHidden && + $0.alphaValue > 0.01 && + $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected + }) { + return exactVisible + } + + // Fall back to any exact match, including hidden labels. + if let exactAny = labels.first(where: { + $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected + }) { + return exactAny + } + } + + // Otherwise heuristically choose the largest visible, centered label first. + let visibleNonEmpty = labels.filter { + !$0.isHidden && + $0.alphaValue > 0.01 && + !$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + if let centeredVisible = visibleNonEmpty + .filter({ $0.alignment == .center }) + .max(by: { $0.bounds.width < $1.bounds.width }) { + return centeredVisible + } + + if let visible = visibleNonEmpty.max(by: { $0.bounds.width < $1.bounds.width }) { + return visible + } + + return labels.max(by: { $0.bounds.width < $1.bounds.width }) + } + + /// Copies text styling from the source tab label onto the inline editor. + private func applyTextStyle(to editor: NSTextField, from label: NSTextField, title: String) { + var attributes: [NSAttributedString.Key: Any] = [:] + if label.attributedStringValue.length > 0 { + attributes = label.attributedStringValue.attributes(at: 0, effectiveRange: nil) + } + + if attributes[.font] == nil, let font = label.font { + attributes[.font] = font + } + + if attributes[.foregroundColor] == nil { + attributes[.foregroundColor] = label.textColor + } + + if let font = attributes[.font] as? NSFont { + editor.font = font + } + + if let textColor = attributes[.foregroundColor] as? NSColor { + editor.textColor = textColor + } + + if !attributes.isEmpty { + editor.attributedStringValue = NSAttributedString(string: title, attributes: attributes) + } else { + editor.stringValue = title + } + } + + // MARK: NSTextFieldDelegate + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard control === inlineTitleEditor else { return false } + + // Enter commits and exits inline edit. + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + finishEditing(commit: true) + return true + } + + // Escape cancels and restores the previous tab title. + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + finishEditing(commit: false) + return true + } + + return false + } + + func controlTextDidEndEditing(_ obj: Notification) { + guard let inlineTitleEditor, + let finishedEditor = obj.object as? NSTextField, + finishedEditor === inlineTitleEditor + else { return } + + // Blur/end-edit commits, matching standard NSTextField behavior. + finishEditing(commit: true) + } +} From f5e2561eb75e8dcfd018fd726ed06671dc6233e3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 08:37:06 -0800 Subject: [PATCH 51/79] macos: rename to TabTitleEditor --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Window Styles/TerminalWindow.swift | 28 +++++++------- ...Coordinator.swift => TabTitleEditor.swift} | 38 +++++++++---------- 3 files changed, 34 insertions(+), 33 deletions(-) rename macos/Sources/Helpers/{InlineTabTitleEditingCoordinator.swift => TabTitleEditor.swift} (90%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 49d8132e8..d34dfa257 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -190,6 +190,7 @@ Helpers/Private/CGS.swift, Helpers/Private/Dock.swift, Helpers/TabGroupCloseCoordinator.swift, + Helpers/TabTitleEditor.swift, Helpers/VibrantLayer.m, Helpers/Weak.swift, ); diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 93982dc54..62835e286 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -37,8 +37,8 @@ class TerminalWindow: NSWindow { /// Sets up our tab context menu private var tabMenuObserver: NSObjectProtocol? - /// Coordinates inline tab title editing for this host window. - private lazy var inlineTabTitleEditingCoordinator = InlineTabTitleEditingCoordinator( + /// Handles inline tab title editing for this host window. + private lazy var tabTitleEditor = TabTitleEditor( hostWindow: self, delegate: self ) @@ -181,7 +181,7 @@ class TerminalWindow: NSWindow { override var canBecomeMain: Bool { return true } override func sendEvent(_ event: NSEvent) { - if inlineTabTitleEditingCoordinator.handleDoubleClick(event) { + if tabTitleEditor.handleDoubleClick(event) { return } @@ -189,7 +189,7 @@ class TerminalWindow: NSWindow { } override func close() { - inlineTabTitleEditingCoordinator.finishEditing(commit: true) + tabTitleEditor.finishEditing(commit: true) NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) super.close() } @@ -224,7 +224,7 @@ class TerminalWindow: NSWindow { @discardableResult func beginInlineTabTitleEdit(for targetWindow: NSWindow) -> Bool { - inlineTabTitleEditingCoordinator.beginEditing(for: targetWindow) + tabTitleEditor.beginEditing(for: targetWindow) } @objc private func renameTabFromContextMenu(_ sender: NSMenuItem) { @@ -794,16 +794,16 @@ private func makeTabColorPaletteView( // MARK: - Inline Tab Title Editing -extension TerminalWindow: InlineTabTitleEditingCoordinatorDelegate { - func inlineTabTitleEditingCoordinator( - _ coordinator: InlineTabTitleEditingCoordinator, +extension TerminalWindow: TabTitleEditorDelegate { + func tabTitleEditor( + _ editor: TabTitleEditor, canRenameTabFor targetWindow: NSWindow ) -> Bool { targetWindow.windowController is BaseTerminalController } - func inlineTabTitleEditingCoordinator( - _ coordinator: InlineTabTitleEditingCoordinator, + func tabTitleEditor( + _ editor: TabTitleEditor, titleFor targetWindow: NSWindow ) -> String { guard let targetController = targetWindow.windowController as? BaseTerminalController else { @@ -813,8 +813,8 @@ extension TerminalWindow: InlineTabTitleEditingCoordinatorDelegate { return targetController.titleOverride ?? targetWindow.title } - func inlineTabTitleEditingCoordinator( - _ coordinator: InlineTabTitleEditingCoordinator, + func tabTitleEditor( + _ editor: TabTitleEditor, didCommitTitle editedTitle: String, for targetWindow: NSWindow ) { @@ -822,8 +822,8 @@ extension TerminalWindow: InlineTabTitleEditingCoordinatorDelegate { targetController.titleOverride = editedTitle.isEmpty ? nil : editedTitle } - func inlineTabTitleEditingCoordinator( - _ coordinator: InlineTabTitleEditingCoordinator, + func tabTitleEditor( + _ editor: TabTitleEditor, performFallbackRenameFor targetWindow: NSWindow ) { guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } diff --git a/macos/Sources/Helpers/InlineTabTitleEditingCoordinator.swift b/macos/Sources/Helpers/TabTitleEditor.swift similarity index 90% rename from macos/Sources/Helpers/InlineTabTitleEditingCoordinator.swift rename to macos/Sources/Helpers/TabTitleEditor.swift index 1cd10fb23..c1784112e 100644 --- a/macos/Sources/Helpers/InlineTabTitleEditingCoordinator.swift +++ b/macos/Sources/Helpers/TabTitleEditor.swift @@ -1,39 +1,39 @@ import AppKit -/// Delegate used by ``InlineTabTitleEditingCoordinator`` to resolve tab-specific behavior. -protocol InlineTabTitleEditingCoordinatorDelegate: AnyObject { +/// Delegate used by ``TabTitleEditor`` to resolve tab-specific behavior. +protocol TabTitleEditorDelegate: AnyObject { /// Returns whether inline rename should be allowed for the given tab window. - func inlineTabTitleEditingCoordinator( - _ coordinator: InlineTabTitleEditingCoordinator, + func tabTitleEditor( + _ editor: TabTitleEditor, canRenameTabFor targetWindow: NSWindow ) -> Bool /// Returns the current title value to seed into the inline editor. - func inlineTabTitleEditingCoordinator( - _ coordinator: InlineTabTitleEditingCoordinator, + func tabTitleEditor( + _ editor: TabTitleEditor, titleFor targetWindow: NSWindow ) -> String /// Called when inline editing commits a title for a target tab window. - func inlineTabTitleEditingCoordinator( - _ coordinator: InlineTabTitleEditingCoordinator, + func tabTitleEditor( + _ editor: TabTitleEditor, didCommitTitle editedTitle: String, for targetWindow: NSWindow ) /// Called when inline editing could not start and the host should show a fallback flow. - func inlineTabTitleEditingCoordinator( - _ coordinator: InlineTabTitleEditingCoordinator, + func tabTitleEditor( + _ editor: TabTitleEditor, performFallbackRenameFor targetWindow: NSWindow ) } /// Handles inline tab title editing for native AppKit window tabs. -final class InlineTabTitleEditingCoordinator: NSObject, NSTextFieldDelegate { +final class TabTitleEditor: NSObject, NSTextFieldDelegate { /// Host window containing the tab bar where editing occurs. private weak var hostWindow: NSWindow? /// Delegate that provides and commits title data for target tab windows. - private weak var delegate: InlineTabTitleEditingCoordinatorDelegate? + private weak var delegate: TabTitleEditorDelegate? /// Active inline editor view, if editing is in progress. private weak var inlineTitleEditor: NSTextField? @@ -47,7 +47,7 @@ final class InlineTabTitleEditingCoordinator: NSObject, NSTextFieldDelegate { private var pendingEditWorkItem: DispatchWorkItem? /// Creates a coordinator bound to a host window and rename delegate. - init(hostWindow: NSWindow, delegate: InlineTabTitleEditingCoordinatorDelegate) { + init(hostWindow: NSWindow, delegate: TabTitleEditorDelegate) { self.hostWindow = hostWindow self.delegate = delegate } @@ -57,7 +57,7 @@ final class InlineTabTitleEditingCoordinator: NSObject, NSTextFieldDelegate { func handleDoubleClick(_ event: NSEvent) -> Bool { // We only want double-clicks guard event.type == .leftMouseDown, event.clickCount == 2 else { return false } - + // If we don't have a host window to look up the click, we do nothing. guard let hostWindow else { return false } @@ -65,7 +65,7 @@ final class InlineTabTitleEditingCoordinator: NSObject, NSTextFieldDelegate { let locationInScreen = hostWindow.convertPoint(toScreen: event.locationInWindow) guard let tabIndex = hostWindow.tabIndex(atScreenPoint: locationInScreen), let targetWindow = hostWindow.tabbedWindows?[safe: tabIndex], - delegate?.inlineTabTitleEditingCoordinator(self, canRenameTabFor: targetWindow) == true + delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true else { return false } // We need to start editing in a separate event loop tick, so set that up. @@ -77,7 +77,7 @@ final class InlineTabTitleEditingCoordinator: NSObject, NSTextFieldDelegate { } // Inline editing failed, so trigger fallback rename whatever it is. - self.delegate?.inlineTabTitleEditingCoordinator(self, performFallbackRenameFor: targetWindow) + self.delegate?.tabTitleEditor(self, performFallbackRenameFor: targetWindow) } pendingEditWorkItem = workItem @@ -95,7 +95,7 @@ final class InlineTabTitleEditingCoordinator: NSObject, NSTextFieldDelegate { let tabbedWindows = hostWindow.tabbedWindows, let tabIndex = tabbedWindows.firstIndex(of: targetWindow), let tabButton = hostWindow.tabButtonsInVisualOrder()[safe: tabIndex], - delegate?.inlineTabTitleEditingCoordinator(self, canRenameTabFor: targetWindow) == true + delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true else { return false } // If we have a pending edit, we need to cancel it because we got @@ -108,7 +108,7 @@ final class InlineTabTitleEditingCoordinator: NSObject, NSTextFieldDelegate { let titleLabels = tabButton .descendants(withClassName: "NSTextField") .compactMap { $0 as? NSTextField } - let editedTitle = delegate?.inlineTabTitleEditingCoordinator(self, titleFor: targetWindow) ?? targetWindow.title + let editedTitle = delegate?.tabTitleEditor(self, titleFor: targetWindow) ?? targetWindow.title let sourceLabel = sourceTabTitleLabel(from: titleLabels, matching: editedTitle) let editorFrame = tabTitleEditorFrame(for: tabButton, sourceLabel: sourceLabel) guard editorFrame.width >= 20, editorFrame.height >= 14 else { return false } @@ -215,7 +215,7 @@ final class InlineTabTitleEditingCoordinator: NSObject, NSTextFieldDelegate { // Delegate owns title persistence semantics (including empty-title handling). guard commit, let targetWindow else { return } - delegate?.inlineTabTitleEditingCoordinator(self, didCommitTitle: editedTitle, for: targetWindow) + delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow) } /// Chooses an editor frame that aligns with the tab title within the tab button. From 335f0bff310f8de934431fc040d3684dec4e4799 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:40:43 +0000 Subject: [PATCH 52/79] Update VOUCHED list (#10968) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/9932#issuecomment-3945908641) from @mitchellh. Vouch: @MrMage Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 9228a26d5..6c5481f4b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -84,6 +84,7 @@ mikailmm misairuzame mitchellh miupa +mrmage mtak natesmyth neo773 From 51f304e9a08f66ff35419bfd33cb58024ee42a8c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 08:46:06 -0800 Subject: [PATCH 53/79] macos: add AGENTS.md --- macos/AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 macos/AGENTS.md diff --git a/macos/AGENTS.md b/macos/AGENTS.md new file mode 100644 index 000000000..6321808b8 --- /dev/null +++ b/macos/AGENTS.md @@ -0,0 +1,3 @@ +# macOS Ghostty Application + +- Use `swiftlint` for formatting and linting Swift code. From 0830ecfb65dbd44cbb61fcefe65a932928e12b76 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 09:34:47 -0800 Subject: [PATCH 54/79] ci: enable macOS caching (Zig, Xcode) Namespace now supports cache volumes on macOS. This enables caching for Zig and Xcode artifacts. We can't do Nix yet because we can't create `/nix` and there's a chicken/egg with how Nix installation works on macOS. I'm emailing Namespace support about it... But still, a big win for Zig and Xcode! --- .github/workflows/release-tag.yml | 10 ++++++ .github/workflows/release-tip.yml | 35 +++++++++++++++++++++ .github/workflows/test.yml | 52 +++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index d9f73197d..7010cf9d8 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -130,10 +130,20 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + - uses: DeterminateSystems/nix-installer-action@main with: determinate: true diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 3eb4296f7..f59884fb8 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -37,6 +37,11 @@ jobs: with: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix - uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1 with: nix_path: nixpkgs=channel:nixos-unstable @@ -219,6 +224,8 @@ jobs: GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -226,6 +233,14 @@ jobs: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -462,6 +477,8 @@ jobs: GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -469,6 +486,14 @@ jobs: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -646,6 +671,8 @@ jobs: GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -653,6 +680,14 @@ jobs: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0270dd61..f747c0c43 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -330,10 +330,21 @@ jobs: target: [aarch64-macos, x86_64-macos, aarch64-ios] runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -590,10 +601,21 @@ jobs: build-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -633,10 +655,21 @@ jobs: build-macos-freetype: runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -899,10 +932,21 @@ jobs: test-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -1057,6 +1101,14 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: From dcbc765dc0cd84f190013b5085b08bd6e2f800c4 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:32:31 +0000 Subject: [PATCH 55/79] Update VOUCHED list (#10970) Triggered by [comment](https://github.com/ghostty-org/ghostty/issues/10767#issuecomment-3946557197) from @mitchellh. Unvouch: @prsweet Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 6c5481f4b..e33d75a8a 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -100,7 +100,6 @@ piedrahitac pluiedev pouwerkerk priyans-hu -prsweet qwerasd205 reo101 rgehan From 47577c7623efc859c7f7a9c7da3f712807487f29 Mon Sep 17 00:00:00 2001 From: Martin Emde Date: Sun, 11 Jan 2026 09:50:51 -0800 Subject: [PATCH 56/79] Make top visual space for surface drag handles --- .../Surface View/SurfaceGrabHandle.swift | 39 +++++++------------ src/Surface.zig | 7 +++- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index ff751df10..a4f5dddda 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -1,41 +1,30 @@ -import AppKit import SwiftUI extension Ghostty { /// A grab handle overlay at the top of the surface for dragging the window. - /// Only appears when hovering in the top region of the surface. struct SurfaceGrabHandle: View { - private let handleHeight: CGFloat = 10 - let surfaceView: SurfaceView @State private var isHovering: Bool = false @State private var isDragging: Bool = false var body: some View { - VStack(spacing: 0) { - Rectangle() - .fill(Color.primary.opacity(isHovering || isDragging ? 0.15 : 0)) - .frame(height: handleHeight) - .overlay(alignment: .center) { - if isHovering || isDragging { - Image(systemName: "ellipsis") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.primary.opacity(0.5)) - } - } - .contentShape(Rectangle()) - .overlay { - SurfaceDragSource( - surfaceView: surfaceView, - isDragging: $isDragging, - isHovering: $isHovering - ) - } + ZStack { + SurfaceDragSource( + surfaceView: surfaceView, + isDragging: $isDragging, + isHovering: $isHovering + ) + .frame(width: 80, height: 12) + .contentShape(Rectangle()) - Spacer() + Image(systemName: "ellipsis") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) + .offset(y: -2) + .allowsHitTesting(false) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } } diff --git a/src/Surface.zig b/src/Surface.zig index b9dbefa1b..02fe75718 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -427,9 +427,14 @@ const DerivedConfig = struct { } fn scaledPadding(self: *const DerivedConfig, x_dpi: f32, y_dpi: f32) rendererpkg.Padding { + // Add 6pt header height for macOS overlay elements (tight fit around ellipsis) + const header_height_pt: f32 = 6.0; + const header_height_px: u32 = @intFromFloat(@floor(header_height_pt * y_dpi / 72)); + const padding_top: u32 = padding_top: { const padding_top: f32 = @floatFromInt(self.window_padding_top); - break :padding_top @intFromFloat(@floor(padding_top * y_dpi / 72)); + const scaled_padding: u32 = @intFromFloat(@floor(padding_top * y_dpi / 72)); + break :padding_top scaled_padding + header_height_px; }; const padding_bottom: u32 = padding_bottom: { const padding_bottom: f32 = @floatFromInt(self.window_padding_bottom); From 2842b18a3fc6de1b5ad6f15832a4f28419cd5051 Mon Sep 17 00:00:00 2001 From: Martin Emde Date: Sun, 11 Jan 2026 13:32:11 -0800 Subject: [PATCH 57/79] Only show drag handle on hovered surface --- .../Surface View/SurfaceGrabHandle.swift | 19 +++++++++++++------ .../Surface View/SurfaceView_AppKit.swift | 9 +++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index a4f5dddda..b30acc066 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -3,11 +3,15 @@ import SwiftUI extension Ghostty { /// A grab handle overlay at the top of the surface for dragging the window. struct SurfaceGrabHandle: View { - let surfaceView: SurfaceView + @ObservedObject var surfaceView: SurfaceView @State private var isHovering: Bool = false @State private var isDragging: Bool = false + private var ellipsisVisible: Bool { + surfaceView.mouseOverSurface && surfaceView.cursorVisible + } + var body: some View { ZStack { SurfaceDragSource( @@ -18,11 +22,14 @@ extension Ghostty { .frame(width: 80, height: 12) .contentShape(Rectangle()) - Image(systemName: "ellipsis") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) - .offset(y: -2) - .allowsHitTesting(false) + if ellipsisVisible { + Image(systemName: "ellipsis") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) + .offset(y: -2) + .allowsHitTesting(false) + .transition(.opacity) + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 6b3bfbfb4..e45480a20 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -116,6 +116,12 @@ extension Ghostty { // Whether the pointer should be visible or not @Published private(set) var pointerStyle: CursorStyle = .horizontalText + // Whether the mouse is currently over this surface + @Published private(set) var mouseOverSurface: Bool = false + + // Whether the cursor is currently visible (not hidden by typing, etc.) + @Published private(set) var cursorVisible: Bool = true + /// The configuration derived from the Ghostty config so we don't need to rely on references. @Published private(set) var derivedConfig: DerivedConfig @@ -533,6 +539,7 @@ extension Ghostty { } func setCursorVisibility(_ visible: Bool) { + cursorVisible = visible // Technically this action could be called anytime we want to // change the mouse visibility but at the time of writing this // mouse-hide-while-typing is the only use case so this is the @@ -910,6 +917,7 @@ extension Ghostty { } override func mouseEntered(with event: NSEvent) { + mouseOverSurface = true super.mouseEntered(with: event) guard let surfaceModel else { return } @@ -928,6 +936,7 @@ extension Ghostty { } override func mouseExited(with event: NSEvent) { + mouseOverSurface = false guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit From 40e6a6dd58b7fe5422c9811a81c236ecb14b26b3 Mon Sep 17 00:00:00 2001 From: Martin Emde Date: Sun, 11 Jan 2026 13:51:48 -0800 Subject: [PATCH 58/79] Refine spacing and header usage This is 4pt header space, 12pt clickable frame height --- .../Ghostty/Surface View/SurfaceGrabHandle.swift | 2 +- src/Surface.zig | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index b30acc066..a8555e938 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -26,7 +26,7 @@ extension Ghostty { Image(systemName: "ellipsis") .font(.system(size: 10, weight: .semibold)) .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) - .offset(y: -2) + .offset(y: -3) .allowsHitTesting(false) .transition(.opacity) } diff --git a/src/Surface.zig b/src/Surface.zig index 02fe75718..ddcb3a4f3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -427,14 +427,18 @@ const DerivedConfig = struct { } fn scaledPadding(self: *const DerivedConfig, x_dpi: f32, y_dpi: f32) rendererpkg.Padding { - // Add 6pt header height for macOS overlay elements (tight fit around ellipsis) - const header_height_pt: f32 = 6.0; - const header_height_px: u32 = @intFromFloat(@floor(header_height_pt * y_dpi / 72)); - const padding_top: u32 = padding_top: { const padding_top: f32 = @floatFromInt(self.window_padding_top); const scaled_padding: u32 = @intFromFloat(@floor(padding_top * y_dpi / 72)); - break :padding_top scaled_padding + header_height_px; + + // Add 6pt header height for macOS surface grab overlay elements + if (comptime builtin.os.tag == .macos) { + const header_height_pt: f32 = 4.0; + const header_height_px: u32 = @intFromFloat(@floor(header_height_pt * y_dpi / 72)); + break :padding_top scaled_padding + header_height_px; + } + + break :padding_top scaled_padding; }; const padding_bottom: u32 = padding_bottom: { const padding_bottom: f32 = @floatFromInt(self.window_padding_bottom); From 03161547f6847e43f6b3fd308c0387ddb714f5ad Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 12:08:02 -0800 Subject: [PATCH 59/79] Remove the top padding for macOS grab bar --- src/Surface.zig | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index ddcb3a4f3..b9dbefa1b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -429,16 +429,7 @@ const DerivedConfig = struct { fn scaledPadding(self: *const DerivedConfig, x_dpi: f32, y_dpi: f32) rendererpkg.Padding { const padding_top: u32 = padding_top: { const padding_top: f32 = @floatFromInt(self.window_padding_top); - const scaled_padding: u32 = @intFromFloat(@floor(padding_top * y_dpi / 72)); - - // Add 6pt header height for macOS surface grab overlay elements - if (comptime builtin.os.tag == .macos) { - const header_height_pt: f32 = 4.0; - const header_height_px: u32 = @intFromFloat(@floor(header_height_pt * y_dpi / 72)); - break :padding_top scaled_padding + header_height_px; - } - - break :padding_top scaled_padding; + break :padding_top @intFromFloat(@floor(padding_top * y_dpi / 72)); }; const padding_bottom: u32 = padding_bottom: { const padding_bottom: f32 = @floatFromInt(self.window_padding_bottom); From 7a4bddd37bfb1f758a2302c04ec8e77ecae3e49b Mon Sep 17 00:00:00 2001 From: ClearAspect Date: Mon, 8 Dec 2025 22:11:36 -0500 Subject: [PATCH 60/79] renderer: added cursor style and visibility uniforms Specifically: iCurrentCursorStyle iPreviousCursorStyle iCurrentCursorVisible iPreviousCursorVisible Visibility calculated and updated independently from the typical cursor unifrom updates to preserve cursor style even when not in the viewport or set to be hidden --- src/config/Config.zig | 14 +++++++++++ src/renderer/cursor.zig | 2 +- src/renderer/generic.zig | 29 +++++++++++++++------- src/renderer/shaders/shadertoy_prefix.glsl | 9 +++++++ src/renderer/shadertoy.zig | 3 +++ 5 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 9b0e6cc0f..0a6ca9c9f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2946,6 +2946,20 @@ keybind: Keybinds = .{}, /// /// * `vec4 iPreviousCursorColor` - Color of the previous terminal cursor. /// +/// * `vec4 iCurrentCursorStyle` - Style of the terminal cursor +/// +/// Macros simplified use are defined for the various cursor styles: +/// +/// - `CURSORSTYLE_BLOCK` or `0` +/// - `CURSORSTYLE_BLOCK_HOLLOW` or `1` +/// - `CURSORSTYLE_BAR` or `2` +/// - `CURSORSTYLE_UNDERLINE` or `3` +/// - `CURSORSTYLE_LOCK` or `4` +/// +/// * `vec4 iPreviousCursorStyle` - Style of the previous terminal cursor +/// +/// * `vec4 iCursorVisible` - Visibility of the terminal cursor. +/// /// * `float iTimeCursorChange` - Timestamp of terminal cursor change. /// /// When the terminal cursor changes position or color, this is set to diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index cddda9871..33992bc55 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -15,7 +15,7 @@ pub const Style = enum { lock, /// Create a cursor style from the terminal style request. - pub fn fromTerminal(term: terminal.CursorStyle) ?Style { + pub fn fromTerminal(term: terminal.CursorStyle) Style { return switch (term) { .bar => .bar, .block => .block, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index f57339893..ff632f64a 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -756,6 +756,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .previous_cursor = @splat(0), .current_cursor_color = @splat(0), .previous_cursor_color = @splat(0), + .current_cursor_style = 0, + .previous_cursor_style = 0, + .cursor_visible = 0, .cursor_change_time = 0, .time_focus = 0, .focus = 1, // assume focused initially @@ -2011,11 +2014,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Only update when terminal state is dirty. if (self.terminal_state.dirty == .false) return; + const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms; const colors: *const terminal.RenderState.Colors = &self.terminal_state.colors; // 256-color palette for (colors.palette, 0..) |color, i| { - self.custom_shader_uniforms.palette[i] = .{ + uniforms.palette[i] = .{ @as(f32, @floatFromInt(color.r)) / 255.0, @as(f32, @floatFromInt(color.g)) / 255.0, @as(f32, @floatFromInt(color.b)) / 255.0, @@ -2024,7 +2028,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Background color - self.custom_shader_uniforms.background_color = .{ + uniforms.background_color = .{ @as(f32, @floatFromInt(colors.background.r)) / 255.0, @as(f32, @floatFromInt(colors.background.g)) / 255.0, @as(f32, @floatFromInt(colors.background.b)) / 255.0, @@ -2032,7 +2036,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; // Foreground color - self.custom_shader_uniforms.foreground_color = .{ + uniforms.foreground_color = .{ @as(f32, @floatFromInt(colors.foreground.r)) / 255.0, @as(f32, @floatFromInt(colors.foreground.g)) / 255.0, @as(f32, @floatFromInt(colors.foreground.b)) / 255.0, @@ -2041,7 +2045,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Cursor color if (colors.cursor) |cursor_color| { - self.custom_shader_uniforms.cursor_color = .{ + uniforms.cursor_color = .{ @as(f32, @floatFromInt(cursor_color.r)) / 255.0, @as(f32, @floatFromInt(cursor_color.g)) / 255.0, @as(f32, @floatFromInt(cursor_color.b)) / 255.0, @@ -2055,7 +2059,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Cursor text color if (self.config.cursor_text) |cursor_text| { - self.custom_shader_uniforms.cursor_text = .{ + uniforms.cursor_text = .{ @as(f32, @floatFromInt(cursor_text.color.r)) / 255.0, @as(f32, @floatFromInt(cursor_text.color.g)) / 255.0, @as(f32, @floatFromInt(cursor_text.color.b)) / 255.0, @@ -2065,7 +2069,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Selection background color if (self.config.selection_background) |selection_bg| { - self.custom_shader_uniforms.selection_background_color = .{ + uniforms.selection_background_color = .{ @as(f32, @floatFromInt(selection_bg.color.r)) / 255.0, @as(f32, @floatFromInt(selection_bg.color.g)) / 255.0, @as(f32, @floatFromInt(selection_bg.color.b)) / 255.0, @@ -2075,13 +2079,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Selection foreground color if (self.config.selection_foreground) |selection_fg| { - self.custom_shader_uniforms.selection_foreground_color = .{ + uniforms.selection_foreground_color = .{ @as(f32, @floatFromInt(selection_fg.color.r)) / 255.0, @as(f32, @floatFromInt(selection_fg.color.g)) / 255.0, @as(f32, @floatFromInt(selection_fg.color.b)) / 255.0, 1.0, }; } + + // Cursor visibility + uniforms.cursor_visible = @intFromBool(self.terminal_state.cursor.visible); + + // Cursor style + const cursor_style: renderer.CursorStyle = .fromTerminal(self.terminal_state.cursor.visual_style); + uniforms.previous_cursor_style = uniforms.current_cursor_style; + uniforms.current_cursor_style = @as(i32, @intFromEnum(cursor_style)); } /// Update per-frame custom shader uniforms. @@ -2091,7 +2103,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // We only need to do this if we have custom shaders. if (!self.has_custom_shaders) return; - const uniforms = &self.custom_shader_uniforms; + const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms; const now = try std.time.Instant.now(); defer self.last_frame_time = now; @@ -2125,7 +2137,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 0, }; - // Update custom cursor uniforms, if we have a cursor. if (self.cells.getCursorGlyph()) |cursor| { const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]); const cursor_height: f32 = @floatFromInt(cursor.glyph_size[1]); diff --git a/src/renderer/shaders/shadertoy_prefix.glsl b/src/renderer/shaders/shadertoy_prefix.glsl index 661bd233d..4b6d091b8 100644 --- a/src/renderer/shaders/shadertoy_prefix.glsl +++ b/src/renderer/shaders/shadertoy_prefix.glsl @@ -15,6 +15,9 @@ layout(binding = 1, std140) uniform Globals { uniform vec4 iPreviousCursor; uniform vec4 iCurrentCursorColor; uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; uniform float iTimeCursorChange; uniform float iTimeFocus; uniform int iFocus; @@ -27,6 +30,12 @@ layout(binding = 1, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + layout(binding = 0) uniform sampler2D iChannel0; // These are unused currently by Ghostty: diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig index 7d0ad4b0a..556c28293 100644 --- a/src/renderer/shadertoy.zig +++ b/src/renderer/shadertoy.zig @@ -24,6 +24,9 @@ pub const Uniforms = extern struct { previous_cursor: [4]f32 align(16), current_cursor_color: [4]f32 align(16), previous_cursor_color: [4]f32 align(16), + current_cursor_style: i32 align(4), + previous_cursor_style: i32 align(4), + cursor_visible: i32 align(4), cursor_change_time: f32 align(4), time_focus: f32 align(4), focus: i32 align(4), From 375a6313c94d913c456c33c5d033a3fa910739ac Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:23:46 +0000 Subject: [PATCH 61/79] Update VOUCHED list (#10971) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/10942) from @mitchellh. Vouch: @aalhendi Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index e33d75a8a..277bf3ae5 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -19,6 +19,7 @@ # discussion by the author. Maintainers can denounce users by commenting # "!denounce" or "!denounce [username]" on a discussion. 00-kat +aalhendi abudvytis aindriu80 alanmoyano From b2a7f71b586b83d3b2bb6a17b8c2d79b123dc33f Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:25:10 +0000 Subject: [PATCH 62/79] Update VOUCHED list (#10972) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/10801) from @mitchellh. Vouch: @curtismoncoq Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 277bf3ae5..f1c97f2ac 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -38,6 +38,7 @@ brentschroeter charliie-dev chernetskyi craziestowl +curtismoncoq d-dudas daiimus damyanbogoev From 123438a4ebf249f4391b58af068bd5e7d0dbf80d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:48:13 +0000 Subject: [PATCH 63/79] build(deps): bump namespacelabs/nscloud-setup from 0.0.10 to 0.0.11 Bumps [namespacelabs/nscloud-setup](https://github.com/namespacelabs/nscloud-setup) from 0.0.10 to 0.0.11. - [Release notes](https://github.com/namespacelabs/nscloud-setup/releases) - [Commits](https://github.com/namespacelabs/nscloud-setup/compare/d1c625762f7c926a54bd39252efff0705fd11c64...f378676225212387f1283f4da878712af2c4cd60) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-setup dependency-version: 0.0.11 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f747c0c43..e2e6b9ad4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1312,7 +1312,7 @@ jobs: needs: [test, build-dist] steps: - name: Install and configure Namespace CLI - uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10 + uses: namespacelabs/nscloud-setup@f378676225212387f1283f4da878712af2c4cd60 # v0.0.11 - name: Configure Namespace powered Buildx uses: namespacelabs/nscloud-setup-buildx-action@f5814dcf37a16cce0624d5bec2ab879654294aa0 # v0.0.22 From c3a900d1f4b67dd59d9fab89faf4b69026464390 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 19:49:18 -0800 Subject: [PATCH 64/79] ci: update vouch to 1.4.1 --- .github/workflows/vouch-check-issue.yml | 2 +- .github/workflows/vouch-check-pr.yml | 2 +- .github/workflows/vouch-manage-by-discussion.yml | 2 +- .github/workflows/vouch-manage-by-issue.yml | 2 +- .github/workflows/vouch-sync-codeowners.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml index 60c56fe8f..3e0da9818 100644 --- a/.github/workflows/vouch-check-issue.yml +++ b/.github/workflows/vouch-check-issue.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-issue@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/check-issue@0a63681ac1791b66359e8ccf29eabdd40bdf18e0 # v1.4.1 with: issue-number: ${{ github.event.issue.number }} auto-close: true diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml index aaf9176b3..eaee77830 100644 --- a/.github/workflows/vouch-check-pr.yml +++ b/.github/workflows/vouch-check-pr.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-pr@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/check-pr@0a63681ac1791b66359e8ccf29eabdd40bdf18e0 # v1.4.1 with: pr-number: ${{ github.event.pull_request.number }} auto-close: true diff --git a/.github/workflows/vouch-manage-by-discussion.yml b/.github/workflows/vouch-manage-by-discussion.yml index 93e7a1343..a826e726e 100644 --- a/.github/workflows/vouch-manage-by-discussion.yml +++ b/.github/workflows/vouch-manage-by-discussion.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-discussion@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/manage-by-discussion@0a63681ac1791b66359e8ccf29eabdd40bdf18e0 # v1.4.1 with: discussion-number: ${{ github.event.discussion.number }} comment-node-id: ${{ github.event.comment.node_id }} diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml index acea8f4fd..70ef9620c 100644 --- a/.github/workflows/vouch-manage-by-issue.yml +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-issue@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/manage-by-issue@0a63681ac1791b66359e8ccf29eabdd40bdf18e0 # v1.4.1 with: repo: ${{ github.repository }} issue-id: ${{ github.event.issue.number }} diff --git a/.github/workflows/vouch-sync-codeowners.yml b/.github/workflows/vouch-sync-codeowners.yml index fe1977a66..77ad92bd2 100644 --- a/.github/workflows/vouch-sync-codeowners.yml +++ b/.github/workflows/vouch-sync-codeowners.yml @@ -23,7 +23,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/sync-codeowners@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/sync-codeowners@0a63681ac1791b66359e8ccf29eabdd40bdf18e0 # v1.4.1 with: repo: ${{ github.repository }} pull-request: "true" From e3a6adeff5918fbbaecf98b738f9fb55c715370b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 19:54:28 -0800 Subject: [PATCH 65/79] ci: point xcode to the mounted cache path by Namespace --- .github/workflows/release-tag.yml | 4 +++- .github/workflows/release-tip.yml | 12 +++++++++--- .github/workflows/test.yml | 10 ++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 7010cf9d8..16cfd9dba 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -184,7 +184,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # Add all our metadata to Info.plist so we can reference it later. - name: Update Info.plist diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index f59884fb8..bccb3b0ed 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -278,7 +278,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. @@ -531,7 +533,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. @@ -725,7 +729,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e2e6b9ad4..ff085ef81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -644,13 +644,19 @@ jobs: # codesigning. IMPORTANT: this must NOT run in a Nix environment. # Nix breaks xcodebuild so this has to be run outside. - name: Build Ghostty.app - run: cd macos && xcodebuild -target Ghostty + run: | + cd macos + xcodebuild -target Ghostty \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # Build the iOS target without code signing just to verify it works. - name: Build Ghostty iOS run: | cd macos - xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" + xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES build-macos-freetype: runs-on: namespace-profile-ghostty-macos-tahoe From 45525a0a85a7ef318ca0962941aa5afc00f50e1a Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sat, 20 Dec 2025 16:08:16 +0100 Subject: [PATCH 66/79] macOS: use `NSDockTilePlugIn` to update app icons --- macos/DockTilePlugIn/DockTilePlugin.swift | 115 +++++++++++ macos/Ghostty-Info.plist | 2 + macos/Ghostty.xcodeproj/project.pbxproj | 195 +++++++++++++++++- macos/Sources/App/macOS/AppDelegate.swift | 91 +------- macos/Sources/App/macOS/AppIcon.swift | 187 +++++++++++++++++ .../ColorizedGhosttyIcon.swift | 14 +- .../ColorizedGhosttyIconView.swift | 2 +- macos/Sources/Ghostty/Package.swift | 56 ----- macos/Sources/Ghostty/SharedPackage.swift | 60 ++++++ .../Extensions/OSColor+Extension.swift | 5 +- macos/Tests/CustomIconTests.swift | 20 ++ typos.toml | 2 + 12 files changed, 600 insertions(+), 149 deletions(-) create mode 100644 macos/DockTilePlugIn/DockTilePlugin.swift create mode 100644 macos/Sources/App/macOS/AppIcon.swift create mode 100644 macos/Sources/Ghostty/SharedPackage.swift create mode 100644 macos/Tests/CustomIconTests.swift diff --git a/macos/DockTilePlugIn/DockTilePlugin.swift b/macos/DockTilePlugIn/DockTilePlugin.swift new file mode 100644 index 000000000..f711bfd2d --- /dev/null +++ b/macos/DockTilePlugIn/DockTilePlugin.swift @@ -0,0 +1,115 @@ +import AppKit + +/// This class lives as long as the app is in the Dock. +/// If the user pins the app to the Dock, it will not be deallocated. +/// Be careful when storing state in this class. +class DockTilePlugin: NSObject, NSDockTilePlugIn { + private let pluginBundle = Bundle(for: DockTilePlugin.self) + #if DEBUG + private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty.debug") + #else + private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty") + #endif + + private var iconChangeObserver: Any? + + func setDockTile(_ dockTile: NSDockTile?) { + guard let dockTile, let ghosttyUserDefaults else { + iconChangeObserver = nil + return + } + // Try to restore the previous icon on launch. + iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile) + + iconChangeObserver = DistributedNotificationCenter.default().publisher(for: Ghostty.Notification.ghosttyIconDidChange) + .map { [weak self] _ in + self?.ghosttyUserDefaults?.appIcon + } + .receive(on: DispatchQueue.global()) + .sink { [weak self] newIcon in + guard let self else { return } + iconDidChange(newIcon, dockTile: dockTile) + } + } + + func getGhosttyAppPath() -> String { + var url = pluginBundle.bundleURL + // Remove "/Contents/PlugIns/DockTilePlugIn.bundle" from the bundle URL to reach Ghostty.app. + while url.lastPathComponent != "Ghostty.app", !url.lastPathComponent.isEmpty { + url.deleteLastPathComponent() + } + return url.path + } + + func iconDidChange(_ newIcon: Ghostty.CustomAppIcon?, dockTile: NSDockTile) { + guard let appIcon = newIcon?.image(in: pluginBundle) else { + resetIcon(dockTile: dockTile) + return + } + let appBundlePath = getGhosttyAppPath() + NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) + NSWorkspace.shared.noteFileSystemChanged(appBundlePath) + + dockTile.setIcon(appIcon) + } + + func resetIcon(dockTile: NSDockTile) { + let appBundlePath = getGhosttyAppPath() + let appIcon: NSImage + if #available(macOS 26.0, *) { + // Reset to the default (glassy) icon. + NSWorkspace.shared.setIcon(nil, forFile: appBundlePath) + #if DEBUG + // Use the `Blueprint` icon to + // distinguish Debug from Release builds. + appIcon = pluginBundle.image(forResource: "BlueprintImage")! + #else + // Get the composed icon from the app bundle. + if let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath).bestRepresentation(for: CGRect(origin: .zero, size: dockTile.size), context: nil, hints: nil) { + appIcon = NSImage(size: dockTile.size) + appIcon.addRepresentation(iconRep) + } else { + // If something unexpected happens on macOS 26, + // fall back to a bundled icon. + appIcon = pluginBundle.image(forResource: "AppIconImage")! + } + #endif + } else { + // Use the bundled icon to keep the corner radius + // consistent with other apps. + appIcon = pluginBundle.image(forResource: "AppIconImage")! + NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) + } + NSWorkspace.shared.noteFileSystemChanged(appBundlePath) + dockTile.setIcon(appIcon) + } +} + +private extension NSDockTile { + func setIcon(_ newIcon: NSImage) { + // Update the Dock tile on the main thread. + DispatchQueue.main.async { + let iconView = NSImageView(frame: CGRect(origin: .zero, size: self.size)) + iconView.wantsLayer = true + iconView.image = newIcon + self.contentView = iconView + self.display() + } + } +} + +extension NSDockTile: @unchecked @retroactive Sendable {} + +#if DEBUG +private extension NSAlert { + static func notify(_ message: String, image: NSImage?) { + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = message + alert.icon = image + _ = alert.runModal() + } + } +} +#endif + diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index 5960dc0e7..4896681b9 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -2,6 +2,8 @@ + NSDockTilePlugIn + DockTilePlugin.plugin CFBundleDocumentTypes diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index d34dfa257..724bf86a7 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; }; 55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; }; 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; + 819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */ = {isa = PBXBuildFile; fileRef = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; }; A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; @@ -35,6 +37,13 @@ remoteGlobalIDString = A5B30530299BEAAA0047F10C; remoteInfo = Ghostty; }; + 819324672F2502FB00A9ED8F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A5B30529299BEAAA0047F10C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8193244C2F24E6C000A9ED8F; + remoteInfo = DockTilePlugin; + }; A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = A5B30529299BEAAA0047F10C /* Project object */; @@ -44,12 +53,27 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 819324572F24E74E00A9ED8F /* Copy DockTilePlugin */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */, + ); + name = "Copy DockTilePlugin"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 29C15B1C2CDC3B2000520DD4 /* bat */ = {isa = PBXFileReference; lastKnownFileType = folder; name = bat; path = "../zig-out/share/bat"; sourceTree = ""; }; 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; }; 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = ""; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = ""; }; A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = ""; }; @@ -70,11 +94,24 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + App/macOS/AppIcon.swift, + "Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift", + Ghostty/SharedPackage.swift, + Helpers/CrossKit.swift, + "Helpers/Extensions/NSImage+Extension.swift", + "Helpers/Extensions/OSColor+Extension.swift", + ); + target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */; + }; 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( App/macOS/AppDelegate.swift, "App/macOS/AppDelegate+Ghostty.swift", + App/macOS/AppIcon.swift, App/macOS/main.swift, App/macOS/MainMenu.xib, Features/About/About.xib, @@ -208,7 +245,8 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = ""; }; - 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; + 8193245A2F24E7D000A9ED8F /* DockTilePlugIn */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = DockTilePlugIn; sourceTree = ""; }; + 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -220,6 +258,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8193244A2F24E6C000A9ED8F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A54F45F02E1F047A0046BD5C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -275,6 +320,7 @@ A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */, 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */, 81F82BC72E82815D001EDFA7 /* Sources */, + 8193245A2F24E7D000A9ED8F /* DockTilePlugIn */, A54F45F42E1F047A0046BD5C /* Tests */, 810ACCA02E9D3302004F8F92 /* GhosttyUITests */, A5D495A3299BECBA00DD1313 /* Frameworks */, @@ -290,6 +336,7 @@ A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */, A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */, 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */, + 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */, ); name = Products; sourceTree = ""; @@ -329,6 +376,28 @@ productReference = 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 8193244C2F24E6C000A9ED8F /* DockTilePlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = 819324512F24E6C000A9ED8F /* Build configuration list for PBXNativeTarget "DockTilePlugin" */; + buildPhases = ( + 819324492F24E6C000A9ED8F /* Sources */, + 8193244A2F24E6C000A9ED8F /* Frameworks */, + 8193244B2F24E6C000A9ED8F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8193245A2F24E7D000A9ED8F /* DockTilePlugIn */, + ); + name = DockTilePlugin; + packageProductDependencies = ( + ); + productName = DockTilePlugin; + productReference = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */; + productType = "com.apple.product-type.bundle"; + }; A54F45F22E1F047A0046BD5C /* GhosttyTests */ = { isa = PBXNativeTarget; buildConfigurationList = A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */; @@ -360,10 +429,12 @@ A5B3052D299BEAAA0047F10C /* Sources */, A5B3052E299BEAAA0047F10C /* Frameworks */, A5B3052F299BEAAA0047F10C /* Resources */, + 819324572F24E74E00A9ED8F /* Copy DockTilePlugin */, ); buildRules = ( ); dependencies = ( + 819324682F2502FB00A9ED8F /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 81F82BC72E82815D001EDFA7 /* Sources */, @@ -410,6 +481,9 @@ CreatedOnToolsVersion = 26.1; TestTargetID = A5B30530299BEAAA0047F10C; }; + 8193244C2F24E6C000A9ED8F = { + CreatedOnToolsVersion = 26.2; + }; A54F45F22E1F047A0046BD5C = { CreatedOnToolsVersion = 26.0; TestTargetID = A5B30530299BEAAA0047F10C; @@ -441,6 +515,7 @@ targets = ( A5B30530299BEAAA0047F10C /* Ghostty */, A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */, + 8193244C2F24E6C000A9ED8F /* DockTilePlugin */, A54F45F22E1F047A0046BD5C /* GhosttyTests */, 810ACC9E2E9D3301004F8F92 /* GhosttyUITests */, ); @@ -455,6 +530,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8193244B2F24E6C000A9ED8F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; A54F45F12E1F047A0046BD5C /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -523,6 +606,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 819324492F24E6C000A9ED8F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A54F45EF2E1F047A0046BD5C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -552,6 +642,11 @@ target = A5B30530299BEAAA0047F10C /* Ghostty */; targetProxy = 810ACCA52E9D3302004F8F92 /* PBXContainerItemProxy */; }; + 819324682F2502FB00A9ED8F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */; + targetProxy = 819324672F2502FB00A9ED8F /* PBXContainerItemProxy */; + }; A54F45F82E1F047A0046BD5C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A5B30530299BEAAA0047F10C /* Ghostty */; @@ -684,7 +779,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; @@ -707,6 +802,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; @@ -729,6 +825,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; @@ -737,6 +834,90 @@ }; name = ReleaseLocal; }; + 8193244E2F24E6C000A9ED8F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.DockTilePlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DOCK_TILE_PLUGIN DEBUG"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + WRAPPER_EXTENSION = plugin; + }; + name = Debug; + }; + 8193244F2F24E6C000A9ED8F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.DockTilePlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DOCK_TILE_PLUGIN; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + WRAPPER_EXTENSION = plugin; + }; + name = Release; + }; + 819324502F24E6C000A9ED8F /* ReleaseLocal */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSPrincipalClass = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.DockTilePlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DOCK_TILE_PLUGIN; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + WRAPPER_EXTENSION = plugin; + }; + name = ReleaseLocal; + }; A54F45F92E1F047A0046BD5C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1163,6 +1344,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = ReleaseLocal; }; + 819324512F24E6C000A9ED8F /* Build configuration list for PBXNativeTarget "DockTilePlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8193244E2F24E6C000A9ED8F /* Debug */, + 8193244F2F24E6C000A9ED8F /* Release */, + 819324502F24E6C000A9ED8F /* ReleaseLocal */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = ReleaseLocal; + }; A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 582af1746..2b2c1b7d1 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -928,9 +928,7 @@ class AppDelegate: NSObject, } else { GlobalEventTap.shared.disable() } - Task { - await updateAppIcon(from: config) - } + updateAppIcon(from: config) } /// Sync the appearance of our app with the theme specified in the config. @@ -938,81 +936,15 @@ class AppDelegate: NSObject, NSApplication.shared.appearance = .init(ghosttyConfig: config) } - // Using AppIconActor to ensure this work - // happens synchronously in the background - @AppIconActor - private func updateAppIcon(from config: Ghostty.Config) async { - var appIcon: NSImage? - var appIconName: String? = config.macosIcon.rawValue - - switch config.macosIcon { - case let icon where icon.assetName != nil: - appIcon = NSImage(named: icon.assetName!)! - - case .custom: - if let userIcon = NSImage(contentsOfFile: config.macosCustomIcon) { - appIcon = userIcon - appIconName = config.macosCustomIcon - } else { - appIcon = nil // Revert back to official icon if invalid location - appIconName = nil // Discard saved icon name - } - - case .customStyle: - // Discard saved icon name - // if no valid colours were found - appIconName = nil - guard let ghostColor = config.macosIconGhostColor else { break } - guard let screenColors = config.macosIconScreenColor else { break } - guard let icon = ColorizedGhosttyIcon( - screenColors: screenColors, - ghostColor: ghostColor, - frame: config.macosIconFrame - ).makeImage() else { break } - appIcon = icon - let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString) - appIconName = (colorStrings + [config.macosIconFrame.rawValue]) - .joined(separator: "_") - - default: - // Discard saved icon name - appIconName = nil + private func updateAppIcon(from config: Ghostty.Config) { + // Since this is called after `DockTilePlugin` has been running, + // clean it up here to trigger a correct update of the current config. + UserDefaults.standard.removeObject(forKey: "CustomGhosttyIcon") + DispatchQueue.global().async { + UserDefaults.standard.appIcon = Ghostty.CustomAppIcon(config: config) + DistributedNotificationCenter.default() + .postNotificationName(Ghostty.Notification.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true) } - - // 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 { - // Changing the app bundle's icon will corrupt code signing. - // We only use the default blueprint icon for the dock, - // so developers don't need to clean and re-build every time. - NSApplication.shared.applicationIconImage = NSImage(named: "BlueprintImage") - } - } -#endif - return - } - // make it immutable, so Swift 6 won't complain - let newIcon = appIcon - - let appPath = Bundle.main.bundlePath - 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 @@ -1349,8 +1281,3 @@ private enum QuickTerminalState { /// Controller has been initialized. case initialized(QuickTerminalController) } - -@globalActor -private actor AppIconActor: GlobalActor { - static let shared = AppIconActor() -} diff --git a/macos/Sources/App/macOS/AppIcon.swift b/macos/Sources/App/macOS/AppIcon.swift new file mode 100644 index 000000000..3dd8cdd98 --- /dev/null +++ b/macos/Sources/App/macOS/AppIcon.swift @@ -0,0 +1,187 @@ +import AppKit +import System + +#if !DOCK_TILE_PLUGIN +import GhosttyKit +#endif + +extension Ghostty { + /// For DockTilePlugin to generate icon + /// without relying on ``Ghostty/Ghostty/Config`` + enum CustomAppIcon: Equatable, Codable { + case official + case blueprint + case chalkboard + case glass + case holographic + case microchip + case paper + case retro + case xray + /// Save image data to avoid sandboxing issues + case custom(fileData: Data) + case customStyle(ghostColorHex: String, screenColorHexes: [String], iconFrame: Ghostty.MacOSIconFrame) + + /// Restore the icon from previously saved values + init?(string: String) { + switch string { + case MacOSIcon.official.rawValue: + self = .official + case MacOSIcon.blueprint.rawValue: + self = .blueprint + case MacOSIcon.chalkboard.rawValue: + self = .chalkboard + case MacOSIcon.glass.rawValue: + self = .glass + case MacOSIcon.holographic.rawValue: + self = .holographic + case MacOSIcon.microchip.rawValue: + self = .microchip + case MacOSIcon.paper.rawValue: + self = .paper + case MacOSIcon.retro.rawValue: + self = .retro + case MacOSIcon.xray.rawValue: + self = .xray + default: + /* + let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString) + appIconName = (colorStrings + [config.macosIconFrame.rawValue]) + .joined(separator: "_") + */ + var parts = string.split(separator: "_").map(String.init) + if + let _ = parts.first.flatMap(NSColor.init(hex:)), + let frame = parts.last.flatMap(Ghostty.MacOSIconFrame.init(rawValue:)) + { + let ghostC = parts.removeFirst() + _ = parts.removeLast() + self = .customStyle( + ghostColorHex: ghostC, + screenColorHexes: parts, + iconFrame: frame + ) + } else { + // Due to sandboxing with `com.apple.dock.external.extra.arm64`, + // we can’t restore custom icon file automatically. + // The user must open the app to update it. + return nil + } + } + } + + func image(in bundle: Bundle) -> NSImage? { + switch self { + case .official: + return nil + case .blueprint: + return bundle.image(forResource: "BlueprintImage")! + case .chalkboard: + return bundle.image(forResource: "ChalkboardImage")! + case .glass: + return bundle.image(forResource: "GlassImage")! + case .holographic: + return bundle.image(forResource: "HolographicImage")! + case .microchip: + return bundle.image(forResource: "MicrochipImage")! + case .paper: + return bundle.image(forResource: "PaperImage")! + case .retro: + return bundle.image(forResource: "RetroImage")! + case .xray: + return bundle.image(forResource: "XrayImage")! + case let .custom(file): + if let userIcon = NSImage(data: file) { + return userIcon + } else { + return nil + } + case let .customStyle(ghostColorHex, screenColorHexes, macosIconFrame): + let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) + guard + let ghostColor = NSColor(hex: ghostColorHex), + let icon = ColorizedGhosttyIcon( + screenColors: screenColors, + ghostColor: ghostColor, + frame: macosIconFrame + ).makeImage(in: bundle) + else { + return nil + } + return icon + } + } + } +} + +#if !DOCK_TILE_PLUGIN +extension Ghostty.CustomAppIcon { + init?(config: Ghostty.Config) { + switch config.macosIcon { + case .official: + return nil + case .blueprint: + self = .blueprint + case .chalkboard: + self = .chalkboard + case .glass: + self = .glass + case .holographic: + self = .holographic + case .microchip: + self = .microchip + case .paper: + self = .paper + case .retro: + self = .retro + case .xray: + self = .xray + case .custom: + if let data = try? Data(contentsOf: URL(filePath: config.macosCustomIcon, relativeTo: nil)) { + self = .custom(fileData: data) + } else { + return nil + } + case .customStyle: + // Discard saved icon name + // if no valid colours were found + guard + let ghostColor = config.macosIconGhostColor?.hexString, + let screenColors = config.macosIconScreenColor?.compactMap(\.hexString) + else { + return nil + } + self = .customStyle(ghostColorHex: ghostColor, screenColorHexes: screenColors, iconFrame: config.macosIconFrame) + } + } +} +#endif + +extension UserDefaults { + var appIcon: Ghostty.CustomAppIcon? { + get { + defer { + removeObject(forKey: "CustomGhosttyIcon") + } + if let previous = string(forKey: "CustomGhosttyIcon"), let newIcon = Ghostty.CustomAppIcon(string: previous) { + // update new storage once + self.appIcon = newIcon + return newIcon + } + guard let data = data(forKey: "NewCustomGhosttyIcon") else { + return nil + } + return try? JSONDecoder().decode(Ghostty.CustomAppIcon.self, from: data) + } + set { + guard let newData = try? JSONEncoder().encode(newValue) else { + return + } + set(newData, forKey: "NewCustomGhosttyIcon") + } + } +} + +extension Ghostty.Notification { + static let ghosttyIconDidChange = Notification.Name("com.mitchellh.ghostty.iconDidChange") +} diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift index e58699cff..df24477d4 100644 --- a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift +++ b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift @@ -11,13 +11,13 @@ struct ColorizedGhosttyIcon { let frame: Ghostty.MacOSIconFrame /// Make a custom colorized ghostty icon. - func makeImage() -> NSImage? { + func makeImage(in bundle: Bundle) -> NSImage? { // All of our layers (not in order) - guard let screen = NSImage(named: "CustomIconScreen") else { return nil } - guard let screenMask = NSImage(named: "CustomIconScreenMask") else { return nil } - guard let ghost = NSImage(named: "CustomIconGhost") else { return nil } - guard let crt = NSImage(named: "CustomIconCRT") else { return nil } - guard let gloss = NSImage(named: "CustomIconGloss") else { return nil } + guard let screen = bundle.image(forResource: "CustomIconScreen") else { return nil } + guard let screenMask = bundle.image(forResource: "CustomIconScreenMask") else { return nil } + guard let ghost = bundle.image(forResource: "CustomIconGhost") else { return nil } + guard let crt = bundle.image(forResource: "CustomIconCRT") else { return nil } + guard let gloss = bundle.image(forResource: "CustomIconGloss") else { return nil } let baseName = switch frame { case .aluminum: "CustomIconBaseAluminum" @@ -25,7 +25,7 @@ struct ColorizedGhosttyIcon { case .chrome: "CustomIconBaseChrome" case .plastic: "CustomIconBasePlastic" } - guard let base = NSImage(named: baseName) else { return nil } + guard let base = bundle.image(forResource: baseName) else { return nil } // Apply our color in various ways to our layers. // NOTE: These functions are not built-in, they're implemented as an extension diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift index 8fbebfdc8..7271c595f 100644 --- a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift +++ b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift @@ -8,6 +8,6 @@ struct ColorizedGhosttyIconView: View { screenColors: [.purple, .blue], ghostColor: .yellow, frame: .aluminum - ).makeImage()!) + ).makeImage(in: .main)!) } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 1e92eb8a1..30c355327 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -2,23 +2,6 @@ import os import SwiftUI import GhosttyKit -struct Ghostty { - // The primary logger used by the GhosttyKit libraries. - static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: "ghostty" - ) - - // All the notifications that will be emitted will be put here. - struct Notification {} - - // The user notification category identifier - static let userNotificationCategory = "com.mitchellh.ghostty.userNotification" - - // The user notification "Show" action - static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" -} - // MARK: C Extensions /// A command is fully self-contained so it is Sendable. @@ -317,45 +300,6 @@ extension Ghostty { } } - /// macos-icon - enum MacOSIcon: String, Sendable { - case official - case blueprint - case chalkboard - case glass - case holographic - case microchip - case paper - case retro - case xray - case custom - case customStyle = "custom-style" - - /// Bundled asset name for built-in icons - var assetName: String? { - switch self { - case .official: return nil - case .blueprint: return "BlueprintImage" - case .chalkboard: return "ChalkboardImage" - case .microchip: return "MicrochipImage" - case .glass: return "GlassImage" - case .holographic: return "HolographicImage" - case .paper: return "PaperImage" - case .retro: return "RetroImage" - case .xray: return "XrayImage" - case .custom, .customStyle: return nil - } - } - } - - /// macos-icon-frame - enum MacOSIconFrame: String { - case aluminum - case beige - case plastic - case chrome - } - /// Enum for the macos-window-buttons config option enum MacOSWindowButtons: String { case visible diff --git a/macos/Sources/Ghostty/SharedPackage.swift b/macos/Sources/Ghostty/SharedPackage.swift new file mode 100644 index 000000000..3e591a57b --- /dev/null +++ b/macos/Sources/Ghostty/SharedPackage.swift @@ -0,0 +1,60 @@ +import Foundation +import os + +enum Ghostty { + // The primary logger used by the GhosttyKit libraries. + static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "ghostty" + ) + + // All the notifications that will be emitted will be put here. + struct Notification {} + + // The user notification category identifier + static let userNotificationCategory = "com.mitchellh.ghostty.userNotification" + + // The user notification "Show" action + static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" +} + +extension Ghostty { + /// macos-icon + enum MacOSIcon: String, Sendable { + case official + case blueprint + case chalkboard + case glass + case holographic + case microchip + case paper + case retro + case xray + case custom + case customStyle = "custom-style" + + /// Bundled asset name for built-in icons + var assetName: String? { + switch self { + case .official: return nil + case .blueprint: return "BlueprintImage" + case .chalkboard: return "ChalkboardImage" + case .microchip: return "MicrochipImage" + case .glass: return "GlassImage" + case .holographic: return "HolographicImage" + case .paper: return "PaperImage" + case .retro: return "RetroImage" + case .xray: return "XrayImage" + case .custom, .customStyle: return nil + } + } + } + + /// macos-icon-frame + enum MacOSIconFrame: String, Codable { + case aluminum + case beige + case plastic + case chrome + } +} diff --git a/macos/Sources/Helpers/Extensions/OSColor+Extension.swift b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift index 54b3e1fab..67246bcf5 100644 --- a/macos/Sources/Helpers/Extensions/OSColor+Extension.swift +++ b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift @@ -1,5 +1,7 @@ import Foundation +#if !DOCK_TILE_PLUGIN import GhosttyKit +#endif extension OSColor { var isLightColor: Bool { @@ -92,7 +94,7 @@ extension OSColor { } // MARK: Ghostty Types - +#if !DOCK_TILE_PLUGIN extension OSColor { /// Create a color from a Ghostty color. convenience init(ghostty: ghostty_config_color_s) { @@ -102,3 +104,4 @@ extension OSColor { self.init(red: red, green: green, blue: blue, alpha: 1) } } +#endif diff --git a/macos/Tests/CustomIconTests.swift b/macos/Tests/CustomIconTests.swift new file mode 100644 index 000000000..df6310bbf --- /dev/null +++ b/macos/Tests/CustomIconTests.swift @@ -0,0 +1,20 @@ +@testable import Ghostty +import Testing + +struct CustomIconTests { + @Test func migration() { + #expect(Ghostty.CustomAppIcon.blueprint == Ghostty.CustomAppIcon(string: "blueprint")) + + #expect(nil == Ghostty.CustomAppIcon(string: "~/downloads/some/file.png")) + + #expect(nil == Ghostty.CustomAppIcon(string: "#B0260C")) + + #expect(nil == Ghostty.CustomAppIcon(string: "plastic")) + + #expect(Ghostty.CustomAppIcon.customStyle(ghostColorHex: "#B0260C", screenColorHexes: [], iconFrame: .plastic) == Ghostty.CustomAppIcon(string: "#B0260C_plastic")) + + #expect(Ghostty.CustomAppIcon.customStyle(ghostColorHex: "#B0260C", screenColorHexes: ["#4F2C27"], iconFrame: .plastic) == Ghostty.CustomAppIcon(string: "#B0260C_#4F2C27_plastic")) + + #expect(Ghostty.CustomAppIcon.customStyle(ghostColorHex: "#B0260C", screenColorHexes: ["#4F2C27", "#B0260C"], iconFrame: .plastic) == Ghostty.CustomAppIcon(string: "#B0260C_#4F2C27_#B0260C_plastic")) + } +} diff --git a/typos.toml b/typos.toml index ad167f06e..3c7cd75f2 100644 --- a/typos.toml +++ b/typos.toml @@ -42,6 +42,8 @@ extend-ignore-re = [ "draw[0-9A-F]+(_[0-9A-F]+)?\\(", # Ignore test data in src/input/paste.zig "\"hel\\\\x", + # Ignore long hex-like IDs such as 815E26BA2EF1E00F005C67B1 + "[0-9A-F]{12,}", ] [default.extend-words] From a79557f5214bda88de13a9de2c109ad9020ee7d6 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:26:20 +0100 Subject: [PATCH 67/79] macOS: stop cycling icons when AboutWindow is closed and start cycling with current icon --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Features/About/AboutController.swift | 8 ++- .../Features/About/AboutViewModel.swift | 40 +++++++++++++++ .../Features/About/CyclingIconView.swift | 50 ++++++------------- 4 files changed, 64 insertions(+), 35 deletions(-) create mode 100644 macos/Sources/Features/About/AboutViewModel.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 724bf86a7..17c6f46e6 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -117,6 +117,7 @@ Features/About/About.xib, Features/About/AboutController.swift, Features/About/AboutView.swift, + Features/About/AboutViewModel.swift, Features/About/CyclingIconView.swift, "Features/App Intents/CloseTerminalIntent.swift", "Features/App Intents/CommandPaletteIntent.swift", diff --git a/macos/Sources/Features/About/AboutController.swift b/macos/Sources/Features/About/AboutController.swift index 2f494f12c..6f4cccf6d 100644 --- a/macos/Sources/Features/About/AboutController.swift +++ b/macos/Sources/Features/About/AboutController.swift @@ -5,19 +5,21 @@ import SwiftUI class AboutController: NSWindowController, NSWindowDelegate { static let shared: AboutController = AboutController() + private let viewModel = AboutViewModel() override var windowNibName: NSNib.Name? { "About" } override func windowDidLoad() { guard let window = window else { return } window.center() window.isMovableByWindowBackground = true - window.contentView = NSHostingView(rootView: AboutView()) + window.contentView = NSHostingView(rootView: AboutView().environmentObject(viewModel)) } // MARK: - Functions func show() { window?.makeKeyAndOrderFront(nil) + viewModel.startCyclingIcons() } func hide() { @@ -38,4 +40,8 @@ class AboutController: NSWindowController, NSWindowDelegate { @objc func cancel(_ sender: Any?) { close() } + + func windowWillClose(_ notification: Notification) { + viewModel.stopCyclingIcons() + } } diff --git a/macos/Sources/Features/About/AboutViewModel.swift b/macos/Sources/Features/About/AboutViewModel.swift new file mode 100644 index 000000000..dc0d38c21 --- /dev/null +++ b/macos/Sources/Features/About/AboutViewModel.swift @@ -0,0 +1,40 @@ +import Combine + +class AboutViewModel: ObservableObject { + @Published var currentIcon: Ghostty.MacOSIcon? + @Published var isHovering: Bool = false + + private var timerCancellable: AnyCancellable? + + private let icons: [Ghostty.MacOSIcon] = [ + .official, + .blueprint, + .chalkboard, + .microchip, + .glass, + .holographic, + .paper, + .retro, + .xray, + ] + + func startCyclingIcons() { + timerCancellable = Timer.publish(every: 3, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + guard let self, !isHovering else { return } + advanceToNextIcon() + } + } + + func stopCyclingIcons() { + timerCancellable = nil + currentIcon = nil + } + + func advanceToNextIcon() { + let currentIndex = currentIcon.flatMap(icons.firstIndex(of:)) ?? 0 + let nextIndex = icons.indexWrapping(after: currentIndex) + currentIcon = icons[nextIndex] + } +} diff --git a/macos/Sources/Features/About/CyclingIconView.swift b/macos/Sources/Features/About/CyclingIconView.swift index 4274278e0..c2a860ff7 100644 --- a/macos/Sources/Features/About/CyclingIconView.swift +++ b/macos/Sources/Features/About/CyclingIconView.swift @@ -1,50 +1,38 @@ import SwiftUI import GhosttyKit +import Combine /// A view that cycles through Ghostty's official icon variants. struct CyclingIconView: View { - @State private var currentIcon: Ghostty.MacOSIcon = .official - @State private var isHovering: Bool = false - - private let icons: [Ghostty.MacOSIcon] = [ - .official, - .blueprint, - .chalkboard, - .microchip, - .glass, - .holographic, - .paper, - .retro, - .xray, - ] - private let timerPublisher = Timer.publish(every: 3, on: .main, in: .common) + @EnvironmentObject var viewModel: AboutViewModel var body: some View { ZStack { - iconView(for: currentIcon) - .id(currentIcon) + iconView(for: viewModel.currentIcon) + .id(viewModel.currentIcon) } - .animation(.easeInOut(duration: 0.5), value: currentIcon) + .animation(.easeInOut(duration: 0.5), value: viewModel.currentIcon) .frame(height: 128) - .onReceive(timerPublisher.autoconnect()) { _ in - if !isHovering { - advanceToNextIcon() - } - } .onHover { hovering in - isHovering = hovering + viewModel.isHovering = hovering } .onTapGesture { - advanceToNextIcon() + viewModel.advanceToNextIcon() + } + .contextMenu { + if let currentIcon = viewModel.currentIcon { + Button("Copy Icon Config") { + NSPasteboard.general.setString("macos-icon = \(currentIcon.rawValue)", forType: .string) + } + } } - .help("macos-icon = \(currentIcon.rawValue)") .accessibilityLabel("Ghostty Application Icon") .accessibilityHint("Click to cycle through icon variants") } @ViewBuilder - private func iconView(for icon: Ghostty.MacOSIcon) -> some View { - let iconImage: Image = switch icon.assetName { + private func iconView(for icon: Ghostty.MacOSIcon?) -> some View { + let iconImage: Image = switch icon?.assetName { case let assetName?: Image(assetName) case nil: ghosttyIconImage() } @@ -53,10 +41,4 @@ struct CyclingIconView: View { .resizable() .aspectRatio(contentMode: .fit) } - - private func advanceToNextIcon() { - let currentIndex = icons.firstIndex(of: currentIcon) ?? 0 - let nextIndex = icons.indexWrapping(after: currentIndex) - currentIcon = icons[nextIndex] - } } From 2c28c27ca52f130fa743ae3314cdbb0cd5ebd710 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 13:14:49 -0800 Subject: [PATCH 68/79] moving lots of files, removing unused stuff --- macos/Ghostty.xcodeproj/project.pbxproj | 40 ++-- macos/Sources/App/macOS/AppDelegate.swift | 5 +- macos/Sources/App/macOS/AppIcon.swift | 187 ------------------ .../Features/Dock Tile Plugin/AppIcon.swift | 144 ++++++++++++++ .../Dock Tile Plugin}/DockTilePlugin.swift | 29 +-- .../Notification+AppIcon.swift | 5 + .../UserDefaults+AppIcon.swift | 37 ++++ ...ackage.swift => Ghostty.ConfigTypes.swift} | 21 +- .../{Package.swift => GhosttyPackage.swift} | 8 + .../Sources/Ghostty/GhosttyPackageMeta.swift | 16 ++ 10 files changed, 248 insertions(+), 244 deletions(-) delete mode 100644 macos/Sources/App/macOS/AppIcon.swift create mode 100644 macos/Sources/Features/Dock Tile Plugin/AppIcon.swift rename macos/{DockTilePlugIn => Sources/Features/Dock Tile Plugin}/DockTilePlugin.swift (85%) create mode 100644 macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift create mode 100644 macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift rename macos/Sources/Ghostty/{SharedPackage.swift => Ghostty.ConfigTypes.swift} (65%) rename macos/Sources/Ghostty/{Package.swift => GhosttyPackage.swift} (98%) create mode 100644 macos/Sources/Ghostty/GhosttyPackageMeta.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 17c6f46e6..27f9d0902 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -97,9 +97,13 @@ 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - App/macOS/AppIcon.swift, "Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift", - Ghostty/SharedPackage.swift, + "Features/Dock Tile Plugin/AppIcon.swift", + "Features/Dock Tile Plugin/DockTilePlugin.swift", + "Features/Dock Tile Plugin/Notification+AppIcon.swift", + "Features/Dock Tile Plugin/UserDefaults+AppIcon.swift", + Ghostty/Ghostty.ConfigTypes.swift, + Ghostty/GhosttyPackageMeta.swift, Helpers/CrossKit.swift, "Helpers/Extensions/NSImage+Extension.swift", "Helpers/Extensions/OSColor+Extension.swift", @@ -111,7 +115,6 @@ membershipExceptions = ( App/macOS/AppDelegate.swift, "App/macOS/AppDelegate+Ghostty.swift", - App/macOS/AppIcon.swift, App/macOS/main.swift, App/macOS/MainMenu.xib, Features/About/About.xib, @@ -139,6 +142,10 @@ "Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift", "Features/Command Palette/CommandPalette.swift", "Features/Command Palette/TerminalCommandPalette.swift", + "Features/Dock Tile Plugin/AppIcon.swift", + "Features/Dock Tile Plugin/DockTilePlugin.swift", + "Features/Dock Tile Plugin/Notification+AppIcon.swift", + "Features/Dock Tile Plugin/UserDefaults+AppIcon.swift", "Features/Global Keybinds/GlobalEventTap.swift", Features/QuickTerminal/QuickTerminal.xib, Features/QuickTerminal/QuickTerminalController.swift, @@ -238,6 +245,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( App/iOS/iOSApp.swift, + "Features/Dock Tile Plugin/DockTilePlugin.swift", "Ghostty/Surface View/SurfaceView_UIKit.swift", ); target = A5B30530299BEAAA0047F10C /* Ghostty */; @@ -246,7 +254,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = ""; }; - 8193245A2F24E7D000A9ED8F /* DockTilePlugIn */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = DockTilePlugIn; sourceTree = ""; }; 81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = ""; }; A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -321,7 +328,6 @@ A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */, 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */, 81F82BC72E82815D001EDFA7 /* Sources */, - 8193245A2F24E7D000A9ED8F /* DockTilePlugIn */, A54F45F42E1F047A0046BD5C /* Tests */, 810ACCA02E9D3302004F8F92 /* GhosttyUITests */, A5D495A3299BECBA00DD1313 /* Frameworks */, @@ -389,9 +395,6 @@ ); dependencies = ( ); - fileSystemSynchronizedGroups = ( - 8193245A2F24E7D000A9ED8F /* DockTilePlugIn */, - ); name = DockTilePlugin; packageProductDependencies = ( ); @@ -845,13 +848,14 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.DockTilePlugin; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -873,13 +877,14 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.DockTilePlugin; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; @@ -901,13 +906,14 @@ ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ghostty Dock Tile Plugin"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.DockTilePlugin; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-dock-tile"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 2b2c1b7d1..b9aab0ac4 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -928,6 +928,7 @@ class AppDelegate: NSObject, } else { GlobalEventTap.shared.disable() } + updateAppIcon(from: config) } @@ -941,9 +942,9 @@ class AppDelegate: NSObject, // clean it up here to trigger a correct update of the current config. UserDefaults.standard.removeObject(forKey: "CustomGhosttyIcon") DispatchQueue.global().async { - UserDefaults.standard.appIcon = Ghostty.CustomAppIcon(config: config) + UserDefaults.standard.appIcon = AppIcon(config: config) DistributedNotificationCenter.default() - .postNotificationName(Ghostty.Notification.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true) + .postNotificationName(.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true) } } diff --git a/macos/Sources/App/macOS/AppIcon.swift b/macos/Sources/App/macOS/AppIcon.swift deleted file mode 100644 index 3dd8cdd98..000000000 --- a/macos/Sources/App/macOS/AppIcon.swift +++ /dev/null @@ -1,187 +0,0 @@ -import AppKit -import System - -#if !DOCK_TILE_PLUGIN -import GhosttyKit -#endif - -extension Ghostty { - /// For DockTilePlugin to generate icon - /// without relying on ``Ghostty/Ghostty/Config`` - enum CustomAppIcon: Equatable, Codable { - case official - case blueprint - case chalkboard - case glass - case holographic - case microchip - case paper - case retro - case xray - /// Save image data to avoid sandboxing issues - case custom(fileData: Data) - case customStyle(ghostColorHex: String, screenColorHexes: [String], iconFrame: Ghostty.MacOSIconFrame) - - /// Restore the icon from previously saved values - init?(string: String) { - switch string { - case MacOSIcon.official.rawValue: - self = .official - case MacOSIcon.blueprint.rawValue: - self = .blueprint - case MacOSIcon.chalkboard.rawValue: - self = .chalkboard - case MacOSIcon.glass.rawValue: - self = .glass - case MacOSIcon.holographic.rawValue: - self = .holographic - case MacOSIcon.microchip.rawValue: - self = .microchip - case MacOSIcon.paper.rawValue: - self = .paper - case MacOSIcon.retro.rawValue: - self = .retro - case MacOSIcon.xray.rawValue: - self = .xray - default: - /* - let colorStrings = ([ghostColor] + screenColors).compactMap(\.hexString) - appIconName = (colorStrings + [config.macosIconFrame.rawValue]) - .joined(separator: "_") - */ - var parts = string.split(separator: "_").map(String.init) - if - let _ = parts.first.flatMap(NSColor.init(hex:)), - let frame = parts.last.flatMap(Ghostty.MacOSIconFrame.init(rawValue:)) - { - let ghostC = parts.removeFirst() - _ = parts.removeLast() - self = .customStyle( - ghostColorHex: ghostC, - screenColorHexes: parts, - iconFrame: frame - ) - } else { - // Due to sandboxing with `com.apple.dock.external.extra.arm64`, - // we can’t restore custom icon file automatically. - // The user must open the app to update it. - return nil - } - } - } - - func image(in bundle: Bundle) -> NSImage? { - switch self { - case .official: - return nil - case .blueprint: - return bundle.image(forResource: "BlueprintImage")! - case .chalkboard: - return bundle.image(forResource: "ChalkboardImage")! - case .glass: - return bundle.image(forResource: "GlassImage")! - case .holographic: - return bundle.image(forResource: "HolographicImage")! - case .microchip: - return bundle.image(forResource: "MicrochipImage")! - case .paper: - return bundle.image(forResource: "PaperImage")! - case .retro: - return bundle.image(forResource: "RetroImage")! - case .xray: - return bundle.image(forResource: "XrayImage")! - case let .custom(file): - if let userIcon = NSImage(data: file) { - return userIcon - } else { - return nil - } - case let .customStyle(ghostColorHex, screenColorHexes, macosIconFrame): - let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) - guard - let ghostColor = NSColor(hex: ghostColorHex), - let icon = ColorizedGhosttyIcon( - screenColors: screenColors, - ghostColor: ghostColor, - frame: macosIconFrame - ).makeImage(in: bundle) - else { - return nil - } - return icon - } - } - } -} - -#if !DOCK_TILE_PLUGIN -extension Ghostty.CustomAppIcon { - init?(config: Ghostty.Config) { - switch config.macosIcon { - case .official: - return nil - case .blueprint: - self = .blueprint - case .chalkboard: - self = .chalkboard - case .glass: - self = .glass - case .holographic: - self = .holographic - case .microchip: - self = .microchip - case .paper: - self = .paper - case .retro: - self = .retro - case .xray: - self = .xray - case .custom: - if let data = try? Data(contentsOf: URL(filePath: config.macosCustomIcon, relativeTo: nil)) { - self = .custom(fileData: data) - } else { - return nil - } - case .customStyle: - // Discard saved icon name - // if no valid colours were found - guard - let ghostColor = config.macosIconGhostColor?.hexString, - let screenColors = config.macosIconScreenColor?.compactMap(\.hexString) - else { - return nil - } - self = .customStyle(ghostColorHex: ghostColor, screenColorHexes: screenColors, iconFrame: config.macosIconFrame) - } - } -} -#endif - -extension UserDefaults { - var appIcon: Ghostty.CustomAppIcon? { - get { - defer { - removeObject(forKey: "CustomGhosttyIcon") - } - if let previous = string(forKey: "CustomGhosttyIcon"), let newIcon = Ghostty.CustomAppIcon(string: previous) { - // update new storage once - self.appIcon = newIcon - return newIcon - } - guard let data = data(forKey: "NewCustomGhosttyIcon") else { - return nil - } - return try? JSONDecoder().decode(Ghostty.CustomAppIcon.self, from: data) - } - set { - guard let newData = try? JSONEncoder().encode(newValue) else { - return - } - set(newData, forKey: "NewCustomGhosttyIcon") - } - } -} - -extension Ghostty.Notification { - static let ghosttyIconDidChange = Notification.Name("com.mitchellh.ghostty.iconDidChange") -} diff --git a/macos/Sources/Features/Dock Tile Plugin/AppIcon.swift b/macos/Sources/Features/Dock Tile Plugin/AppIcon.swift new file mode 100644 index 000000000..52ab9ef95 --- /dev/null +++ b/macos/Sources/Features/Dock Tile Plugin/AppIcon.swift @@ -0,0 +1,144 @@ +import AppKit +import System + +/// The icon style for the Ghostty App. +enum AppIcon: Equatable, Codable { + case official + case blueprint + case chalkboard + case glass + case holographic + case microchip + case paper + case retro + case xray + /// Save full image data to avoid sandboxing issues + case custom(fileData: Data) + case customStyle(ghostColorHex: String, screenColorHexes: [String], iconFrame: Ghostty.MacOSIconFrame) + +#if !DOCK_TILE_PLUGIN + init?(config: Ghostty.Config) { + switch config.macosIcon { + case .official: + return nil + case .blueprint: + self = .blueprint + case .chalkboard: + self = .chalkboard + case .glass: + self = .glass + case .holographic: + self = .holographic + case .microchip: + self = .microchip + case .paper: + self = .paper + case .retro: + self = .retro + case .xray: + self = .xray + case .custom: + if let data = try? Data(contentsOf: URL(filePath: config.macosCustomIcon, relativeTo: nil)) { + self = .custom(fileData: data) + } else { + return nil + } + case .customStyle: + // Discard saved icon name + // if no valid colours were found + guard + let ghostColor = config.macosIconGhostColor?.hexString, + let screenColors = config.macosIconScreenColor?.compactMap(\.hexString) + else { + return nil + } + self = .customStyle(ghostColorHex: ghostColor, screenColorHexes: screenColors, iconFrame: config.macosIconFrame) + } + } +#endif + + /// Restore the icon from previously saved values + init?(string: String) { + switch string { + case Ghostty.MacOSIcon.official.rawValue: + self = .official + case Ghostty.MacOSIcon.blueprint.rawValue: + self = .blueprint + case Ghostty.MacOSIcon.chalkboard.rawValue: + self = .chalkboard + case Ghostty.MacOSIcon.glass.rawValue: + self = .glass + case Ghostty.MacOSIcon.holographic.rawValue: + self = .holographic + case Ghostty.MacOSIcon.microchip.rawValue: + self = .microchip + case Ghostty.MacOSIcon.paper.rawValue: + self = .paper + case Ghostty.MacOSIcon.retro.rawValue: + self = .retro + case Ghostty.MacOSIcon.xray.rawValue: + self = .xray + default: + var parts = string.split(separator: "_").map(String.init) + if + let _ = parts.first.flatMap(NSColor.init(hex:)), + let frame = parts.last.flatMap(Ghostty.MacOSIconFrame.init(rawValue:)) + { + let ghostC = parts.removeFirst() + _ = parts.removeLast() + self = .customStyle( + ghostColorHex: ghostC, + screenColorHexes: parts, + iconFrame: frame + ) + } else { + // Due to sandboxing with `com.apple.dock.external.extra.arm64`, + // we can’t restore custom icon file automatically. + // The user must open the app to update it. + return nil + } + } + } + + func image(in bundle: Bundle) -> NSImage? { + switch self { + case .official: + return nil + case .blueprint: + return bundle.image(forResource: "BlueprintImage")! + case .chalkboard: + return bundle.image(forResource: "ChalkboardImage")! + case .glass: + return bundle.image(forResource: "GlassImage")! + case .holographic: + return bundle.image(forResource: "HolographicImage")! + case .microchip: + return bundle.image(forResource: "MicrochipImage")! + case .paper: + return bundle.image(forResource: "PaperImage")! + case .retro: + return bundle.image(forResource: "RetroImage")! + case .xray: + return bundle.image(forResource: "XrayImage")! + case let .custom(file): + if let userIcon = NSImage(data: file) { + return userIcon + } else { + return nil + } + case let .customStyle(ghostColorHex, screenColorHexes, macosIconFrame): + let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) + guard + let ghostColor = NSColor(hex: ghostColorHex), + let icon = ColorizedGhosttyIcon( + screenColors: screenColors, + ghostColor: ghostColor, + frame: macosIconFrame + ).makeImage(in: bundle) + else { + return nil + } + return icon + } + } +} diff --git a/macos/DockTilePlugIn/DockTilePlugin.swift b/macos/Sources/Features/Dock Tile Plugin/DockTilePlugin.swift similarity index 85% rename from macos/DockTilePlugIn/DockTilePlugin.swift rename to macos/Sources/Features/Dock Tile Plugin/DockTilePlugin.swift index f711bfd2d..ad750376d 100644 --- a/macos/DockTilePlugIn/DockTilePlugin.swift +++ b/macos/Sources/Features/Dock Tile Plugin/DockTilePlugin.swift @@ -1,10 +1,14 @@ import AppKit -/// This class lives as long as the app is in the Dock. -/// If the user pins the app to the Dock, it will not be deallocated. -/// Be careful when storing state in this class. class DockTilePlugin: NSObject, NSDockTilePlugIn { + // WARNING: An instance of this class is alive as long as Ghostty's icon is + // in the doc (running or not!), so keep any state and processing to a + // minimum to respect resource usage. + private let pluginBundle = Bundle(for: DockTilePlugin.self) + + // Separate defaults based on debug vs release builds so we can test icons + // without messing up releases. #if DEBUG private let ghosttyUserDefaults = UserDefaults(suiteName: "com.mitchellh.ghostty.debug") #else @@ -21,7 +25,7 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { // Try to restore the previous icon on launch. iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile) - iconChangeObserver = DistributedNotificationCenter.default().publisher(for: Ghostty.Notification.ghosttyIconDidChange) + iconChangeObserver = DistributedNotificationCenter.default().publisher(for: .ghosttyIconDidChange) .map { [weak self] _ in self?.ghosttyUserDefaults?.appIcon } @@ -41,7 +45,7 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { return url.path } - func iconDidChange(_ newIcon: Ghostty.CustomAppIcon?, dockTile: NSDockTile) { + func iconDidChange(_ newIcon: AppIcon?, dockTile: NSDockTile) { guard let appIcon = newIcon?.image(in: pluginBundle) else { resetIcon(dockTile: dockTile) return @@ -80,6 +84,7 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { appIcon = pluginBundle.image(forResource: "AppIconImage")! NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) } + NSWorkspace.shared.noteFileSystemChanged(appBundlePath) dockTile.setIcon(appIcon) } @@ -99,17 +104,3 @@ private extension NSDockTile { } extension NSDockTile: @unchecked @retroactive Sendable {} - -#if DEBUG -private extension NSAlert { - static func notify(_ message: String, image: NSImage?) { - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = message - alert.icon = image - _ = alert.runModal() - } - } -} -#endif - diff --git a/macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift b/macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift new file mode 100644 index 000000000..e492f1a77 --- /dev/null +++ b/macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift @@ -0,0 +1,5 @@ +import AppKit + +extension Notification.Name { + static let ghosttyIconDidChange = Notification.Name("com.mitchellh.ghostty.iconDidChange") +} diff --git a/macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift b/macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift new file mode 100644 index 000000000..cce8e24a4 --- /dev/null +++ b/macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift @@ -0,0 +1,37 @@ +import AppKit + +extension UserDefaults { + private static let customIconKeyOld = "CustomGhosttyIcon" + private static let customIconKeyNew = "CustomGhosttyIcon2" + + var appIcon: AppIcon? { + get { + // Always remove our old pre-docktileplugin values. + defer { + removeObject(forKey: Self.customIconKeyOld) + } + + // If we have an old, pre-docktileplugin value, then we parse the + // the old value (try) and set it. + if let previous = string(forKey: Self.customIconKeyOld), let newIcon = AppIcon(string: previous) { + // update new storage once + self.appIcon = newIcon + return newIcon + } + + // Check if we have the new key for our dock tile plugin format. + guard let data = data(forKey: Self.customIconKeyNew) else { + return nil + } + return try? JSONDecoder().decode(AppIcon.self, from: data) + } + + set { + guard let newData = try? JSONEncoder().encode(newValue) else { + return + } + + set(newData, forKey: Self.customIconKeyNew) + } + } +} diff --git a/macos/Sources/Ghostty/SharedPackage.swift b/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift similarity index 65% rename from macos/Sources/Ghostty/SharedPackage.swift rename to macos/Sources/Ghostty/Ghostty.ConfigTypes.swift index 3e591a57b..8c559fad2 100644 --- a/macos/Sources/Ghostty/SharedPackage.swift +++ b/macos/Sources/Ghostty/Ghostty.ConfigTypes.swift @@ -1,22 +1,5 @@ -import Foundation -import os - -enum Ghostty { - // The primary logger used by the GhosttyKit libraries. - static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: "ghostty" - ) - - // All the notifications that will be emitted will be put here. - struct Notification {} - - // The user notification category identifier - static let userNotificationCategory = "com.mitchellh.ghostty.userNotification" - - // The user notification "Show" action - static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" -} +// This file contains the configuration types for Ghostty so that alternate targets +// can get typed information without depending on all the dependencies of GhosttyKit. extension Ghostty { /// macos-icon diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/GhosttyPackage.swift similarity index 98% rename from macos/Sources/Ghostty/Package.swift rename to macos/Sources/Ghostty/GhosttyPackage.swift index 30c355327..03211862f 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/GhosttyPackage.swift @@ -11,6 +11,14 @@ extension ghostty_command_s: @unchecked @retroactive Sendable {} /// may be unsafe but the value itself is safe to send across threads. extension ghostty_surface_t: @unchecked @retroactive Sendable {} +extension Ghostty { + // The user notification category identifier + static let userNotificationCategory = "com.mitchellh.ghostty.userNotification" + + // The user notification "Show" action + static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" +} + // MARK: Build Info extension Ghostty { diff --git a/macos/Sources/Ghostty/GhosttyPackageMeta.swift b/macos/Sources/Ghostty/GhosttyPackageMeta.swift new file mode 100644 index 000000000..8e035c323 --- /dev/null +++ b/macos/Sources/Ghostty/GhosttyPackageMeta.swift @@ -0,0 +1,16 @@ +import Foundation +import os + +// This defines the minimal information required so all other files can do +// `extension Ghostty` to add more to it. This purposely has minimal +// dependencies so things like our dock tile plugin can use it. +enum Ghostty { + // The primary logger used by the GhosttyKit libraries. + static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: "ghostty" + ) + + // All the notifications that will be emitted will be put here. + struct Notification {} +} From 4b1178e4f647119b93b004e86f95f2d99485468f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Feb 2026 13:17:24 -0800 Subject: [PATCH 69/79] macos: rename a bunch of files --- macos/Ghostty.xcodeproj/project.pbxproj | 26 +++++++++---------- .../AppIcon.swift | 0 .../ColorizedGhosttyIcon.swift | 0 .../ColorizedGhosttyIconImage.swift | 0 .../ColorizedGhosttyIconView.swift | 0 .../DockTilePlugin.swift | 0 .../Extensions}/Notification+AppIcon.swift | 0 .../Extensions}/UserDefaults+AppIcon.swift | 0 8 files changed, 13 insertions(+), 13 deletions(-) rename macos/Sources/Features/{Dock Tile Plugin => Custom App Icon}/AppIcon.swift (100%) rename macos/Sources/Features/{Colorized Ghostty Icon => Custom App Icon}/ColorizedGhosttyIcon.swift (100%) rename macos/Sources/Features/{Colorized Ghostty Icon => Custom App Icon}/ColorizedGhosttyIconImage.swift (100%) rename macos/Sources/Features/{Colorized Ghostty Icon => Custom App Icon}/ColorizedGhosttyIconView.swift (100%) rename macos/Sources/Features/{Dock Tile Plugin => Custom App Icon}/DockTilePlugin.swift (100%) rename macos/Sources/Features/{Dock Tile Plugin => Custom App Icon/Extensions}/Notification+AppIcon.swift (100%) rename macos/Sources/Features/{Dock Tile Plugin => Custom App Icon/Extensions}/UserDefaults+AppIcon.swift (100%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 27f9d0902..8871343c3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -97,11 +97,11 @@ 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - "Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift", - "Features/Dock Tile Plugin/AppIcon.swift", - "Features/Dock Tile Plugin/DockTilePlugin.swift", - "Features/Dock Tile Plugin/Notification+AppIcon.swift", - "Features/Dock Tile Plugin/UserDefaults+AppIcon.swift", + "Features/Custom App Icon/AppIcon.swift", + "Features/Custom App Icon/ColorizedGhosttyIcon.swift", + "Features/Custom App Icon/DockTilePlugin.swift", + "Features/Custom App Icon/Extensions/Notification+AppIcon.swift", + "Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift", Ghostty/Ghostty.ConfigTypes.swift, Ghostty/GhosttyPackageMeta.swift, Helpers/CrossKit.swift, @@ -137,15 +137,15 @@ Features/ClipboardConfirmation/ClipboardConfirmation.xib, Features/ClipboardConfirmation/ClipboardConfirmationController.swift, Features/ClipboardConfirmation/ClipboardConfirmationView.swift, - "Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift", - "Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift", - "Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift", "Features/Command Palette/CommandPalette.swift", "Features/Command Palette/TerminalCommandPalette.swift", - "Features/Dock Tile Plugin/AppIcon.swift", - "Features/Dock Tile Plugin/DockTilePlugin.swift", - "Features/Dock Tile Plugin/Notification+AppIcon.swift", - "Features/Dock Tile Plugin/UserDefaults+AppIcon.swift", + "Features/Custom App Icon/AppIcon.swift", + "Features/Custom App Icon/ColorizedGhosttyIcon.swift", + "Features/Custom App Icon/ColorizedGhosttyIconImage.swift", + "Features/Custom App Icon/ColorizedGhosttyIconView.swift", + "Features/Custom App Icon/DockTilePlugin.swift", + "Features/Custom App Icon/Extensions/Notification+AppIcon.swift", + "Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift", "Features/Global Keybinds/GlobalEventTap.swift", Features/QuickTerminal/QuickTerminal.xib, Features/QuickTerminal/QuickTerminalController.swift, @@ -245,7 +245,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( App/iOS/iOSApp.swift, - "Features/Dock Tile Plugin/DockTilePlugin.swift", + "Features/Custom App Icon/DockTilePlugin.swift", "Ghostty/Surface View/SurfaceView_UIKit.swift", ); target = A5B30530299BEAAA0047F10C /* Ghostty */; diff --git a/macos/Sources/Features/Dock Tile Plugin/AppIcon.swift b/macos/Sources/Features/Custom App Icon/AppIcon.swift similarity index 100% rename from macos/Sources/Features/Dock Tile Plugin/AppIcon.swift rename to macos/Sources/Features/Custom App Icon/AppIcon.swift diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift similarity index 100% rename from macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift rename to macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconImage.swift similarity index 100% rename from macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift rename to macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconImage.swift diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconView.swift similarity index 100% rename from macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconView.swift rename to macos/Sources/Features/Custom App Icon/ColorizedGhosttyIconView.swift diff --git a/macos/Sources/Features/Dock Tile Plugin/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift similarity index 100% rename from macos/Sources/Features/Dock Tile Plugin/DockTilePlugin.swift rename to macos/Sources/Features/Custom App Icon/DockTilePlugin.swift diff --git a/macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift b/macos/Sources/Features/Custom App Icon/Extensions/Notification+AppIcon.swift similarity index 100% rename from macos/Sources/Features/Dock Tile Plugin/Notification+AppIcon.swift rename to macos/Sources/Features/Custom App Icon/Extensions/Notification+AppIcon.swift diff --git a/macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift similarity index 100% rename from macos/Sources/Features/Dock Tile Plugin/UserDefaults+AppIcon.swift rename to macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift From 9f3f97b231ac738eb4413bb0d948525510025531 Mon Sep 17 00:00:00 2001 From: Andrej Daskalov Date: Tue, 24 Feb 2026 09:50:16 +0100 Subject: [PATCH 70/79] i18n: Updated mk translations with new strings --- po/mk_MK.UTF-8.po | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/po/mk_MK.UTF-8.po b/po/mk_MK.UTF-8.po index 539283271..0307fff95 100644 --- a/po/mk_MK.UTF-8.po +++ b/po/mk_MK.UTF-8.po @@ -19,7 +19,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Отвори во Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -179,7 +179,7 @@ msgstr "Јазиче" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Промени наслов на јазиче…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -336,7 +336,7 @@ msgstr "Промени наслов на терминал" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Промени наслов на јазиче" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" From f831f68f1aab3148e8d46362cb9991425e62f395 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:51:30 +0100 Subject: [PATCH 71/79] macOS: update AppIcon encoding - make `ColorizedGhosttyIcon` codable - remove deprecated string encoding introduced in tip --- .../Features/Custom App Icon/AppIcon.swift | 78 +++---------------- .../ColorizedGhosttyIcon.swift | 35 ++++++++- .../Extensions/UserDefaults+AppIcon.swift | 8 -- 3 files changed, 44 insertions(+), 77 deletions(-) diff --git a/macos/Sources/Features/Custom App Icon/AppIcon.swift b/macos/Sources/Features/Custom App Icon/AppIcon.swift index 52ab9ef95..296bd10fe 100644 --- a/macos/Sources/Features/Custom App Icon/AppIcon.swift +++ b/macos/Sources/Features/Custom App Icon/AppIcon.swift @@ -13,9 +13,9 @@ enum AppIcon: Equatable, Codable { case retro case xray /// Save full image data to avoid sandboxing issues - case custom(fileData: Data) - case customStyle(ghostColorHex: String, screenColorHexes: [String], iconFrame: Ghostty.MacOSIconFrame) - + case custom(_ iconFile: Data) + case customStyle(_ icon: ColorizedGhosttyIcon) + #if !DOCK_TILE_PLUGIN init?(config: Ghostty.Config) { switch config.macosIcon { @@ -39,7 +39,7 @@ enum AppIcon: Equatable, Codable { self = .xray case .custom: if let data = try? Data(contentsOf: URL(filePath: config.macosCustomIcon, relativeTo: nil)) { - self = .custom(fileData: data) + self = .custom(data) } else { return nil } @@ -47,59 +47,16 @@ enum AppIcon: Equatable, Codable { // Discard saved icon name // if no valid colours were found guard - let ghostColor = config.macosIconGhostColor?.hexString, - let screenColors = config.macosIconScreenColor?.compactMap(\.hexString) + let ghostColor = config.macosIconGhostColor, + let screenColors = config.macosIconScreenColor else { return nil } - self = .customStyle(ghostColorHex: ghostColor, screenColorHexes: screenColors, iconFrame: config.macosIconFrame) + self = .customStyle(ColorizedGhosttyIcon(screenColors: screenColors, ghostColor: ghostColor, frame: config.macosIconFrame)) } } #endif - /// Restore the icon from previously saved values - init?(string: String) { - switch string { - case Ghostty.MacOSIcon.official.rawValue: - self = .official - case Ghostty.MacOSIcon.blueprint.rawValue: - self = .blueprint - case Ghostty.MacOSIcon.chalkboard.rawValue: - self = .chalkboard - case Ghostty.MacOSIcon.glass.rawValue: - self = .glass - case Ghostty.MacOSIcon.holographic.rawValue: - self = .holographic - case Ghostty.MacOSIcon.microchip.rawValue: - self = .microchip - case Ghostty.MacOSIcon.paper.rawValue: - self = .paper - case Ghostty.MacOSIcon.retro.rawValue: - self = .retro - case Ghostty.MacOSIcon.xray.rawValue: - self = .xray - default: - var parts = string.split(separator: "_").map(String.init) - if - let _ = parts.first.flatMap(NSColor.init(hex:)), - let frame = parts.last.flatMap(Ghostty.MacOSIconFrame.init(rawValue:)) - { - let ghostC = parts.removeFirst() - _ = parts.removeLast() - self = .customStyle( - ghostColorHex: ghostC, - screenColorHexes: parts, - iconFrame: frame - ) - } else { - // Due to sandboxing with `com.apple.dock.external.extra.arm64`, - // we can’t restore custom icon file automatically. - // The user must open the app to update it. - return nil - } - } - } - func image(in bundle: Bundle) -> NSImage? { switch self { case .official: @@ -121,24 +78,9 @@ enum AppIcon: Equatable, Codable { case .xray: return bundle.image(forResource: "XrayImage")! case let .custom(file): - if let userIcon = NSImage(data: file) { - return userIcon - } else { - return nil - } - case let .customStyle(ghostColorHex, screenColorHexes, macosIconFrame): - let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) - guard - let ghostColor = NSColor(hex: ghostColorHex), - let icon = ColorizedGhosttyIcon( - screenColors: screenColors, - ghostColor: ghostColor, - frame: macosIconFrame - ).makeImage(in: bundle) - else { - return nil - } - return icon + return NSImage(data: file) + case let .customStyle(customIcon): + return customIcon.makeImage(in: bundle) } } } diff --git a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift index df24477d4..62f58a063 100644 --- a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift +++ b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift @@ -1,6 +1,33 @@ import Cocoa -struct ColorizedGhosttyIcon { +struct ColorizedGhosttyIcon: Codable, Equatable { + init(screenColors: [NSColor], ghostColor: NSColor, frame: Ghostty.MacOSIconFrame) { + self.screenColors = screenColors + self.ghostColor = ghostColor + self.frame = frame + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let screenColorHexes = try container.decode([String].self, forKey: .screenColors) + let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) + let ghostColorHex = try container.decode(String.self, forKey: .ghostColor) + guard let ghostColor = NSColor(hex: ghostColorHex) else { + throw NSError(domain: "Custom Icon Error", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to decode ghost color from \(ghostColorHex)" + ]) + } + let frame = try container.decode(Ghostty.MacOSIconFrame.self, forKey: .frame) + self.init(screenColors: screenColors, ghostColor: ghostColor, frame: frame) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(screenColors.compactMap(\.hexString), forKey: .screenColors) + try container.encode(ghostColor.hexString, forKey: .ghostColor) + try container.encode(frame, forKey: .frame) + } + /// The colors that make up the gradient of the screen. let screenColors: [NSColor] @@ -10,6 +37,12 @@ struct ColorizedGhosttyIcon { /// The frame type to use let frame: Ghostty.MacOSIconFrame + private enum CodingKeys: String, CodingKey { + case screenColors + case ghostColor + case frame + } + /// Make a custom colorized ghostty icon. func makeImage(in bundle: Bundle) -> NSImage? { // All of our layers (not in order) diff --git a/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift index cce8e24a4..9478cc5c3 100644 --- a/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift +++ b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift @@ -11,14 +11,6 @@ extension UserDefaults { removeObject(forKey: Self.customIconKeyOld) } - // If we have an old, pre-docktileplugin value, then we parse the - // the old value (try) and set it. - if let previous = string(forKey: Self.customIconKeyOld), let newIcon = AppIcon(string: previous) { - // update new storage once - self.appIcon = newIcon - return newIcon - } - // Check if we have the new key for our dock tile plugin format. guard let data = data(forKey: Self.customIconKeyNew) else { return nil From c72788894e11c0d60368ba138f3f346b8c1eb145 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:13:24 +0100 Subject: [PATCH 72/79] ci: fix linting and delete non-useful tests --- macos/Sources/App/macOS/AppDelegate.swift | 2 +- .../Features/Custom App Icon/AppIcon.swift | 2 +- .../Custom App Icon/DockTilePlugin.swift | 6 +++--- .../Extensions/UserDefaults+AppIcon.swift | 8 ++++---- macos/Tests/CustomIconTests.swift | 20 ------------------- 5 files changed, 9 insertions(+), 29 deletions(-) delete mode 100644 macos/Tests/CustomIconTests.swift diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index b9aab0ac4..028d4506c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -928,7 +928,7 @@ class AppDelegate: NSObject, } else { GlobalEventTap.shared.disable() } - + updateAppIcon(from: config) } diff --git a/macos/Sources/Features/Custom App Icon/AppIcon.swift b/macos/Sources/Features/Custom App Icon/AppIcon.swift index 296bd10fe..13c6b83a1 100644 --- a/macos/Sources/Features/Custom App Icon/AppIcon.swift +++ b/macos/Sources/Features/Custom App Icon/AppIcon.swift @@ -56,7 +56,7 @@ enum AppIcon: Equatable, Codable { } } #endif - + func image(in bundle: Bundle) -> NSImage? { switch self { case .official: diff --git a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift index ad750376d..a3e094f96 100644 --- a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift +++ b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift @@ -4,9 +4,9 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { // WARNING: An instance of this class is alive as long as Ghostty's icon is // in the doc (running or not!), so keep any state and processing to a // minimum to respect resource usage. - + private let pluginBundle = Bundle(for: DockTilePlugin.self) - + // Separate defaults based on debug vs release builds so we can test icons // without messing up releases. #if DEBUG @@ -84,7 +84,7 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { appIcon = pluginBundle.image(forResource: "AppIconImage")! NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) } - + NSWorkspace.shared.noteFileSystemChanged(appBundlePath) dockTile.setIcon(appIcon) } diff --git a/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift index 9478cc5c3..d15644c93 100644 --- a/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift +++ b/macos/Sources/Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift @@ -3,26 +3,26 @@ import AppKit extension UserDefaults { private static let customIconKeyOld = "CustomGhosttyIcon" private static let customIconKeyNew = "CustomGhosttyIcon2" - + var appIcon: AppIcon? { get { // Always remove our old pre-docktileplugin values. defer { removeObject(forKey: Self.customIconKeyOld) } - + // Check if we have the new key for our dock tile plugin format. guard let data = data(forKey: Self.customIconKeyNew) else { return nil } return try? JSONDecoder().decode(AppIcon.self, from: data) } - + set { guard let newData = try? JSONEncoder().encode(newValue) else { return } - + set(newData, forKey: Self.customIconKeyNew) } } diff --git a/macos/Tests/CustomIconTests.swift b/macos/Tests/CustomIconTests.swift deleted file mode 100644 index df6310bbf..000000000 --- a/macos/Tests/CustomIconTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -@testable import Ghostty -import Testing - -struct CustomIconTests { - @Test func migration() { - #expect(Ghostty.CustomAppIcon.blueprint == Ghostty.CustomAppIcon(string: "blueprint")) - - #expect(nil == Ghostty.CustomAppIcon(string: "~/downloads/some/file.png")) - - #expect(nil == Ghostty.CustomAppIcon(string: "#B0260C")) - - #expect(nil == Ghostty.CustomAppIcon(string: "plastic")) - - #expect(Ghostty.CustomAppIcon.customStyle(ghostColorHex: "#B0260C", screenColorHexes: [], iconFrame: .plastic) == Ghostty.CustomAppIcon(string: "#B0260C_plastic")) - - #expect(Ghostty.CustomAppIcon.customStyle(ghostColorHex: "#B0260C", screenColorHexes: ["#4F2C27"], iconFrame: .plastic) == Ghostty.CustomAppIcon(string: "#B0260C_#4F2C27_plastic")) - - #expect(Ghostty.CustomAppIcon.customStyle(ghostColorHex: "#B0260C", screenColorHexes: ["#4F2C27", "#B0260C"], iconFrame: .plastic) == Ghostty.CustomAppIcon(string: "#B0260C_#4F2C27_#B0260C_plastic")) - } -} From 57d570525b3ee4e365ec7bde237230d429760e41 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 24 Feb 2026 10:28:33 -0600 Subject: [PATCH 73/79] gtk: clean up title renaming and fix a small leak --- src/apprt/gtk/class/tab.zig | 4 ++++ src/apprt/gtk/class/window.zig | 9 +++++++++ src/apprt/gtk/ui/1.5/window.blp | 2 +- src/input/Binding.zig | 5 +---- src/input/command.zig | 4 ++-- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index 15e126642..24caa4990 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -330,6 +330,10 @@ pub const Tab = extern struct { glib.free(@ptrCast(@constCast(v))); priv.title = null; } + if (priv.title_override) |v| { + glib.free(@ptrCast(@constCast(v))); + priv.title_override = null; + } gobject.Object.virtual_methods.finalize.call( Class.parent, diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index dc33abd21..a79945991 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -339,6 +339,7 @@ pub const Window = extern struct { .init("close-tab", actionCloseTab, s_variant_type), .init("new-tab", actionNewTab, null), .init("new-window", actionNewWindow, null), + .init("prompt-surface-title", actionPromptSurfaceTitle, null), .init("prompt-tab-title", actionPromptTabTitle, null), .init("prompt-context-tab-title", actionPromptContextTabTitle, null), .init("ring-bell", actionRingBell, null), @@ -1803,6 +1804,14 @@ pub const Window = extern struct { tab.promptTabTitle(); } + fn actionPromptSurfaceTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.prompt_surface_title); + } + fn actionPromptTabTitle( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index a139f8cc5..b66a93093 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -243,7 +243,7 @@ menu main_menu { item { label: _("Change Title…"); - action: "win.prompt-title"; + action: "win.prompt-surface-title"; } item { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 57414d764..286c8f2ed 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -570,12 +570,9 @@ pub const Action = union(enum) { toggle_tab_overview, /// Change the title of the current focused surface via a pop-up prompt. - /// - /// This requires libadwaita 1.5 or newer on Linux. The current libadwaita - /// version can be found by running `ghostty +version`. prompt_surface_title, - /// Change the title of the current tab/window via a pop-up prompt. The + /// Change the title of the current tab via a pop-up prompt. The /// title set via this prompt overrides any title set by the terminal /// and persists across focus changes within the tab. prompt_tab_title, diff --git a/src/input/command.zig b/src/input/command.zig index d6d2b0247..f50e6840b 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -440,13 +440,13 @@ fn actionCommands(action: Action.Key) []const Command { .prompt_surface_title => comptime &.{.{ .action = .prompt_surface_title, - .title = "Change Terminal Title...", + .title = "Change Terminal Title…", .description = "Prompt for a new title for the current terminal.", }}, .prompt_tab_title => comptime &.{.{ .action = .prompt_tab_title, - .title = "Change Tab Title...", + .title = "Change Tab Title…", .description = "Prompt for a new title for the current tab.", }}, From 245ea565975a1199e048699759a839c790c76d79 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Feb 2026 09:48:39 -0800 Subject: [PATCH 74/79] update vouch to 1.4.2 --- .github/workflows/vouch-check-issue.yml | 2 +- .github/workflows/vouch-check-pr.yml | 2 +- .github/workflows/vouch-manage-by-discussion.yml | 2 +- .github/workflows/vouch-manage-by-issue.yml | 2 +- .github/workflows/vouch-sync-codeowners.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml index 3e0da9818..3fa3bb542 100644 --- a/.github/workflows/vouch-check-issue.yml +++ b/.github/workflows/vouch-check-issue.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-issue@0a63681ac1791b66359e8ccf29eabdd40bdf18e0 # v1.4.1 + - uses: mitchellh/vouch/action/check-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: issue-number: ${{ github.event.issue.number }} auto-close: true diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml index eaee77830..0efb6208c 100644 --- a/.github/workflows/vouch-check-pr.yml +++ b/.github/workflows/vouch-check-pr.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-pr@0a63681ac1791b66359e8ccf29eabdd40bdf18e0 # v1.4.1 + - uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: pr-number: ${{ github.event.pull_request.number }} auto-close: true diff --git a/.github/workflows/vouch-manage-by-discussion.yml b/.github/workflows/vouch-manage-by-discussion.yml index a826e726e..cf7c092e2 100644 --- a/.github/workflows/vouch-manage-by-discussion.yml +++ b/.github/workflows/vouch-manage-by-discussion.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-discussion@0a63681ac1791b66359e8ccf29eabdd40bdf18e0 # v1.4.1 + - uses: mitchellh/vouch/action/manage-by-discussion@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: discussion-number: ${{ github.event.discussion.number }} comment-node-id: ${{ github.event.comment.node_id }} diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml index 70ef9620c..6f85520bd 100644 --- a/.github/workflows/vouch-manage-by-issue.yml +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-issue@0a63681ac1791b66359e8ccf29eabdd40bdf18e0 # v1.4.1 + - uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: repo: ${{ github.repository }} issue-id: ${{ github.event.issue.number }} diff --git a/.github/workflows/vouch-sync-codeowners.yml b/.github/workflows/vouch-sync-codeowners.yml index 77ad92bd2..fac06a372 100644 --- a/.github/workflows/vouch-sync-codeowners.yml +++ b/.github/workflows/vouch-sync-codeowners.yml @@ -23,7 +23,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/sync-codeowners@0a63681ac1791b66359e8ccf29eabdd40bdf18e0 # v1.4.1 + - uses: mitchellh/vouch/action/sync-codeowners@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: repo: ${{ github.repository }} pull-request: "true" From 1087751d265364e2b52eb4069505852edd48b8b7 Mon Sep 17 00:00:00 2001 From: "ghostty-vouch[bot]" <262049992+ghostty-vouch[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:49:28 +0000 Subject: [PATCH 75/79] Update VOUCHED list (#10996) Triggered by [discussion comment](https://github.com/ghostty-org/ghostty/discussions/10981#discussioncomment-15913757) from @mitchellh. Vouch: @Koranir Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index f1c97f2ac..179b5f222 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -72,6 +72,7 @@ khipp kirwiisp kjvdven kloneets +koranir kristina8888 kristofersoler laxystem From f451ea8e4603a63058a9f019623a8d18b103b98f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Feb 2026 10:20:25 -0800 Subject: [PATCH 76/79] macos: move icon codable/equatable to extension --- .../ColorizedGhosttyIcon.swift | 95 ++++++++++++------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift index 62f58a063..99d684369 100644 --- a/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift +++ b/macos/Sources/Features/Custom App Icon/ColorizedGhosttyIcon.swift @@ -1,33 +1,6 @@ import Cocoa -struct ColorizedGhosttyIcon: Codable, Equatable { - init(screenColors: [NSColor], ghostColor: NSColor, frame: Ghostty.MacOSIconFrame) { - self.screenColors = screenColors - self.ghostColor = ghostColor - self.frame = frame - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let screenColorHexes = try container.decode([String].self, forKey: .screenColors) - let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) - let ghostColorHex = try container.decode(String.self, forKey: .ghostColor) - guard let ghostColor = NSColor(hex: ghostColorHex) else { - throw NSError(domain: "Custom Icon Error", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to decode ghost color from \(ghostColorHex)" - ]) - } - let frame = try container.decode(Ghostty.MacOSIconFrame.self, forKey: .frame) - self.init(screenColors: screenColors, ghostColor: ghostColor, frame: frame) - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(screenColors.compactMap(\.hexString), forKey: .screenColors) - try container.encode(ghostColor.hexString, forKey: .ghostColor) - try container.encode(frame, forKey: .frame) - } - +struct ColorizedGhosttyIcon { /// The colors that make up the gradient of the screen. let screenColors: [NSColor] @@ -37,12 +10,6 @@ struct ColorizedGhosttyIcon: Codable, Equatable { /// The frame type to use let frame: Ghostty.MacOSIconFrame - private enum CodingKeys: String, CodingKey { - case screenColors - case ghostColor - case frame - } - /// Make a custom colorized ghostty icon. func makeImage(in bundle: Bundle) -> NSImage? { // All of our layers (not in order) @@ -86,3 +53,63 @@ struct ColorizedGhosttyIcon: Codable, Equatable { ]) } } + +// MARK: Codable + +extension ColorizedGhosttyIcon: Codable { + private enum CodingKeys: String, CodingKey { + case version + case screenColors + case ghostColor + case frame + + static let currentVersion: Int = 1 + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // If no version exists then this is the legacy v0 format. + let version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 0 + guard version == 0 || version == CodingKeys.currentVersion else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unsupported ColorizedGhosttyIcon version: \(version)" + ) + ) + } + + let screenColorHexes = try container.decode([String].self, forKey: .screenColors) + let screenColors = screenColorHexes.compactMap(NSColor.init(hex:)) + let ghostColorHex = try container.decode(String.self, forKey: .ghostColor) + guard let ghostColor = NSColor(hex: ghostColorHex) else { + throw DecodingError.dataCorruptedError( + forKey: .ghostColor, + in: container, + debugDescription: "Failed to decode ghost color from \(ghostColorHex)" + ) + } + let frame = try container.decode(Ghostty.MacOSIconFrame.self, forKey: .frame) + self.init(screenColors: screenColors, ghostColor: ghostColor, frame: frame) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(CodingKeys.currentVersion, forKey: .version) + try container.encode(screenColors.compactMap(\.hexString), forKey: .screenColors) + try container.encode(ghostColor.hexString, forKey: .ghostColor) + try container.encode(frame, forKey: .frame) + } + +} + +// MARK: Equatable + +extension ColorizedGhosttyIcon: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.frame == rhs.frame && + lhs.screenColors.compactMap(\.hexString) == rhs.screenColors.compactMap(\.hexString) && + lhs.ghostColor.hexString == rhs.ghostColor.hexString + } +} From eaf7d8a012636d51aa304d59147ea656e1edd615 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Feb 2026 10:26:11 -0800 Subject: [PATCH 77/79] macos: icon tests --- macos/Tests/ColorizedGhosttyIconTests.swift | 144 ++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 macos/Tests/ColorizedGhosttyIconTests.swift diff --git a/macos/Tests/ColorizedGhosttyIconTests.swift b/macos/Tests/ColorizedGhosttyIconTests.swift new file mode 100644 index 000000000..bf2963f33 --- /dev/null +++ b/macos/Tests/ColorizedGhosttyIconTests.swift @@ -0,0 +1,144 @@ +import AppKit +import Foundation +import Testing +@testable import Ghostty + +struct ColorizedGhosttyIconTests { + private func makeIcon( + screenColors: [NSColor] = [ + NSColor(hex: "#112233")!, + NSColor(hex: "#AABBCC")!, + ], + ghostColor: NSColor = NSColor(hex: "#445566")!, + frame: Ghostty.MacOSIconFrame = .aluminum + ) -> ColorizedGhosttyIcon { + .init(screenColors: screenColors, ghostColor: ghostColor, frame: frame) + } + + // MARK: - Codable + + @Test func codableRoundTripPreservesIcon() throws { + let icon = makeIcon(frame: .chrome) + let data = try JSONEncoder().encode(icon) + let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + + #expect(decoded == icon) + #expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"]) + #expect(decoded.ghostColor.hexString == "#445566") + #expect(decoded.frame == .chrome) + } + + @Test func encodingWritesVersionAndHexColors() throws { + let icon = makeIcon(frame: .plastic) + let data = try JSONEncoder().encode(icon) + + let payload = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(payload["version"] as? Int == 1) + #expect(payload["screenColors"] as? [String] == ["#112233", "#AABBCC"]) + #expect(payload["ghostColor"] as? String == "#445566") + #expect(payload["frame"] as? String == "plastic") + } + + @Test func decodesLegacyV0PayloadWithoutVersion() throws { + let data = Data(""" + { + "screenColors": ["#112233", "#AABBCC"], + "ghostColor": "#445566", + "frame": "beige" + } + """.utf8) + + let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + #expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"]) + #expect(decoded.ghostColor.hexString == "#445566") + #expect(decoded.frame == .beige) + } + + @Test func decodingUnsupportedVersionThrowsDataCorrupted() { + let data = Data(""" + { + "version": 99, + "screenColors": ["#112233", "#AABBCC"], + "ghostColor": "#445566", + "frame": "chrome" + } + """.utf8) + + do { + _ = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + Issue.record("Expected decode to fail for unsupported version") + } catch let DecodingError.dataCorrupted(context) { + #expect(context.debugDescription.contains("Unsupported ColorizedGhosttyIcon version")) + } catch { + Issue.record("Expected DecodingError.dataCorrupted, got: \(error)") + } + } + + @Test func decodingInvalidGhostColorThrows() { + let data = Data(""" + { + "version": 1, + "screenColors": ["#112233", "#AABBCC"], + "ghostColor": "not-a-color", + "frame": "chrome" + } + """.utf8) + + do { + _ = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + Issue.record("Expected decode to fail for invalid ghost color") + } catch let DecodingError.dataCorrupted(context) { + #expect(context.debugDescription.contains("Failed to decode ghost color")) + } catch { + Issue.record("Expected DecodingError.dataCorrupted, got: \(error)") + } + } + + @Test func decodingInvalidScreenColorsDropsInvalidEntries() throws { + let data = Data(""" + { + "version": 1, + "screenColors": ["#112233", "invalid", "#AABBCC"], + "ghostColor": "#445566", + "frame": "chrome" + } + """.utf8) + + let decoded = try JSONDecoder().decode(ColorizedGhosttyIcon.self, from: data) + #expect(decoded.screenColors.compactMap(\.hexString) == ["#112233", "#AABBCC"]) + } + + // MARK: - Equatable + + @Test func equatableUsesHexColorAndFrameValues() { + let lhs = makeIcon( + screenColors: [ + NSColor(red: 0x11 / 255.0, green: 0x22 / 255.0, blue: 0x33 / 255.0, alpha: 1.0), + NSColor(red: 0xAA / 255.0, green: 0xBB / 255.0, blue: 0xCC / 255.0, alpha: 1.0), + ], + ghostColor: NSColor(red: 0x44 / 255.0, green: 0x55 / 255.0, blue: 0x66 / 255.0, alpha: 1.0), + frame: .chrome + ) + let rhs = makeIcon(frame: .chrome) + + #expect(lhs == rhs) + } + + @Test func equatableReturnsFalseForDifferentFrame() { + let lhs = makeIcon(frame: .aluminum) + let rhs = makeIcon(frame: .chrome) + #expect(lhs != rhs) + } + + @Test func equatableReturnsFalseForDifferentScreenColors() { + let lhs = makeIcon(screenColors: [NSColor(hex: "#112233")!, NSColor(hex: "#AABBCC")!]) + let rhs = makeIcon(screenColors: [NSColor(hex: "#112233")!, NSColor(hex: "#CCBBAA")!]) + #expect(lhs != rhs) + } + + @Test func equatableReturnsFalseForDifferentGhostColor() { + let lhs = makeIcon(ghostColor: NSColor(hex: "#445566")!) + let rhs = makeIcon(ghostColor: NSColor(hex: "#665544")!) + #expect(lhs != rhs) + } +} From 06084cd840daa053d80e56c78b03490b36dd67cd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Feb 2026 10:37:46 -0800 Subject: [PATCH 78/79] macos: various dock tile cleanups --- .../Custom App Icon/DockTilePlugin.swift | 70 +++++++++++-------- 1 file changed, 41 insertions(+), 29 deletions(-) diff --git a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift index a3e094f96..6c5abc198 100644 --- a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift +++ b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift @@ -17,26 +17,8 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { private var iconChangeObserver: Any? - func setDockTile(_ dockTile: NSDockTile?) { - guard let dockTile, let ghosttyUserDefaults else { - iconChangeObserver = nil - return - } - // Try to restore the previous icon on launch. - iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile) - - iconChangeObserver = DistributedNotificationCenter.default().publisher(for: .ghosttyIconDidChange) - .map { [weak self] _ in - self?.ghosttyUserDefaults?.appIcon - } - .receive(on: DispatchQueue.global()) - .sink { [weak self] newIcon in - guard let self else { return } - iconDidChange(newIcon, dockTile: dockTile) - } - } - - func getGhosttyAppPath() -> String { + /// The path to the Ghostty.app, determined based on the bundle path of this plugin. + var ghosttyAppPath: String { var url = pluginBundle.bundleURL // Remove "/Contents/PlugIns/DockTilePlugIn.bundle" from the bundle URL to reach Ghostty.app. while url.lastPathComponent != "Ghostty.app", !url.lastPathComponent.isEmpty { @@ -45,31 +27,59 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { return url.path } - func iconDidChange(_ newIcon: AppIcon?, dockTile: NSDockTile) { + /// The primary NSDockTilePlugin function. + func setDockTile(_ dockTile: NSDockTile?) { + // If no dock tile or no access to Ghostty defaults, we can't do anything. + guard let dockTile, let ghosttyUserDefaults else { + iconChangeObserver = nil + return + } + + // Try to restore the previous icon on launch. + iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile) + + // Setup a new observer for when the icon changes so we can update. This message + // is sent by the primary Ghostty app. + iconChangeObserver = DistributedNotificationCenter + .default() + .publisher(for: .ghosttyIconDidChange) + .map { [weak self] _ in self?.ghosttyUserDefaults?.appIcon } + .receive(on: DispatchQueue.global()) + .sink { [weak self] newIcon in self?.iconDidChange(newIcon, dockTile: dockTile) } + } + + private func iconDidChange(_ newIcon: AppIcon?, dockTile: NSDockTile) { guard let appIcon = newIcon?.image(in: pluginBundle) else { resetIcon(dockTile: dockTile) return } - let appBundlePath = getGhosttyAppPath() + + let appBundlePath = self.ghosttyAppPath NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) NSWorkspace.shared.noteFileSystemChanged(appBundlePath) dockTile.setIcon(appIcon) } - func resetIcon(dockTile: NSDockTile) { - let appBundlePath = getGhosttyAppPath() + /// Reset the application icon and dock tile icon to the default. + private func resetIcon(dockTile: NSDockTile) { + let appBundlePath = self.ghosttyAppPath let appIcon: NSImage if #available(macOS 26.0, *) { // Reset to the default (glassy) icon. NSWorkspace.shared.setIcon(nil, forFile: appBundlePath) + #if DEBUG - // Use the `Blueprint` icon to - // distinguish Debug from Release builds. + // Use the `Blueprint` icon to distinguish Debug from Release builds. appIcon = pluginBundle.image(forResource: "BlueprintImage")! #else // Get the composed icon from the app bundle. - if let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath).bestRepresentation(for: CGRect(origin: .zero, size: dockTile.size), context: nil, hints: nil) { + if let iconRep = NSWorkspace.shared.icon(forFile: appBundlePath) + .bestRepresentation( + for: CGRect(origin: .zero, size: dockTile.size), + context: nil, + hints: nil + ) { appIcon = NSImage(size: dockTile.size) appIcon.addRepresentation(iconRep) } else { @@ -79,12 +89,12 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn { } #endif } else { - // Use the bundled icon to keep the corner radius - // consistent with other apps. + // Use the bundled icon to keep the corner radius consistent with pre-Tahoe apps. appIcon = pluginBundle.image(forResource: "AppIconImage")! NSWorkspace.shared.setIcon(appIcon, forFile: appBundlePath) } + // Notify Finder/Dock so icon caches refresh immediately. NSWorkspace.shared.noteFileSystemChanged(appBundlePath) dockTile.setIcon(appIcon) } @@ -103,4 +113,6 @@ private extension NSDockTile { } } +// This is required because of the DispatchQueue call above. This doesn't +// feel right but I don't know a better way to solve this. extension NSDockTile: @unchecked @retroactive Sendable {} From 6132597563596a12ced386f3760786b62e2dd216 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Feb 2026 12:29:14 -0800 Subject: [PATCH 79/79] ci: codesign DockTilePlugin --- .github/workflows/release-tag.yml | 1 + .github/workflows/release-tip.yml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 16cfd9dba..4cf128d43 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -231,6 +231,7 @@ jobs: /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin" # Codesign the app bundle /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index bccb3b0ed..fb26e964e 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -326,6 +326,7 @@ jobs: /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin" # Codesign the app bundle /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app @@ -581,6 +582,7 @@ jobs: /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin" # Codesign the app bundle /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app @@ -777,6 +779,7 @@ jobs: /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/Frameworks/Sparkle.framework" + /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime "macos/build/Release/Ghostty.app/Contents/PlugIns/DockTilePlugin.plugin" # Codesign the app bundle /usr/bin/codesign --verbose -f -s "$MACOS_CERTIFICATE_NAME" -o runtime --entitlements "macos/Ghostty.entitlements" macos/build/Release/Ghostty.app