From 53b029284d8a025dd18bb07102247593ddb64a71 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Mon, 21 Jul 2025 21:23:02 -0700 Subject: [PATCH 01/42] Fix off-by-one error & adjust overline pos in cell height mod --- src/font/Metrics.zig | 57 +++++++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index 89f6a507f..811a4c139 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -235,17 +235,26 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { // centered in the cell. if (comptime tag == .cell_height) { // We split the difference in half because we want to - // center the baseline in the cell. + // center the baseline in the cell. We round the shift + // down to the nearest full pixel shift, such that if + // the diff is odd, there's one more pixel added/removed + // on top than on the bottom. if (new > original) { - const diff = (new - original) / 2; - self.cell_baseline +|= diff; - self.underline_position +|= diff; - self.strikethrough_position +|= diff; + const diff = new - original; + const diff_bottom = diff / 2; + const diff_top = diff - diff_bottom; + self.cell_baseline +|= diff_bottom; + self.underline_position +|= diff_top; + self.strikethrough_position +|= diff_top; + self.overline_position +|= @as(i32, @intCast(diff_top)); } else { - const diff = (original - new) / 2; - self.cell_baseline -|= diff; - self.underline_position -|= diff; - self.strikethrough_position -|= diff; + const diff = original - new; + const diff_bottom = diff / 2; + const diff_top = diff - diff_bottom; + self.cell_baseline -|= diff_bottom; + self.underline_position -|= diff_top; + self.strikethrough_position -|= diff_top; + self.overline_position -|= @as(i32, @intCast(diff_top)); } } }, @@ -463,19 +472,24 @@ test "Metrics: adjust cell height smaller" { var set: ModifierSet = .{}; defer set.deinit(alloc); - try set.put(alloc, .cell_height, .{ .percent = 0.5 }); + // We choose numbers such that the subtracted number of pixels is odd, + // as that's the case that could most easily have off-by-one errors. + // Here we're removing 25 pixels: 12 on the bottom, 13 on top. + try set.put(alloc, .cell_height, .{ .percent = 0.75 }); var m: Metrics = init(); m.cell_baseline = 50; m.underline_position = 55; m.strikethrough_position = 30; + m.overline_position = 0; m.cell_height = 100; m.cursor_height = 100; m.apply(set); - try testing.expectEqual(@as(u32, 50), m.cell_height); - try testing.expectEqual(@as(u32, 25), m.cell_baseline); - try testing.expectEqual(@as(u32, 30), m.underline_position); - try testing.expectEqual(@as(u32, 5), m.strikethrough_position); + try testing.expectEqual(@as(u32, 75), m.cell_height); + try testing.expectEqual(@as(u32, 38), m.cell_baseline); + try testing.expectEqual(@as(u32, 42), m.underline_position); + try testing.expectEqual(@as(u32, 17), m.strikethrough_position); + try testing.expectEqual(@as(i32, -13), m.overline_position); // Cursor height is separate from cell height and does not follow it. try testing.expectEqual(@as(u32, 100), m.cursor_height); } @@ -486,19 +500,24 @@ test "Metrics: adjust cell height larger" { var set: ModifierSet = .{}; defer set.deinit(alloc); - try set.put(alloc, .cell_height, .{ .percent = 2 }); + // We choose numbers such that the added number of pixels is odd, + // as that's the case that could most easily have off-by-one errors. + // Here we're adding 75 pixels: 37 on the bottom, 38 on top. + try set.put(alloc, .cell_height, .{ .percent = 1.75 }); var m: Metrics = init(); m.cell_baseline = 50; m.underline_position = 55; m.strikethrough_position = 30; + m.overline_position = 0; m.cell_height = 100; m.cursor_height = 100; m.apply(set); - try testing.expectEqual(@as(u32, 200), m.cell_height); - try testing.expectEqual(@as(u32, 100), m.cell_baseline); - try testing.expectEqual(@as(u32, 105), m.underline_position); - try testing.expectEqual(@as(u32, 80), m.strikethrough_position); + try testing.expectEqual(@as(u32, 175), m.cell_height); + try testing.expectEqual(@as(u32, 87), m.cell_baseline); + try testing.expectEqual(@as(u32, 93), m.underline_position); + try testing.expectEqual(@as(u32, 68), m.strikethrough_position); + try testing.expectEqual(@as(i32, 38), m.overline_position); // Cursor height is separate from cell height and does not follow it. try testing.expectEqual(@as(u32, 100), m.cursor_height); } From 54b56af57000c14b3cc418ee26cfbf306a58cdbe Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Tue, 22 Jul 2025 09:30:43 -0700 Subject: [PATCH 02/42] Rewrite comment for clarity --- src/font/Metrics.zig | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index 811a4c139..d26f833af 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -235,10 +235,9 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { // centered in the cell. if (comptime tag == .cell_height) { // We split the difference in half because we want to - // center the baseline in the cell. We round the shift - // down to the nearest full pixel shift, such that if - // the diff is odd, there's one more pixel added/removed - // on top than on the bottom. + // center the baseline in the cell. If the difference + // is odd, one more pixel is added/removed on top than + // on the bottom. if (new > original) { const diff = new - original; const diff_bottom = diff / 2; From 650028fa9f60528d1d02111277de595f693d6e5e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Sep 2025 08:48:20 -0700 Subject: [PATCH 03/42] config: bind both physical digit plus unicode digit for `goto_tab` Fixes #8478 The comments explain this. --- .../Terminal/TerminalController.swift | 3 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- src/config/Config.zig | 33 ++++++++++++++++--- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 414f38d81..cadbb40e0 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -439,8 +439,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr continue } - let action = "goto_tab:\(tab)" - if let equiv = ghostty.config.keyboardShortcut(for: action) { + if let equiv = ghostty.config.keyboardShortcut(for: "goto_tab:\(tab)") { window.keyEquivalent = "\(equiv)" } else { window.keyEquivalent = "" diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2d83a8a6b..746f34cdb 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1264,7 +1264,7 @@ extension Ghostty { var key_ev = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags) key_ev.composing = composing - + // For text, we only encode UTF8 if we don't have a single control // character. Control characters are encoded by Ghostty itself. // Without this, `ctrl+enter` does the wrong thing. diff --git a/src/config/Config.zig b/src/config/Config.zig index 5ffd01871..384a4f006 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5785,15 +5785,24 @@ pub const Keybinds = struct { else .{ .alt = true }; - // Cmd+N for goto tab N + // Cmd/Alt+N for goto tab N const start: u21 = '1'; const end: u21 = '8'; - var i: u21 = start; - while (i <= end) : (i += 1) { + comptime var i: u21 = start; + inline while (i <= end) : (i += 1) { + // We register BOTH the physical `digit_N` key and the unicode + // `N` key. This allows most keyboard layouts to work with + // this shortcut. Namely, AZERTY doesn't produce unicode `N` + // for their digit keys (they're on shifted keys on the same + // physical keys). + try self.set.putFlags( alloc, .{ - .key = .{ .unicode = i }, + .key = .{ .physical = @field( + inputpkg.Key, + std.fmt.comptimePrint("digit_{u}", .{i}), + ) }, .mods = mods, }, .{ .goto_tab = (i - start) + 1 }, @@ -5806,6 +5815,22 @@ pub const Keybinds = struct { .performable = !builtin.target.os.tag.isDarwin(), }, ); + + // Important: this must be the LAST binding set so that the + // libghostty trigger API returns this one for the action, + // so that things like the macOS tab bar key equivalent label + // work properly. + try self.set.putFlags( + alloc, + .{ + .key = .{ .unicode = i }, + .mods = mods, + }, + .{ .goto_tab = (i - start) + 1 }, + .{ + .performable = !builtin.target.os.tag.isDarwin(), + }, + ); } try self.set.putFlags( alloc, From 7dcf2c9b62cc34b64c34e63aad6f6116c259bd61 Mon Sep 17 00:00:00 2001 From: Jesse Miller Date: Tue, 2 Sep 2025 12:05:30 -0600 Subject: [PATCH 04/42] Compare fields directly instead of PackedStyle Comparing the fields directly is actually faster than PackedStyle --- src/terminal/style.zig | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/terminal/style.zig b/src/terminal/style.zig index deb2b8ec5..25ad5b59f 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -85,9 +85,10 @@ pub const Style = struct { /// True if the style is equal to another style. pub fn eql(self: Style, other: Style) bool { - // We convert the styles to packed structs and compare as integers - // because this is much faster than comparing each field separately. - return PackedStyle.fromStyle(self) == PackedStyle.fromStyle(other); + return @as(u16, @bitCast(self.flags)) == @as(u16, @bitCast(other.flags)) and + std.meta.eql(self.fg_color, other.fg_color) and + std.meta.eql(self.bg_color, other.bg_color) and + std.meta.eql(self.underline_color, other.underline_color); } /// Returns the bg color for a cell with this style given the cell From ef7857f9be6054ee7a7fd732cbb54791f5c004b7 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 2 Sep 2025 12:42:34 -0600 Subject: [PATCH 05/42] fix(renderer): kitty images should all be processed When processing kitty images in a loop in a few places we were returning under certain conditions where we should instead have just continued the loop. This caused serious problems for kitty images, especially for apps that used multiple images on screen at once. ... I have no clue how I originally wrote this code and didn't see such a trivial mistake, I think I was sleep deprived or something. --- src/renderer/generic.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 1305dc3dc..039c8bea6 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1551,15 +1551,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Look up the image const image = self.images.get(p.image_id) orelse { log.warn("image not found for placement image_id={}", .{p.image_id}); - return; + continue; }; // Get the texture const texture = switch (image.image) { - .ready => |t| t, + .ready, + .unload_ready, + => |t| t, else => { log.warn("image not ready for placement image_id={}", .{p.image_id}); - return; + continue; }, }; @@ -1909,7 +1911,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (img.isUnloading()) { img.deinit(self.alloc); self.images.removeByPtr(kv.key_ptr); - return; + continue; } if (img.isPending()) try img.upload(self.alloc, &self.api); } From 2bf0d3f4c717216472826508a46ce89914c2ba8d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Sep 2025 12:26:50 -0700 Subject: [PATCH 06/42] macOS: Notify macOS of cell width/height for firstRect Related to #2473 This fixes an issue where the dictation icon didn't show the language picker. --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 746f34cdb..d88201eaf 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1710,7 +1710,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { // Ghostty coordinates are in top-left (0, 0) so we have to convert to // bottom-left since that is what UIKit expects - let viewRect = NSMakeRect(x, frame.size.height - y, 0, 0) + let viewRect = NSMakeRect(x, frame.size.height - y, cellSize.width, cellSize.height) // Convert the point to the window coordinates let winRect = self.convert(viewRect, to: nil) From a72995590bf7369272f3a10a895a3802a3797447 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 2 Sep 2025 13:33:33 -0600 Subject: [PATCH 07/42] fix(font): detect and reject improper advance for icwidth --- src/font/face/coretext.zig | 25 +++++++++++++++++++++---- src/font/face/freetype.zig | 16 +++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index cb6e6b1f7..8edee8baa 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -806,14 +806,31 @@ pub const Face = struct { const ic_width: ?f64 = ic_width: { const glyph = self.glyphIndex('水') orelse break :ic_width null; - var advances: [1]macos.graphics.Size = undefined; - _ = ct_font.getAdvancesForGlyphs( + const advance = ct_font.getAdvancesForGlyphs( .horizontal, &.{@intCast(glyph)}, - &advances, + null, ); - break :ic_width advances[0].width; + const bounds = ct_font.getBoundingRectsForGlyphs( + .horizontal, + &.{@intCast(glyph)}, + null, + ); + + // If the advance of the glyph is less than the width of the actual + // glyph then we just treat it as invalid since it's probably wrong + // and using it for size normalization will instead make the font + // way too big. + // + // This can sometimes happen if there's a CJK font that has been + // patched with the nerd fonts patcher and it butchers the advance + // values so the advance ends up half the width of the actual glyph. + if (bounds.size.width > advance) { + break :ic_width null; + } + + break :ic_width advance; }; return .{ diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 6c888672b..6ed1c8adb 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -1007,7 +1007,21 @@ pub const Face = struct { .no_svg = true, }) catch break :ic_width null; - break :ic_width f26dot6ToF64(face.handle.*.glyph.*.advance.x); + const ft_glyph = face.handle.*.glyph; + + // If the advance of the glyph is less than the width of the actual + // glyph then we just treat it as invalid since it's probably wrong + // and using it for size normalization will instead make the font + // way too big. + // + // This can sometimes happen if there's a CJK font that has been + // patched with the nerd fonts patcher and it butchers the advance + // values so the advance ends up half the width of the actual glyph. + if (ft_glyph.*.metrics.width > ft_glyph.*.advance.x) { + break :ic_width null; + } + + break :ic_width f26dot6ToF64(ft_glyph.*.advance.x); }; return .{ From 9aa1698e5a6f9b9924b2c7816e8c54ff0fcfa48f Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 2 Sep 2025 13:47:59 -0600 Subject: [PATCH 08/42] font: log warning when rejecting ic_width --- src/font/face/coretext.zig | 10 ++++++++++ src/font/face/freetype.zig | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 8edee8baa..a85c94430 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -827,6 +827,16 @@ pub const Face = struct { // patched with the nerd fonts patcher and it butchers the advance // values so the advance ends up half the width of the actual glyph. if (bounds.size.width > advance) { + var buf: [1024]u8 = undefined; + const font_name = self.name(&buf) catch ""; + log.warn( + "(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.", + .{ + font_name, + bounds.size.width, + advance, + }, + ); break :ic_width null; } diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 6ed1c8adb..1d8d2efff 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -1018,6 +1018,16 @@ pub const Face = struct { // patched with the nerd fonts patcher and it butchers the advance // values so the advance ends up half the width of the actual glyph. if (ft_glyph.*.metrics.width > ft_glyph.*.advance.x) { + var buf: [1024]u8 = undefined; + const font_name = self.name(&buf) catch ""; + log.warn( + "(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.", + .{ + font_name, + f26dot6ToF64(ft_glyph.*.metrics.width), + f26dot6ToF64(ft_glyph.*.advance.x), + }, + ); break :ic_width null; } From e8217aa00756dd29e3249aef9fcb735a9996cdbd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Sep 2025 13:06:23 -0700 Subject: [PATCH 09/42] macOS: firstRect should return full rect width/height Fixes #2473 This commit changes `ghostty_surface_ime_point` to return a full rect with the width/height calculated for the preedit. The `firstRect` function, which calls `ghostty_surface_ime_point` was previously setting the width/height to zero. macOS didn't like this. We then changed it to just hardcode it to width/height of one cell. This worked but made it so the IME cursor didn't follow the preedit. --- include/ghostty.h | 2 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 16 ++++++--- src/Surface.zig | 34 ++++++++++++++++++- src/apprt/embedded.zig | 10 +++++- src/apprt/structs.zig | 2 ++ 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index c871dd593..7888b380c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -964,7 +964,7 @@ void ghostty_surface_mouse_scroll(ghostty_surface_t, double, ghostty_input_scroll_mods_t); void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); -void ghostty_surface_ime_point(ghostty_surface_t, double*, double*); +void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*); void ghostty_surface_request_close(ghostty_surface_t); void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); void ghostty_surface_split_focus(ghostty_surface_t, diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d88201eaf..eef4bccb3 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1683,8 +1683,10 @@ extension Ghostty.SurfaceView: NSTextInputClient { } // Ghostty will tell us where it thinks an IME keyboard should render. - var x: Double = 0; - var y: Double = 0; + var x: Double = 0 + var y: Double = 0 + var width: Double = cellSize.width + var height: Double = cellSize.height // QuickLook never gives us a matching range to our selection so if we detect // this then we return the top-left selection point rather than the cursor point. @@ -1702,15 +1704,19 @@ extension Ghostty.SurfaceView: NSTextInputClient { // Free our text ghostty_surface_free_text(surface, &text) } else { - ghostty_surface_ime_point(surface, &x, &y) + ghostty_surface_ime_point(surface, &x, &y, &width, &height) } } else { - ghostty_surface_ime_point(surface, &x, &y) + ghostty_surface_ime_point(surface, &x, &y, &width, &height) } // Ghostty coordinates are in top-left (0, 0) so we have to convert to // bottom-left since that is what UIKit expects - let viewRect = NSMakeRect(x, frame.size.height - y, cellSize.width, cellSize.height) + let viewRect = NSMakeRect( + x, + frame.size.height - y, + max(width, cellSize.width), + max(height, cellSize.height)) // Convert the point to the window coordinates let winRect = self.convert(viewRect, to: nil) diff --git a/src/Surface.zig b/src/Surface.zig index 330d25102..f7a961b62 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1730,6 +1730,7 @@ pub fn pwd( pub fn imePoint(self: *const Surface) apprt.IMEPos { self.renderer_state.mutex.lock(); const cursor = self.renderer_state.terminal.screen.cursor; + const preedit_width: usize = if (self.renderer_state.preedit) |preedit| preedit.width() else 0; self.renderer_state.mutex.unlock(); // TODO: need to handle when scrolling and the cursor is not @@ -1764,7 +1765,38 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { break :y y; }; - return .{ .x = x, .y = y }; + // Our height for now is always just the cell height because our preedit + // rendering only renders in a single line. + const height: f64 = height: { + var height: f64 = @floatFromInt(self.size.cell.height); + height /= content_scale.y; + break :height height; + }; + const width: f64 = width: { + var width: f64 = @floatFromInt(preedit_width * self.size.cell.width); + + // Our max width is the remaining screen width after the cursor. + // We don't have to deal with wrapping because the preedit doesn't + // wrap right now. + const screen_width: f64 = @floatFromInt(self.size.terminal().width); + const x_offset: f64 = @floatFromInt((cursor.x + 1) * self.size.cell.width); + const max = screen_width - x_offset; + width = @min(width, max); + + // Note: we don't apply content scale here because it looks like + // for some reason in macOS its already scaled. I'm not sure why + // that is so I'm going to just leave this comment here so its known + // that I left this out on purpose pending more investigation. + + break :width width; + }; + + return .{ + .x = x, + .y = y, + .width = width, + .height = height, + }; } fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) !void { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index e4961ac49..5f43e1659 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1822,10 +1822,18 @@ pub const CAPI = struct { surface.mousePressureCallback(stage, pressure); } - export fn ghostty_surface_ime_point(surface: *Surface, x: *f64, y: *f64) void { + export fn ghostty_surface_ime_point( + surface: *Surface, + x: *f64, + y: *f64, + width: *f64, + height: *f64, + ) void { const pos = surface.core_surface.imePoint(); x.* = pos.x; y.* = pos.y; + width.* = pos.width; + height.* = pos.height; } /// Request that the surface become closed. This will go through the diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index c9948f3ee..b9e93e9ba 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -24,6 +24,8 @@ pub const CursorPos = struct { pub const IMEPos = struct { x: f64, y: f64, + width: f64, + height: f64, }; /// The clipboard type. From ac104a3dfcaba9092e0d1b046caabddf6c247236 Mon Sep 17 00:00:00 2001 From: Jesse Miller Date: Tue, 2 Sep 2025 14:14:06 -0600 Subject: [PATCH 10/42] zig fmt --- src/terminal/style.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 25ad5b59f..092b25856 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -86,9 +86,9 @@ pub const Style = struct { /// True if the style is equal to another style. pub fn eql(self: Style, other: Style) bool { return @as(u16, @bitCast(self.flags)) == @as(u16, @bitCast(other.flags)) and - std.meta.eql(self.fg_color, other.fg_color) and - std.meta.eql(self.bg_color, other.bg_color) and - std.meta.eql(self.underline_color, other.underline_color); + std.meta.eql(self.fg_color, other.fg_color) and + std.meta.eql(self.bg_color, other.bg_color) and + std.meta.eql(self.underline_color, other.underline_color); } /// Returns the bg color for a cell with this style given the cell From 4614e5fdad4e7bcff941d65800f642d6cf9ff9ab Mon Sep 17 00:00:00 2001 From: Jesse Miller Date: Tue, 2 Sep 2025 14:58:21 -0600 Subject: [PATCH 11/42] Zig 0.14+ can directly compare packed structs. --- src/terminal/style.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 092b25856..d0e100298 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -85,7 +85,7 @@ pub const Style = struct { /// True if the style is equal to another style. pub fn eql(self: Style, other: Style) bool { - return @as(u16, @bitCast(self.flags)) == @as(u16, @bitCast(other.flags)) and + return self.flags == other.flags and std.meta.eql(self.fg_color, other.fg_color) and std.meta.eql(self.bg_color, other.bg_color) and std.meta.eql(self.underline_color, other.underline_color); From cf0390bab57abd807bf98d0822737bcd76573b93 Mon Sep 17 00:00:00 2001 From: Jesse Miller Date: Tue, 2 Sep 2025 15:14:42 -0600 Subject: [PATCH 12/42] Use comptime for eql() to ensure Style struct coverage. --- src/terminal/style.zig | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/terminal/style.zig b/src/terminal/style.zig index d0e100298..4f51cbc71 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -84,11 +84,23 @@ pub const Style = struct { } /// True if the style is equal to another style. + /// For performance do direct comparisons first. pub fn eql(self: Style, other: Style) bool { - return self.flags == other.flags and - std.meta.eql(self.fg_color, other.fg_color) and - std.meta.eql(self.bg_color, other.bg_color) and - std.meta.eql(self.underline_color, other.underline_color); + inline for (comptime std.meta.fields(Style)) |field| { + if (comptime std.meta.hasUniqueRepresentation(field.type)) { + if (@field(self, field.name) != @field(other, field.name)) { + return false; + } + } + } + inline for (comptime std.meta.fields(Style)) |field| { + if (comptime !std.meta.hasUniqueRepresentation(field.type)) { + if (!std.meta.eql(@field(self, field.name), @field(other, field.name))) { + return false; + } + } + } + return true; } /// Returns the bg color for a cell with this style given the cell From 90c0fc259042385e0e6e86f2549e134dc9e0ec49 Mon Sep 17 00:00:00 2001 From: Toufiq Shishir Date: Sat, 30 Aug 2025 21:57:49 +0600 Subject: [PATCH 13/42] feat: add `selection-clear-on-copy` configuration --- src/Surface.zig | 13 +++++++++++++ src/config/Config.zig | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index f7a961b62..bfadb3be8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -258,6 +258,7 @@ const DerivedConfig = struct { mouse_shift_capture: configpkg.MouseShiftCapture, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, macos_option_as_alt: ?configpkg.OptionAsAlt, + selection_clear_on_copy: bool, selection_clear_on_typing: bool, vt_kam_allowed: bool, wait_after_command: bool, @@ -327,6 +328,7 @@ const DerivedConfig = struct { .mouse_shift_capture = config.@"mouse-shift-capture", .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen", .macos_option_as_alt = config.@"macos-option-as-alt", + .selection_clear_on_copy = config.@"selection-clear-on-copy", .selection_clear_on_typing = config.@"selection-clear-on-typing", .vt_kam_allowed = config.@"vt-kam-allowed", .wait_after_command = config.@"wait-after-command", @@ -4544,6 +4546,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool return true; }; + // Clear the selection if configured to do so. + if (self.config.selection_clear_on_copy) { + if (self.setSelection(null)) { + self.queueRender() catch |err| { + log.warn("failed to queue render after clear selection err={}", .{err}); + }; + } else |err| { + log.warn("failed to clear selection after copy err={}", .{err}); + } + } + return true; } diff --git a/src/config/Config.zig b/src/config/Config.zig index 384a4f006..019e57247 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -654,6 +654,18 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// Available since: 1.2.0 @"selection-clear-on-typing": bool = true, +/// Whether to clear selected text after copying. This defaults to `false`. +/// +/// When set to `true`, the selection will be automatically cleared after +/// any copy operation that invokes the `copy_to_clipboard` keyboard binding. +/// Importantly, this will not clear the selection if the copy operation +/// was invoked via `copy-on-select`. +/// +/// When set to `false`, the selection remains visible after copying, allowing +/// to see what was copied and potentially perform additional operations +/// on the same selection. +@"selection-clear-on-copy": bool = false, + /// The minimum contrast ratio between the foreground and background colors. /// The contrast ratio is a value between 1 and 21. A value of 1 allows for no /// contrast (e.g. black on black). This value is the contrast ratio as defined From 52f5ab1a36c34f7d26502bcadad5c3f2d1f2f924 Mon Sep 17 00:00:00 2001 From: Ivan Bastrakov Date: Wed, 3 Sep 2025 01:57:25 +0300 Subject: [PATCH 14/42] i18n: update Russian translation --- po/ru_RU.UTF-8.po | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/po/ru_RU.UTF-8.po b/po/ru_RU.UTF-8.po index 272b27dc2..aa036b38c 100644 --- a/po/ru_RU.UTF-8.po +++ b/po/ru_RU.UTF-8.po @@ -3,15 +3,16 @@ # Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors # This file is distributed under the same license as the com.mitchellh.ghostty package. # blackzeshi , 2025. -# +# Ivan Bastrakov , 2025. + msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-07-22 17:18+0000\n" -"PO-Revision-Date: 2025-03-24 00:01+0500\n" -"Last-Translator: blackzeshi \n" "Language-Team: Russian \n" +"PO-Revision-Date: 2025-09-03 01:50+0300\n" +"Last-Translator: Ivan Bastrakov \n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -89,7 +90,7 @@ msgstr "Сплит вправо" #: src/apprt/gtk/ui/1.5/command-palette.blp:16 msgid "Execute a command…" -msgstr "" +msgstr "Выполнить команду…" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 @@ -162,7 +163,7 @@ msgstr "Открыть конфигурационный файл" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 msgid "Command Palette" -msgstr "" +msgstr "Палитра команд" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" @@ -210,12 +211,12 @@ msgstr "Разрешить" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 msgid "Remember choice for this split" -msgstr "" +msgstr "Запомнить выбор для этого сплита" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 msgid "Reload configuration to show this prompt again" -msgstr "" +msgstr "Перезагрузите конфигурацию, чтобы снова увидеть это сообщение" #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 #: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 @@ -223,7 +224,8 @@ msgid "" "An application is attempting to write to the clipboard. The current " "clipboard contents are shown below." msgstr "" -"Приложение пытается записать данные в буфер обмена. Эти данные показаны ниже." +"Приложение пытается записать данные в буфер обмена. Текущее содержимое " +"буфера обмена показано ниже." #: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 src/apprt/gtk/ui/1.2/ccw-paste.blp:6 msgid "Warning: Potentially Unsafe Paste" @@ -279,15 +281,15 @@ msgstr "Скопировано в буфер обмена" #: src/apprt/gtk/Surface.zig:1268 msgid "Cleared clipboard" -msgstr "" +msgstr "Буфер обмена очищен" #: src/apprt/gtk/Surface.zig:2525 msgid "Command succeeded" -msgstr "" +msgstr "Команда выполнена успешно" #: src/apprt/gtk/Surface.zig:2527 msgid "Command failed" -msgstr "" +msgstr "Команда завершилась с ошибкой" #: src/apprt/gtk/Window.zig:216 msgid "Main Menu" @@ -299,7 +301,7 @@ msgstr "Просмотреть открытые вкладки" #: src/apprt/gtk/Window.zig:266 msgid "New Split" -msgstr "" +msgstr "Новый сплит" #: src/apprt/gtk/Window.zig:329 msgid "" From f016b79f22e45a151f9519898571f98f2d773706 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 2 Sep 2025 20:40:23 -0700 Subject: [PATCH 15/42] apprt/gtk-ng: must quit scenarios should quit immediately Fixes #8495 We were incorrectly calling graceful quit under must quit scenarios. This would do things like confirm quit by inspecting for running processes. However, must quit scenarios (namely when all windows are destroyed) should quit immediately without any checks because the dispose process takes more event loop ticks to fully finish. --- src/apprt/gtk-ng/class/application.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index f0fda2680..22fe3f618 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -476,7 +476,14 @@ pub const Application = extern struct { break :q false; }; - if (must_quit) self.quit(); + if (must_quit) { + // All must quit scenarios do not need confirmation. + // Furthermore, must quit scenarios may result in a situation + // where its unsafe to even access the app/surface memory + // since its in the process of being freed. We must simply + // begin our exit immediately. + self.quitNow(); + } } } From 5eb69b405dbc1d8fe441e8dbb38a2947cd287d7e Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Tue, 2 Sep 2025 17:11:34 +0800 Subject: [PATCH 16/42] gtk-ng/wayland: allow more quick terminal configs --- src/apprt/gtk-ng/winproto/wayland.zig | 23 +++++++++----- src/config/Config.zig | 46 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk-ng/winproto/wayland.zig b/src/apprt/gtk-ng/winproto/wayland.zig index 0ab7c24f0..5837e3e5e 100644 --- a/src/apprt/gtk-ng/winproto/wayland.zig +++ b/src/apprt/gtk-ng/winproto/wayland.zig @@ -114,11 +114,13 @@ pub const App = struct { return false; } - if (self.context.xdg_wm_dialog_present and layer_shell.getLibraryVersion().order(.{ - .major = 1, - .minor = 0, - .patch = 4, - }) == .lt) { + if (self.context.xdg_wm_dialog_present and + layer_shell.getLibraryVersion().order(.{ + .major = 1, + .minor = 0, + .patch = 4, + }) == .lt) + { log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{}); return false; } @@ -128,10 +130,7 @@ pub const App = struct { pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void { const window = apprt_window.as(gtk.Window); - layer_shell.initForWindow(window); - layer_shell.setLayer(window, .top); - layer_shell.setNamespace(window, "ghostty-quick-terminal"); } fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type { @@ -411,6 +410,14 @@ pub const Window = struct { else return; + layer_shell.setLayer(window, switch (config.@"gtk-quick-terminal-layer") { + .overlay => .overlay, + .top => .top, + .bottom => .bottom, + .background => .background, + }); + layer_shell.setNamespace(window, config.@"gtk-quick-terminal-namespace"); + layer_shell.setKeyboardMode( window, switch (config.@"quick-terminal-keyboard-interactivity") { diff --git a/src/config/Config.zig b/src/config/Config.zig index 5ffd01871..3bff3c0f1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2132,6 +2132,44 @@ keybind: Keybinds = .{}, /// terminal would be half a screen tall, and 500 pixels wide. @"quick-terminal-size": QuickTerminalSize = .{}, +/// The layer of the quick terminal window. The higher the layer, +/// the more windows the quick terminal may conceal. +/// +/// Valid values are: +/// +/// * `overlay` +/// +/// The quick terminal appears in front of all windows. +/// +/// * `top` (default) +/// +/// The quick terminal appears in front of normal windows but behind +/// fullscreen overlays like lock screens. +/// +/// * `bottom` +/// +/// The quick terminal appears behind normal windows but in front of +/// wallpapers and other windows in the background layer. +/// +/// * `background` +/// +/// The quick terminal appears behind all windows. +/// +/// GTK Wayland only. +/// +/// Available since: 1.2.0 +@"gtk-quick-terminal-layer": QuickTerminalLayer = .top, +/// The namespace for the quick terminal window. +/// +/// This is an identifier that is used by the Wayland compositor and/or +/// scripts to determine the type of layer surfaces and to possibly apply +/// unique effects. +/// +/// GTK Wayland only. +/// +/// Available since: 1.2.0 +@"gtk-quick-terminal-namespace": [:0]const u8 = "ghostty-quick-terminal", + /// The screen where the quick terminal should show up. /// /// Valid values are: @@ -7165,6 +7203,14 @@ pub const QuickTerminalPosition = enum { center, }; +/// See quick-terminal-layer +pub const QuickTerminalLayer = enum { + overlay, + top, + bottom, + background, +}; + /// See quick-terminal-size pub const QuickTerminalSize = struct { primary: ?Size = null, From 1dee9e7cb255215f6a545f3ff6d7ad88baa059ae Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 3 Sep 2025 09:32:34 -0500 Subject: [PATCH 17/42] config: make default copy_to_clipboard binds performable Make the default keybind for copy_to_clipboard performable. This allows TUIs to receive events when keybinds aren't used, for example cmd+c on macos for copy, or ctrl+shift+c elsewhere. Disclosure: This commit was made with the assistance of AI (ampcode.com) --- src/config/Config.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index d324059b3..1ec0bafce 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5525,10 +5525,11 @@ pub const Keybinds = struct { else .{ .ctrl = true, .shift = true }; - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'c' }, .mods = mods }, .{ .copy_to_clipboard = {} }, + .{ .performable = true }, ); try self.set.put( alloc, From 508e36bc033fc4d8e6b68eb3914dc2c17661a54a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Sep 2025 08:49:47 -0700 Subject: [PATCH 18/42] macOS: split tree zoom state should encode as path, not full node Fixes #8356 Zoom state should encode as a path so that it can be mapped to a reference to the node in `root`. Previously, we were encoding a full node which was instantiating an extra terminal on restore. --- macos/Sources/Features/Splits/SplitTree.swift | 228 +++++++++++------- .../Terminal/TerminalRestorable.swift | 2 +- 2 files changed, 142 insertions(+), 88 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index b353f6cbe..53adf1dc2 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -1,7 +1,7 @@ import AppKit /// SplitTree represents a tree of views that can be divided. -struct SplitTree: Codable { +struct SplitTree { /// The root of the tree. This can be nil to indicate the tree is empty. let root: Node? @@ -29,12 +29,12 @@ struct SplitTree: Codable { } /// The path to a specific node in the tree. - struct Path { + struct Path: Codable { let path: [Component] var isEmpty: Bool { path.isEmpty } - enum Component { + enum Component: Codable { case left case right } @@ -53,7 +53,7 @@ struct SplitTree: Codable { let node: Node let bounds: CGRect } - + /// Direction for spatial navigation within the split tree. enum Direction { case left @@ -132,39 +132,39 @@ extension SplitTree { /// the sibling node takes the place of the parent split. func remove(_ target: Node) -> Self { guard let root else { return self } - + // If we're removing the root itself, return an empty tree if root == target { return .init(root: nil, zoomed: nil) } - + // Otherwise, try to remove from the tree let newRoot = root.remove(target) - + // Update zoomed if it was the removed node let newZoomed = (zoomed == target) ? nil : zoomed - + return .init(root: newRoot, zoomed: newZoomed) } /// Replace a node in the tree with a new node. func replace(node: Node, with newNode: Node) throws -> Self { guard let root else { throw SplitError.viewNotFound } - + // Get the path to the node we want to replace guard let path = root.path(to: node) else { throw SplitError.viewNotFound } - + // Replace the node let newRoot = try root.replaceNode(at: path, with: newNode) - + // Update zoomed if it was the replaced node let newZoomed = (zoomed == node) ? newNode : zoomed - + return .init(root: newRoot, zoomed: newZoomed) } - + /// Find the next view to focus based on the current focused node and direction func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? { guard let root else { return nil } @@ -230,13 +230,13 @@ extension SplitTree { let newRoot = root.equalize() return .init(root: newRoot, zoomed: zoomed) } - + /// Resize a node in the tree by the given pixel amount in the specified direction. - /// + /// /// This method adjusts the split ratios of the tree to accommodate the requested resize /// operation. For up/down resizing, it finds the nearest parent vertical split and adjusts /// its ratio. For left/right resizing, it finds the nearest parent horizontal split. - /// The bounds parameter is used to construct the spatial tree representation which is + /// The bounds parameter is used to construct the spatial tree representation which is /// needed to calculate the current pixel dimensions. /// /// This will always reset the zoomed state. @@ -250,22 +250,22 @@ extension SplitTree { /// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self { guard let root else { throw SplitError.viewNotFound } - + // Find the path to the target node guard let path = root.path(to: node) else { throw SplitError.viewNotFound } - + // Determine which type of split we need to find based on resize direction let targetSplitDirection: Direction = switch direction { case .up, .down: .vertical case .left, .right: .horizontal } - + // Find the nearest parent split of the correct type by walking up the path var splitPath: Path? var splitNode: Node? - + for i in stride(from: path.path.count - 1, through: 0, by: -1) { let parentPath = Path(path: Array(path.path.prefix(i))) if let parent = root.node(at: parentPath), case .split(let split) = parent { @@ -276,29 +276,29 @@ extension SplitTree { } } } - - guard let splitPath = splitPath, + + guard let splitPath = splitPath, let splitNode = splitNode, case .split(let split) = splitNode else { throw SplitError.viewNotFound } - + // Get current spatial representation to calculate pixel dimensions let spatial = root.spatial(within: bounds.size) guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else { throw SplitError.viewNotFound } - + // Calculate the new ratio based on pixel change let pixelOffset = Double(pixels) let newRatio: Double - + switch (split.direction, direction) { case (.horizontal, .left): // Moving left boundary: decrease left side newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width))) case (.horizontal, .right): - // Moving right boundary: increase left side + // Moving right boundary: increase left side newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width))) case (.vertical, .up): // Moving top boundary: decrease top side @@ -310,7 +310,7 @@ extension SplitTree { // Direction doesn't match split type - shouldn't happen due to earlier logic throw SplitError.viewNotFound } - + // Create new split with adjusted ratio let newSplit = Node.Split( direction: split.direction, @@ -318,12 +318,12 @@ extension SplitTree { left: split.left, right: split.right ) - + // Replace the split node with the new one let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit)) return .init(root: newRoot, zoomed: nil) } - + /// Returns the total bounds of the split hierarchy using NSView bounds. /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. /// Also ignores any possible padding between views. @@ -334,6 +334,60 @@ extension SplitTree { } } +// MARK: SplitTree Codable + +fileprivate enum CodingKeys: String, CodingKey { + case version + case root + case zoomed + + static let currentVersion: Int = 1 +} + +extension SplitTree: Codable { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Check version + let version = try container.decode(Int.self, forKey: .version) + guard version == CodingKeys.currentVersion else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unsupported SplitTree version: \(version)" + ) + ) + } + + // Decode root + self.root = try container.decodeIfPresent(Node.self, forKey: .root) + + // Zoomed is encoded as its path. Get the path and then find it. + if let zoomedPath = try container.decodeIfPresent(Path.self, forKey: .zoomed), + let root = self.root { + self.zoomed = root.node(at: zoomedPath) + } else { + self.zoomed = nil + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + // Encode version + try container.encode(CodingKeys.currentVersion, forKey: .version) + + // Encode root + try container.encodeIfPresent(root, forKey: .root) + + // Zoomed is encoded as its path since its a reference type. This lets us + // map it on decode back to the correct node in root. + if let zoomed, let path = root?.path(to: zoomed) { + try container.encode(path, forKey: .zoomed) + } + } +} + // MARK: SplitTree.Node extension SplitTree.Node { @@ -396,20 +450,20 @@ extension SplitTree.Node { return search(self) ? Path(path: components) : nil } - + /// Returns the node at the given path from this node as root. func node(at path: Path) -> Node? { if path.isEmpty { return self } - + guard case .split(let split) = self else { return nil } - + let component = path.path[0] let remainingPath = Path(path: Array(path.path.dropFirst())) - + switch component { case .left: return split.left.node(at: remainingPath) @@ -521,12 +575,12 @@ extension SplitTree.Node { if self == target { return nil } - + switch self { case .leaf: // A leaf that isn't the target stays as is return self - + case .split(let split): // Neither child is directly the target, so we need to recursively // try to remove from both children @@ -543,7 +597,7 @@ extension SplitTree.Node { } else if newRight == nil { return newLeft } - + // Both children still exist after removal return .split(.init( direction: split.direction, @@ -562,7 +616,7 @@ extension SplitTree.Node { case .leaf: // Leaf nodes don't have a ratio to resize return self - + case .split(let split): // Create a new split with the updated ratio return .split(.init( @@ -573,7 +627,7 @@ extension SplitTree.Node { )) } } - + /// Get the leftmost leaf in this subtree func leftmostLeaf() -> ViewType { switch self { @@ -583,7 +637,7 @@ extension SplitTree.Node { return split.left.leftmostLeaf() } } - + /// Get the rightmost leaf in this subtree func rightmostLeaf() -> ViewType { switch self { @@ -593,7 +647,7 @@ extension SplitTree.Node { return split.right.rightmostLeaf() } } - + /// Equalize this node and all its children, returning a new node with splits /// adjusted so that each split's ratio is based on the relative weight /// (number of leaves) of its children. @@ -601,14 +655,14 @@ extension SplitTree.Node { let (equalizedNode, _) = equalizeWithWeight() return equalizedNode } - + /// Internal helper that equalizes and returns both the node and its weight. private func equalizeWithWeight() -> (node: Node, weight: Int) { switch self { case .leaf: // A leaf has weight 1 and doesn't change return (self, 1) - + case .split(let split): // Calculate weights based on split direction let leftWeight = split.left.weightForDirection(split.direction) @@ -629,7 +683,7 @@ extension SplitTree.Node { left: leftNode, right: rightNode ) - + return (.split(newSplit), totalWeight) } } @@ -656,12 +710,12 @@ extension SplitTree.Node { switch self { case .leaf(let view): return [(view, bounds)] - + case .split(let split): // Calculate bounds for left and right based on split direction and ratio let leftBounds: CGRect let rightBounds: CGRect - + switch split.direction { case .horizontal: // Split horizontally: left | right @@ -678,7 +732,7 @@ extension SplitTree.Node { width: bounds.width * (1 - split.ratio), height: bounds.height ) - + case .vertical: // Split vertically: top / bottom // Note: In our normalized coordinate system, Y increases upward @@ -696,13 +750,13 @@ extension SplitTree.Node { height: bounds.height * split.ratio ) } - + // Recursively calculate bounds for children return split.left.calculateViewBounds(in: leftBounds) + split.right.calculateViewBounds(in: rightBounds) } } - + /// Returns the total bounds of this subtree using NSView bounds. /// Ignores x/y coordinates and assumes views are laid out in a perfect grid. /// - Returns: The total width and height needed to contain all views in this subtree @@ -710,11 +764,11 @@ extension SplitTree.Node { switch self { case .leaf(let view): return view.bounds.size - + case .split(let split): let leftBounds = split.left.viewBounds() let rightBounds = split.right.viewBounds() - + switch split.direction { case .horizontal: // Horizontal split: width is sum, height is max @@ -722,7 +776,7 @@ extension SplitTree.Node { width: leftBounds.width + rightBounds.width, height: Swift.max(leftBounds.height, rightBounds.height) ) - + case .vertical: // Vertical split: height is sum, width is max return CGSize( @@ -760,7 +814,7 @@ extension SplitTree.Node { /// // +--------+----+ /// // | C | D | /// // +--------+----+ - /// // + /// // /// // The spatial representation would have: /// // - Total dimensions: (width: 2, height: 2) /// // - Node bounds based on actual split ratios @@ -805,7 +859,7 @@ extension SplitTree.Node { /// Example: /// ``` /// // Single leaf: (1, 1) - /// // Horizontal split with 2 leaves: (2, 1) + /// // Horizontal split with 2 leaves: (2, 1) /// // Vertical split with 2 leaves: (1, 2) /// // Complex layout with both: (2, 2) or larger /// ``` @@ -846,7 +900,7 @@ extension SplitTree.Node { /// /// The calculation process: /// 1. **Leaf nodes**: Create a single slot with the provided bounds - /// 2. **Split nodes**: + /// 2. **Split nodes**: /// - Divide the bounds according to the split ratio and direction /// - Create a slot for the split node itself /// - Recursively calculate slots for both children @@ -926,7 +980,7 @@ extension SplitTree.Spatial { /// /// This method finds all slots positioned in the given direction from the reference node: /// - **Left**: Slots with bounds to the left of the reference node - /// - **Right**: Slots with bounds to the right of the reference node + /// - **Right**: Slots with bounds to the right of the reference node /// - **Up**: Slots with bounds above the reference node (Y=0 is top) /// - **Down**: Slots with bounds below the reference node /// @@ -955,41 +1009,41 @@ extension SplitTree.Spatial { let dy = rect2.minY - rect1.minY return sqrt(dx * dx + dy * dy) } - + let result = switch direction { case .left: // Slots to the left: their right edge is at or left of reference's left edge slots.filter { - $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX + $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } - + case .right: // Slots to the right: their left edge is at or right of reference's right edge slots.filter { - $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX + $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } - + case .up: // Slots above: their bottom edge is at or above reference's top edge slots.filter { - $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY + $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } - + case .down: // Slots below: their top edge is at or below reference's bottom edge slots.filter { - $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY + $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY }.sorted { distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds) } } - + return result } @@ -1008,14 +1062,14 @@ extension SplitTree.Spatial { func doesBorder(side: Direction, from node: SplitTree.Node) -> Bool { // Find the slot for this node guard let slot = slots.first(where: { $0.node == node }) else { return false } - + // Calculate the overall bounds of all slots let overallBounds = slots.reduce(CGRect.null) { result, slot in result.union(slot.bounds) } - + return switch side { - case .up: + case .up: slot.bounds.minY == overallBounds.minY case .down: slot.bounds.maxY == overallBounds.maxY @@ -1052,10 +1106,10 @@ extension SplitTree.Node { case view case split } - + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - + if container.contains(.view) { let view = try container.decode(ViewType.self, forKey: .view) self = .leaf(view: view) @@ -1071,14 +1125,14 @@ extension SplitTree.Node { ) } } - + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - + switch self { case .leaf(let view): try container.encode(view, forKey: .view) - + case .split(let split): try container.encode(split, forKey: .split) } @@ -1093,7 +1147,7 @@ extension SplitTree.Node { switch self { case .leaf(let view): return [view] - + case .split(let split): return split.left.leaves() + split.right.leaves() } @@ -1145,7 +1199,7 @@ extension SplitTree.Node { var structuralIdentity: StructuralIdentity { StructuralIdentity(self) } - + /// A hashable representation of a node that captures its structural identity. /// /// This type provides a way to track changes to a node's structure in SwiftUI @@ -1159,20 +1213,20 @@ extension SplitTree.Node { /// for unchanged portions of the tree. struct StructuralIdentity: Hashable { private let node: SplitTree.Node - + init(_ node: SplitTree.Node) { self.node = node } - + static func == (lhs: Self, rhs: Self) -> Bool { lhs.node.isStructurallyEqual(to: rhs.node) } - + func hash(into hasher: inout Hasher) { node.hashStructure(into: &hasher) } } - + /// Checks if this node is structurally equal to another node. /// Two nodes are structurally equal if they have the same tree structure /// and the same views (by identity) in the same positions. @@ -1181,26 +1235,26 @@ extension SplitTree.Node { case let (.leaf(view1), .leaf(view2)): // Views must be the same instance return view1 === view2 - + case let (.split(split1), .split(split2)): // Splits must have same direction and structurally equal children // Note: We intentionally don't compare ratios as they may change slightly return split1.direction == split2.direction && split1.left.isStructurallyEqual(to: split2.left) && split1.right.isStructurallyEqual(to: split2.right) - + default: // Different node types return false } } - + /// Hash keys for structural identity private enum HashKey: UInt8 { case leaf = 0 case split = 1 } - + /// Hashes the structural identity of this node. /// Includes the tree structure and view identities in the hash. fileprivate func hashStructure(into hasher: inout Hasher) { @@ -1208,7 +1262,7 @@ extension SplitTree.Node { case .leaf(let view): hasher.combine(HashKey.leaf) hasher.combine(ObjectIdentifier(view)) - + case .split(let split): hasher.combine(HashKey.split) hasher.combine(split.direction) @@ -1247,17 +1301,17 @@ extension SplitTree { struct StructuralIdentity: Hashable { private let root: Node? private let zoomed: Node? - + init(_ tree: SplitTree) { self.root = tree.root self.zoomed = tree.zoomed } - + static func == (lhs: Self, rhs: Self) -> Bool { areNodesStructurallyEqual(lhs.root, rhs.root) && areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed) } - + func hash(into hasher: inout Hasher) { hasher.combine(0) // Tree marker if let root = root { @@ -1268,7 +1322,7 @@ extension SplitTree { zoomed.hashStructure(into: &hasher) } } - + /// Helper to compare optional nodes for structural equality private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool { switch (lhs, rhs) { diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 8baa76246..7bad563ab 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,7 +4,7 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 4 + static let version: Int = 5 let focusedSurface: String? let surfaceTree: SplitTree From b90c72aea6838f9a70d79f95de89dcd40d21e522 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Sep 2025 09:08:51 -0700 Subject: [PATCH 19/42] Initial AGENTS.md I've been using agents a lot more with Ghostty and so are contributors. Ghostty welcomes AI contributions (but they must be disclosed as AI assisted), and this AGENTS.md will help everyone using agents work better with the codebase. This AGENTS.md has thus far been working for me very successfully, despite being simple. I suspect we'll add to it as time goes on but I also want to avoid making it too large and polluting the context. --- AGENTS.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..00faaf81c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Agent Development Guide + +A file for [guiding coding agents](https://agents.md/). + +## Commands + +- **Build:** `zig build` +- **Test (Zig):** `zig build test` +- **Test filter (Zig)**: `zig build test -Dtest-filter=` +- **Formatting (Zig)**: `zig fmt .` +- **Formatting (other)**: `prettier -w .` + +## Directory Structure + +- Shared Zig core: `src/` +- C API: `include/ghostty.h` +- macOS app: `macos/` +- GTK (Linux and FreeBSD) app: `src/apprt/gtk-ng` + +## macOS App + +- Do not use `xcodebuild` +- Use `zig build` to build the macOS app and any shared Zig code From fe3dab9467b1b6f5de70afea1e22c374463bb477 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Sep 2025 07:27:12 -0700 Subject: [PATCH 20/42] macOS: SurfaceView should implement Identifiable This has no meaningful functionality yet, it was one of the paths I was looking at for #8505 but didn't pursue further. But I still think that this makes more sense in general for the macOS app and will likely be more useful later. --- macos/Sources/App/macOS/AppDelegate.swift | 2 +- .../App Intents/Entities/TerminalEntity.swift | 6 ++--- macos/Sources/Features/Splits/SplitTree.swift | 26 ++++++++++++++++++- .../Terminal/TerminalController.swift | 4 +-- .../Terminal/TerminalRestorable.swift | 4 +-- .../Sources/Ghostty/SurfaceView_AppKit.swift | 12 +++++---- 6 files changed, 40 insertions(+), 14 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 310a46d6c..f8cf95de2 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -937,7 +937,7 @@ class AppDelegate: NSObject, func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { for c in TerminalController.all { for view in c.surfaceTree { - if view.uuid == uuid { + if view.id == uuid { return view } } diff --git a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift index 974f1b07f..e805466a2 100644 --- a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift @@ -34,7 +34,7 @@ struct TerminalEntity: AppEntity { /// Returns the view associated with this entity. This may no longer exist. @MainActor var surfaceView: Ghostty.SurfaceView? { - Self.defaultQuery.all.first { $0.uuid == self.id } + Self.defaultQuery.all.first { $0.id == self.id } } @MainActor @@ -46,7 +46,7 @@ struct TerminalEntity: AppEntity { @MainActor init(_ view: Ghostty.SurfaceView) { - self.id = view.uuid + self.id = view.id self.title = view.title self.workingDirectory = view.pwd if let nsImage = ImageRenderer(content: view.screenshot()).nsImage { @@ -80,7 +80,7 @@ struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery { @MainActor func entities(for identifiers: [TerminalEntity.ID]) async throws -> [TerminalEntity] { return all.filter { - identifiers.contains($0.uuid) + identifiers.contains($0.id) }.map { TerminalEntity($0) } diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 53adf1dc2..23b597591 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -1,7 +1,7 @@ import AppKit /// SplitTree represents a tree of views that can be divided. -struct SplitTree { +struct SplitTree { /// The root of the tree. This can be nil to indicate the tree is empty. let root: Node? @@ -127,6 +127,13 @@ extension SplitTree { root: try root.insert(view: view, at: at, direction: direction), zoomed: nil) } + /// Find a node containing a view with the specified ID. + /// - Parameter id: The ID of the view to find + /// - Returns: The node containing the view if found, nil otherwise + func find(id: ViewType.ID) -> Node? { + guard let root else { return nil } + return root.find(id: id) + } /// Remove a node from the tree. If the node being removed is part of a split, /// the sibling node takes the place of the parent split. @@ -396,6 +403,23 @@ extension SplitTree.Node { typealias SplitError = SplitTree.SplitError typealias Path = SplitTree.Path + /// Find a node containing a view with the specified ID. + /// - Parameter id: The ID of the view to find + /// - Returns: The node containing the view if found, nil otherwise + func find(id: ViewType.ID) -> Node? { + switch self { + case .leaf(let view): + return view.id == id ? self : nil + + case .split(let split): + if let found = split.left.find(id: id) { + return found + } + + return split.right.find(id: id) + } + } + /// Returns the node in the tree that contains the given view. func node(view: ViewType) -> Node? { switch (self) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index cadbb40e0..bdf3abeb6 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -860,7 +860,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Restore focus to the previously focused surface if let focusedUUID = undoState.focusedSurface, - let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) { + let focusTarget = surfaceTree.first(where: { $0.id == focusedUUID }) { DispatchQueue.main.async { Ghostty.moveFocus(to: focusTarget, from: nil) } @@ -875,7 +875,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return .init( frame: window.frame, surfaceTree: surfaceTree, - focusedSurface: focusedSurface?.uuid, + focusedSurface: focusedSurface?.id, tabIndex: window.tabGroup?.windows.firstIndex(of: window), tabGroup: window.tabGroup) } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 7bad563ab..1e640967e 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -10,7 +10,7 @@ class TerminalRestorableState: Codable { let surfaceTree: SplitTree init(from controller: TerminalController) { - self.focusedSurface = controller.focusedSurface?.uuid.uuidString + self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree } @@ -96,7 +96,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { if let focusedStr = state.focusedSurface { var foundView: Ghostty.SurfaceView? for view in c.surfaceTree { - if view.uuid.uuidString == focusedStr { + if view.id.uuidString == focusedStr { foundView = view break } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index eef4bccb3..1c5c8eb6a 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -6,9 +6,11 @@ import GhosttyKit extension Ghostty { /// The NSView implementation for a terminal surface. - class SurfaceView: OSView, ObservableObject, Codable { + class SurfaceView: OSView, ObservableObject, Codable, Identifiable { + typealias ID = UUID + /// Unique ID per surface - let uuid: UUID + let id: UUID // The current title of the surface as defined by the pty. This can be // changed with escape codes. This is public because the callbacks go @@ -180,7 +182,7 @@ extension Ghostty { init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { self.markedText = NSMutableAttributedString() - self.uuid = uuid ?? .init() + self.id = uuid ?? .init() // Our initial config always is our application wide config. if let appDelegate = NSApplication.shared.delegate as? AppDelegate { @@ -1468,7 +1470,7 @@ extension Ghostty { content.body = body content.sound = UNNotificationSound.default content.categoryIdentifier = Ghostty.userNotificationCategory - content.userInfo = ["surface": self.uuid.uuidString] + content.userInfo = ["surface": self.id.uuidString] let uuid = UUID().uuidString let request = UNNotificationRequest( @@ -1576,7 +1578,7 @@ extension Ghostty { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(pwd, forKey: .pwd) - try container.encode(uuid.uuidString, forKey: .uuid) + try container.encode(id.uuidString, forKey: .uuid) try container.encode(title, forKey: .title) try container.encode(titleFromTerminal != nil, forKey: .isUserSetTitle) } From c8243ffd99e0612e3f2111e8400aa500ec8707ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Sep 2025 09:54:57 -0700 Subject: [PATCH 21/42] macOS: prevent focus loss in hidden titlebar + non-native fullscreen When using hidden titlebar with non-native fullscreen, the window would lose focus after entering the first command. This occurred because: 1. Shell commands update the window title 2. Title changes trigger reapplyHiddenStyle() 3. reapplyHiddenStyle() re-adds .titled to the style mask 4. Style mask changes during fullscreen confuse AppKit, causing focus loss Fixed by adding a guard to skip titlebar restyling while fullscreen is active, using terminalController.fullscreenStyle.isFullscreen for proper detection of both native and non-native fullscreen modes. https://ampcode.com/threads/T-c4ef59cc-1232-4fa5-8f09-c65724ee84d3 --- .../Window Styles/HiddenTitlebarTerminalWindow.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift index 996506f0b..dc7dd7633 100644 --- a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -31,9 +31,16 @@ class HiddenTitlebarTerminalWindow: TerminalWindow { .closable, .miniaturizable, ] - + /// Apply the hidden titlebar style. private func reapplyHiddenStyle() { + // If our window is fullscreen then we don't reapply the hidden style because + // it can result in messing up non-native fullscreen. See: + // https://github.com/ghostty-org/ghostty/issues/8415 + if terminalController?.fullscreenStyle?.isFullscreen ?? false { + return + } + // Apply our style mask while preserving the .fullScreen option if styleMask.contains(.fullScreen) { styleMask = Self.hiddenStyleMask.union([.fullScreen]) From e67db2a01c1d9da055a7f42b23fa22b3426176e6 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 30 Aug 2025 16:19:59 -0500 Subject: [PATCH 22/42] gtk-ng: pull in latest zig-gobject changes --- 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 fb75252c8..072a5ca11 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -55,8 +55,8 @@ .gobject = .{ // https://github.com/jcollie/ghostty-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", - .hash = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM", + .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-30-39-1/ghostty-gobject-0.14.1-2025-08-30-39-1.tar.zst", + .hash = "gobject-0.3.0-Skun7HlFnQBaCs-ZxA9JLd_QV9wbvrnfoJDzPx96IDLc", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 1059338c2..814f0b36c 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -24,10 +24,10 @@ "url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz", "hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=" }, - "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM": { + "gobject-0.3.0-Skun7HlFnQBaCs-ZxA9JLd_QV9wbvrnfoJDzPx96IDLc": { "name": "gobject", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", - "hash": "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI=" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-30-39-1/ghostty-gobject-0.14.1-2025-08-30-39-1.tar.zst", + "hash": "sha256-e/HM6V8s0flOVMelia453vAs4jH4jy7QArPiIsS/k74=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { "name": "gtk4_layer_shell", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 1c3578da4..e5eda6473 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -123,11 +123,11 @@ in }; } { - name = "gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM"; + name = "gobject-0.3.0-Skun7HlFnQBaCs-ZxA9JLd_QV9wbvrnfoJDzPx96IDLc"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst"; - hash = "sha256-B0ziLzKud+kdKu5T1BTE9GMh8EPM/KhhhoNJlys5QPI="; + url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-30-39-1/ghostty-gobject-0.14.1-2025-08-30-39-1.tar.zst"; + hash = "sha256-e/HM6V8s0flOVMelia453vAs4jH4jy7QArPiIsS/k74="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index b6123dd2a..fc2fd64d9 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,7 +27,7 @@ https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d6 https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21aeb76c5a7dc99698401f4f6fefc.tar.gz https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst +https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-30-39-1/ghostty-gobject-0.14.1-2025-08-30-39-1.tar.zst https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6cdbc8501d48601302e32d6b53e9e7934bf354b4.tar.gz https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index ef7099102..a452d75c0 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,9 +31,9 @@ }, { "type": "archive", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-09-37-1/ghostty-gobject-0.14.1-2025-08-09-37-1.tar.zst", - "dest": "vendor/p/gobject-0.3.0-Skun7AngnABC2BPiaoobs6YSSzSgMuEIcjb2rYrRyaAM", - "sha256": "074ce22f32ae77e91d2aee53d414c4f46321f043ccfca861868349972b3940f2" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-30-39-1/ghostty-gobject-0.14.1-2025-08-30-39-1.tar.zst", + "dest": "vendor/p/gobject-0.3.0-Skun7HlFnQBaCs-ZxA9JLd_QV9wbvrnfoJDzPx96IDLc", + "sha256": "7bf1cce95f2cd1f94e54c7a589ae39def02ce231f88f2ed002b3e222c4bf93be" }, { "type": "archive", From 24647288519117a0841e4824f8663598810483bc Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 3 Sep 2025 18:01:40 -0600 Subject: [PATCH 23/42] font: constrain dingbats This was a regression, we were giving dingbats an extra cell of constraint width but not actually applying constraints to them. --- src/font/nerd_font_attributes.zig | 4 ++-- src/font/nerd_font_codegen.py | 4 ++-- src/renderer/generic.zig | 9 ++++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/font/nerd_font_attributes.zig b/src/font/nerd_font_attributes.zig index 55e5604c3..11902d310 100644 --- a/src/font/nerd_font_attributes.zig +++ b/src/font/nerd_font_attributes.zig @@ -7,7 +7,7 @@ const Constraint = @import("face.zig").RenderOptions.Constraint; /// Get the a constraints for the provided codepoint. -pub fn getConstraint(cp: u21) Constraint { +pub fn getConstraint(cp: u21) ?Constraint { return switch (cp) { 0x2500...0x259f, => .{ @@ -1060,6 +1060,6 @@ pub fn getConstraint(cp: u21) Constraint { .align_vertical = .center, .group_width = 1.3001222493887530, }, - else => .none, + else => null, }; } diff --git a/src/font/nerd_font_codegen.py b/src/font/nerd_font_codegen.py index e314bbd02..a103a30ac 100644 --- a/src/font/nerd_font_codegen.py +++ b/src/font/nerd_font_codegen.py @@ -351,8 +351,8 @@ if __name__ == "__main__": const Constraint = @import("face.zig").RenderOptions.Constraint; /// Get the a constraints for the provided codepoint. -pub fn getConstraint(cp: u21) Constraint { +pub fn getConstraint(cp: u21) ?Constraint { return switch (cp) { """) f.write(generate_zig_switch_arms(patch_set, nerd_font)) - f.write("\n else => .none,\n };\n}\n") + f.write("\n else => null,\n };\n}\n") diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 039c8bea6..8726f2951 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -3066,7 +3066,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .thicken = self.config.font_thicken, .thicken_strength = self.config.font_thicken_strength, .cell_width = cell.gridWidth(), - .constraint = getConstraint(cp), + // If there's no Nerd Font constraint for this codepoint + // then, if it's a symbol, we constrain it to fit inside + // its cell(s), we don't modify the alignment at all. + .constraint = getConstraint(cp) orelse + if (cellpkg.isSymbol(cp)) .{ + .size_horizontal = .fit, + .size_vertical = .fit, + } else .none, .constraint_width = constraintWidth(cell_pin), }, ); From 7c4b45eceef8aaa126d86c0c229dcdb33c30a18f Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 3 Sep 2025 18:05:26 -0600 Subject: [PATCH 24/42] font: expand set of characters considered symbols Low hanging fruit of some Unicode blocks that are full of very symbol-y characters. --- src/renderer/cell.zig | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index eeb51bdf0..ec13b8953 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -237,13 +237,27 @@ pub fn isCovering(cp: u21) bool { /// Returns true of the codepoint is a "symbol-like" character, which /// for now we define as anything in a private use area and anything -/// in the "dingbats" unicode block. +/// in several unicode blocks: +/// - Dingbats +/// - Emoticons +/// - Miscellaneous Symbols +/// - Enclosed Alphanumerics +/// - Enclosed Alphanumeric Supplement +/// - Miscellaneous Symbols and Pictographs +/// - Transport and Map Symbols /// /// In the future it may be prudent to expand this to encompass more /// symbol-like characters, and/or exclude some PUA sections. pub fn isSymbol(cp: u21) bool { + // TODO: This should probably become a codegen'd LUT return ziglyph.general_category.isPrivateUse(cp) or - ziglyph.blocks.isDingbats(cp); + ziglyph.blocks.isDingbats(cp) or + ziglyph.blocks.isEmoticons(cp) or + ziglyph.blocks.isMiscellaneousSymbols(cp) or + ziglyph.blocks.isEnclosedAlphanumerics(cp) or + ziglyph.blocks.isEnclosedAlphanumericSupplement(cp) or + ziglyph.blocks.isMiscellaneousSymbolsAndPictographs(cp) or + ziglyph.blocks.isTransportAndMapSymbols(cp); } /// Returns the appropriate `constraint_width` for From 5c1d87fda6b4a7304248fb8bdd00a27a2d0cfbfd Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 3 Sep 2025 20:04:29 -0600 Subject: [PATCH 25/42] fix(font): make `face.getMetrics()` infallible Before we had a bad day if we tried to get the metrics of a bitmap font, which would happen if we ever used one as fallback because we started doing it for all fonts when we added fallback font scaling. This is a pretty easy fix and finally allows users to configure true bitmap fonts as their primary font as long as FreeType/CoreText can handle it. --- src/font/Collection.zig | 23 +++-- src/font/face/coretext.zig | 178 +++++++++++++++++----------------- src/font/face/freetype.zig | 190 ++++++++++++++++++++++--------------- 3 files changed, 216 insertions(+), 175 deletions(-) diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 2b5f591a5..ad9590d70 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -92,7 +92,6 @@ pub const AddOptions = struct { pub const AddError = Allocator.Error || - Face.GetMetricsError || error{ /// There's no more room in the collection. CollectionFull, @@ -127,7 +126,7 @@ pub fn add( // Scale factor to adjust the size of the added face. const scale_factor = self.scaleFactor( - try owned_face.getMetrics(), + owned_face.getMetrics(), opts.size_adjustment, ); @@ -225,7 +224,7 @@ fn getFaceFromEntry( // entry now that we have a loaded face. entry.scale_factor = .{ .scale = self.scaleFactor( - try face.getMetrics(), + face.getMetrics(), entry.scale_factor.adjustment, ), }; @@ -592,7 +591,7 @@ fn scaleFactor( @branchHint(.unlikely); // If we can't load the primary face, just use 1.0 as the scale factor. const primary_face = self.getFace(.{ .idx = 0 }) catch return 1.0; - self.primary_face_metrics = primary_face.getMetrics() catch return 1.0; + self.primary_face_metrics = primary_face.getMetrics(); } const primary_metrics = self.primary_face_metrics.?; @@ -652,7 +651,7 @@ fn scaleFactor( return primary_metric / face_metric; } -const UpdateMetricsError = font.Face.GetMetricsError || error{ +const UpdateMetricsError = error{ CannotLoadPrimaryFont, }; @@ -663,7 +662,7 @@ const UpdateMetricsError = font.Face.GetMetricsError || error{ pub fn updateMetrics(self: *Collection) UpdateMetricsError!void { const primary_face = self.getFace(.{ .idx = 0 }) catch return error.CannotLoadPrimaryFont; - self.primary_face_metrics = try primary_face.getMetrics(); + self.primary_face_metrics = primary_face.getMetrics(); var metrics = Metrics.calc(self.primary_face_metrics.?); @@ -1288,8 +1287,8 @@ test "adjusted sizes" { // The chosen metric should match. { - const primary_metrics = try (try c.getFace(.{ .idx = 0 })).getMetrics(); - const fallback_metrics = try (try c.getFace(fallback_idx)).getMetrics(); + const primary_metrics = (try c.getFace(.{ .idx = 0 })).getMetrics(); + const fallback_metrics = (try c.getFace(fallback_idx)).getMetrics(); try std.testing.expectApproxEqAbs( @field(primary_metrics, metric).?, @@ -1302,8 +1301,8 @@ test "adjusted sizes" { // Resize should keep that relationship. try c.setSize(.{ .points = 37, .xdpi = 96, .ydpi = 96 }); { - const primary_metrics = try (try c.getFace(.{ .idx = 0 })).getMetrics(); - const fallback_metrics = try (try c.getFace(fallback_idx)).getMetrics(); + const primary_metrics = (try c.getFace(.{ .idx = 0 })).getMetrics(); + const fallback_metrics = (try c.getFace(fallback_idx)).getMetrics(); try std.testing.expectApproxEqAbs( @field(primary_metrics, metric).?, @@ -1359,8 +1358,8 @@ test "adjusted sizes" { // Test fallback to lineHeight() (ex_height and cap_height not defined in symbols font). { - const primary_metrics = try (try c.getFace(.{ .idx = 0 })).getMetrics(); - const symbol_metrics = try (try c.getFace(symbol_idx)).getMetrics(); + const primary_metrics = (try c.getFace(.{ .idx = 0 })).getMetrics(); + const symbol_metrics = (try c.getFace(symbol_idx)).getMetrics(); try std.testing.expectApproxEqAbs( primary_metrics.lineHeight(), diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index a85c94430..2804ce2cb 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -574,19 +574,12 @@ pub const Face = struct { }; } - pub const GetMetricsError = error{ - CopyTableError, - InvalidHeadTable, - InvalidPostTable, - InvalidHheaTable, - }; - /// Get the `FaceMetrics` for this face. - pub fn getMetrics(self: *Face) GetMetricsError!font.Metrics.FaceMetrics { + pub fn getMetrics(self: *Face) font.Metrics.FaceMetrics { const ct_font = self.font; // Read the 'head' table out of the font data. - const head: opentype.Head = head: { + const head_: ?opentype.Head = head: { // macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but // the table format is byte-identical to the 'head' table, so if we // can't find 'head' we try 'bhed' instead before failing. @@ -597,29 +590,26 @@ pub const Face = struct { const data = ct_font.copyTable(head_tag) orelse ct_font.copyTable(bhed_tag) orelse - return error.CopyTableError; + break :head null; defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); break :head opentype.Head.init(ptr[0..len]) catch |err| { - return switch (err) { - error.EndOfStream, - => error.InvalidHeadTable, - }; + log.warn("error parsing head table: {}", .{err}); + break :head null; }; }; // Read the 'post' table out of the font data. - const post: opentype.Post = post: { + const post_: ?opentype.Post = post: { const tag = macos.text.FontTableTag.init("post"); - const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + const data = ct_font.copyTable(tag) orelse break :post null; defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); break :post opentype.Post.init(ptr[0..len]) catch |err| { - return switch (err) { - error.EndOfStream => error.InvalidPostTable, - }; + log.warn("error parsing post table: {}", .{err}); + break :post null; }; }; @@ -637,96 +627,114 @@ pub const Face = struct { }; // Read the 'hhea' table out of the font data. - const hhea: opentype.Hhea = hhea: { + const hhea_: ?opentype.Hhea = hhea: { const tag = macos.text.FontTableTag.init("hhea"); - const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + const data = ct_font.copyTable(tag) orelse break :hhea null; defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); break :hhea opentype.Hhea.init(ptr[0..len]) catch |err| { - return switch (err) { - error.EndOfStream => error.InvalidHheaTable, - }; + log.warn("error parsing hhea table: {}", .{err}); + break :hhea null; }; }; - const units_per_em: f64 = @floatFromInt(head.unitsPerEm); + const units_per_em: f64 = + if (head_) |head| + @floatFromInt(head.unitsPerEm) + else + @floatFromInt(self.font.getUnitsPerEm()); const px_per_em: f64 = ct_font.getSize(); const px_per_unit: f64 = px_per_em / units_per_em; const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: { - const hhea_ascent: f64 = @floatFromInt(hhea.ascender); - const hhea_descent: f64 = @floatFromInt(hhea.descender); - const hhea_line_gap: f64 = @floatFromInt(hhea.lineGap); + if (hhea_) |hhea| { + const hhea_ascent: f64 = @floatFromInt(hhea.ascender); + const hhea_descent: f64 = @floatFromInt(hhea.descender); + const hhea_line_gap: f64 = @floatFromInt(hhea.lineGap); - if (os2_) |os2| { - const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); - const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); - const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); + if (os2_) |os2| { + const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); + const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); + const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); - // If the font says to use typo metrics, trust it. - if (os2.fsSelection.use_typo_metrics) break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; + // If the font says to use typo metrics, trust it. + if (os2.fsSelection.use_typo_metrics) break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; - // Otherwise we prefer the height metrics from 'hhea' if they - // are available, or else OS/2 sTypo* metrics, and if all else - // fails then we use OS/2 usWin* metrics. - // - // This is not "standard" behavior, but it's our best bet to - // account for fonts being... just weird. It's pretty much what - // FreeType does to get its generic ascent and descent metrics. + // Otherwise we prefer the height metrics from 'hhea' if they + // are available, or else OS/2 sTypo* metrics, and if all else + // fails then we use OS/2 usWin* metrics. + // + // This is not "standard" behavior, but it's our best bet to + // account for fonts being... just weird. It's pretty much what + // FreeType does to get its generic ascent and descent metrics. - if (hhea.ascender != 0 or hhea.descender != 0) break :vertical_metrics .{ + if (hhea.ascender != 0 or hhea.descender != 0) break :vertical_metrics .{ + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + }; + + if (os2_ascent != 0 or os2_descent != 0) break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + + const win_ascent: f64 = @floatFromInt(os2.usWinAscent); + const win_descent: f64 = @floatFromInt(os2.usWinDescent); + break :vertical_metrics .{ + win_ascent * px_per_unit, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -win_descent * px_per_unit, + 0.0, + }; + } + + // If our font has no OS/2 table, then we just + // blindly use the metrics from the hhea table. + break :vertical_metrics .{ hhea_ascent * px_per_unit, hhea_descent * px_per_unit, hhea_line_gap * px_per_unit, }; - - if (os2_ascent != 0 or os2_descent != 0) break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; - - const win_ascent: f64 = @floatFromInt(os2.usWinAscent); - const win_descent: f64 = @floatFromInt(os2.usWinDescent); - break :vertical_metrics .{ - win_ascent * px_per_unit, - // usWinDescent is *positive* -> down unlike sTypoDescender - // and hhea.Descender, so we flip its sign to fix this. - -win_descent * px_per_unit, - 0.0, - }; } - // If our font has no OS/2 table, then we just - // blindly use the metrics from the hhea table. + // If we couldn't get the hhea table, rely on metrics from CoreText. break :vertical_metrics .{ - hhea_ascent * px_per_unit, - hhea_descent * px_per_unit, - hhea_line_gap * px_per_unit, + self.font.getAscent(), + -self.font.getDescent(), + self.font.getLeading(), }; }; - // Some fonts have degenerate 'post' tables where the underline - // thickness (and often position) are 0. We consider them null - // if this is the case and use our own fallbacks when we calculate. - const has_broken_underline = post.underlineThickness == 0; + const underline_position, const underline_thickness = ul: { + const post = post_ orelse break :ul .{ null, null }; - // If the underline position isn't 0 then we do use it, - // even if the thickness is't properly specified. - const underline_position: ?f64 = if (has_broken_underline and post.underlinePosition == 0) - null - else - @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; + // Some fonts have degenerate 'post' tables where the underline + // thickness (and often position) are 0. We consider them null + // if this is the case and use our own fallbacks when we calculate. + const has_broken_underline = post.underlineThickness == 0; - const underline_thickness = if (has_broken_underline) - null - else - @as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit; + // If the underline position isn't 0 then we do use it, + // even if the thickness is't properly specified. + const pos: ?f64 = if (has_broken_underline and post.underlinePosition == 0) + null + else + @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; + + const thick: ?f64 = if (has_broken_underline) + null + else + @as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit; + + break :ul .{ pos, thick }; + }; // Similar logic to the underline above. const strikethrough_position, const strikethrough_thickness = st: { @@ -989,7 +997,7 @@ test { alloc, &atlas, face.glyphIndex(i).?, - .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(face.getMetrics()) }, ); } } @@ -1054,7 +1062,7 @@ test "in-memory" { alloc, &atlas, face.glyphIndex(i).?, - .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(face.getMetrics()) }, ); } } @@ -1081,7 +1089,7 @@ test "variable" { alloc, &atlas, face.glyphIndex(i).?, - .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(face.getMetrics()) }, ); } } @@ -1112,7 +1120,7 @@ test "variable set variation" { alloc, &atlas, face.glyphIndex(i).?, - .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(face.getMetrics()) }, ); } } diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 1d8d2efff..ef4275131 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -784,12 +784,8 @@ pub const Face = struct { return @as(F26Dot6, @bitCast(@as(i32, @intCast(v)))).to(f64); } - pub const GetMetricsError = error{ - CopyTableError, - }; - /// Get the `FaceMetrics` for this face. - pub fn getMetrics(self: *Face) GetMetricsError!font.Metrics.FaceMetrics { + pub fn getMetrics(self: *Face) font.Metrics.FaceMetrics { const face = self.face; const size_metrics = face.handle.*.size.*.metrics; @@ -799,10 +795,10 @@ pub const Face = struct { assert(size_metrics.x_ppem == size_metrics.y_ppem); // Read the 'head' table out of the font data. - const head = face.getSfntTable(.head) orelse return error.CopyTableError; + const head_ = face.getSfntTable(.head); // Read the 'post' table out of the font data. - const post = face.getSfntTable(.post) orelse return error.CopyTableError; + const post_ = face.getSfntTable(.post); // Read the 'OS/2' table out of the font data. const os2_: ?*freetype.c.TT_OS2 = os2: { @@ -812,92 +808,130 @@ pub const Face = struct { }; // Read the 'hhea' table out of the font data. - const hhea = face.getSfntTable(.hhea) orelse return error.CopyTableError; + const hhea_ = face.getSfntTable(.hhea); - const units_per_em = head.Units_Per_EM; + // Whether the font is in a scalable format. We need to know this + // because many of the metrics provided by FreeType are invalid for + // non-scalable fonts. + const is_scalable = face.handle.*.face_flags & freetype.c.FT_FACE_FLAG_SCALABLE != 0; + + // We get the UPM from the head table. + // + // If we have no head, but it is a scalable face, take the UPM from + // FreeType's units_per_EM, otherwise we'll assume that UPM == PPEM. + const units_per_em: freetype.c.FT_UShort = + if (head_) |head| + head.Units_Per_EM + else if (is_scalable) + face.handle.*.units_per_EM + else + size_metrics.y_ppem; const px_per_em: f64 = @floatFromInt(size_metrics.y_ppem); const px_per_unit = px_per_em / @as(f64, @floatFromInt(units_per_em)); const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: { - const hhea_ascent: f64 = @floatFromInt(hhea.Ascender); - const hhea_descent: f64 = @floatFromInt(hhea.Descender); - const hhea_line_gap: f64 = @floatFromInt(hhea.Line_Gap); + if (hhea_) |hhea| { + const hhea_ascent: f64 = @floatFromInt(hhea.Ascender); + const hhea_descent: f64 = @floatFromInt(hhea.Descender); + const hhea_line_gap: f64 = @floatFromInt(hhea.Line_Gap); - if (os2_) |os2| { - const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); - const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); - const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); + if (os2_) |os2| { + const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); + const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); + const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); - // If the font says to use typo metrics, trust it. - // (The USE_TYPO_METRICS bit is bit 7) - if (os2.fsSelection & (1 << 7) != 0) { + // If the font says to use typo metrics, trust it. + // (The USE_TYPO_METRICS bit is bit 7) + if (os2.fsSelection & (1 << 7) != 0) { + break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + } + + // Otherwise we prefer the height metrics from 'hhea' if they + // are available, or else OS/2 sTypo* metrics, and if all else + // fails then we use OS/2 usWin* metrics. + // + // This is not "standard" behavior, but it's our best bet to + // account for fonts being... just weird. It's pretty much what + // FreeType does to get its generic ascent and descent metrics. + + if (hhea.Ascender != 0 or hhea.Descender != 0) { + break :vertical_metrics .{ + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + }; + } + + if (os2_ascent != 0 or os2_descent != 0) { + break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + } + + const win_ascent: f64 = @floatFromInt(os2.usWinAscent); + const win_descent: f64 = @floatFromInt(os2.usWinDescent); break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, + win_ascent * px_per_unit, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -win_descent * px_per_unit, + 0.0, }; } - // Otherwise we prefer the height metrics from 'hhea' if they - // are available, or else OS/2 sTypo* metrics, and if all else - // fails then we use OS/2 usWin* metrics. - // - // This is not "standard" behavior, but it's our best bet to - // account for fonts being... just weird. It's pretty much what - // FreeType does to get its generic ascent and descent metrics. - - if (hhea.Ascender != 0 or hhea.Descender != 0) { - break :vertical_metrics .{ - hhea_ascent * px_per_unit, - hhea_descent * px_per_unit, - hhea_line_gap * px_per_unit, - }; - } - - if (os2_ascent != 0 or os2_descent != 0) { - break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; - } - - const win_ascent: f64 = @floatFromInt(os2.usWinAscent); - const win_descent: f64 = @floatFromInt(os2.usWinDescent); + // If our font has no OS/2 table, then we just + // blindly use the metrics from the hhea table. break :vertical_metrics .{ - win_ascent * px_per_unit, - // usWinDescent is *positive* -> down unlike sTypoDescender - // and hhea.Descender, so we flip its sign to fix this. - -win_descent * px_per_unit, - 0.0, + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, }; } - // If our font has no OS/2 table, then we just - // blindly use the metrics from the hhea table. + // If we couldn't get the hhea table, rely on metrics from FreeType. + const ascender = f26dot6ToF64(size_metrics.ascender); + const descender = f26dot6ToF64(size_metrics.descender); + const height = f26dot6ToF64(size_metrics.height); break :vertical_metrics .{ - hhea_ascent * px_per_unit, - hhea_descent * px_per_unit, - hhea_line_gap * px_per_unit, + ascender, + descender, + // We compute the line gap by adding the (negative) descender + // and subtracting the (positive) ascender from the line height + // to get the remaining gap size. + // + // NOTE: This might always be 0... but it doesn't hurt to do. + height + descender - ascender, }; }; - // Some fonts have degenerate 'post' tables where the underline - // thickness (and often position) are 0. We consider them null - // if this is the case and use our own fallbacks when we calculate. - const has_broken_underline = post.underlineThickness == 0; + const underline_position: ?f64, const underline_thickness: ?f64 = ul: { + const post = post_ orelse break :ul .{ null, null }; - // If the underline position isn't 0 then we do use it, - // even if the thickness is't properly specified. - const underline_position = if (has_broken_underline and post.underlinePosition == 0) - null - else - @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; + // Some fonts have degenerate 'post' tables where the underline + // thickness (and often position) are 0. We consider them null + // if this is the case and use our own fallbacks when we calculate. + const has_broken_underline = post.underlineThickness == 0; - const underline_thickness = if (has_broken_underline) - null - else - @as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit; + // If the underline position isn't 0 then we do use it, + // even if the thickness is't properly specified. + const pos: ?f64 = if (has_broken_underline and post.underlinePosition == 0) + null + else + @as(f64, @floatFromInt(post.underlinePosition)) * px_per_unit; + + const thick: ?f64 = if (has_broken_underline) + null + else + @as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit; + + break :ul .{ pos, thick }; + }; // Similar logic to the underline above. const strikethrough_position, const strikethrough_thickness = st: { @@ -1085,7 +1119,7 @@ test { alloc, &atlas, ft_font.glyphIndex(i).?, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); } @@ -1095,7 +1129,7 @@ test { alloc, &atlas, ft_font.glyphIndex('A').?, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); try testing.expectEqual(@as(u32, 11), g1.height); @@ -1104,7 +1138,7 @@ test { alloc, &atlas, ft_font.glyphIndex('A').?, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); try testing.expectEqual(@as(u32, 20), g2.height); } @@ -1131,7 +1165,7 @@ test "color emoji" { alloc, &atlas, ft_font.glyphIndex('🥸').?, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); // Make sure this glyph has color @@ -1191,7 +1225,7 @@ test "mono to bgra" { alloc, &atlas, 3, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); } @@ -1255,7 +1289,7 @@ test "bitmap glyph" { alloc, &atlas, 77, - .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + .{ .grid_metrics = font.Metrics.calc(ft_font.getMetrics()) }, ); // should render crisp From aeae54072c5393595f4555d2d60acd384ffc955f Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 3 Sep 2025 21:33:38 -0600 Subject: [PATCH 26/42] fix(font/freetype): mark glyph bitmap as owned if modifying This caused a malloc fault due to a double free when deiniting the face if we didn't do this, which makes sense- making it possible to actually load bitmap fonts revealed this bug which was sitting dormant the whole time before that ever since I refactored the freetype rasterization. --- src/font/face/freetype.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index ef4275131..19eef2dac 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -594,6 +594,12 @@ pub const Face = struct { freetype.c.FT_PIXEL_MODE_GRAY, => {}, else => { + // Make sure the slot owns its bitmap, + // since we'll be modifying it here. + if (freetype.c.FT_GlyphSlot_Own_Bitmap(glyph) != 0) { + return error.BitmapHandlingError; + } + var converted: freetype.c.FT_Bitmap = undefined; freetype.c.FT_Bitmap_Init(&converted); if (freetype.c.FT_Bitmap_Convert( From 43ee3cc8c6431879dc6eec51ebc256299d54cd9b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 4 Sep 2025 11:57:39 -0500 Subject: [PATCH 27/42] update zig-gobject to Zig 0.15 version (but still builds on Zig 0.14) --- 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 072a5ca11..05ca3134e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -55,8 +55,8 @@ .gobject = .{ // https://github.com/jcollie/ghostty-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-30-39-1/ghostty-gobject-0.14.1-2025-08-30-39-1.tar.zst", - .hash = "gobject-0.3.0-Skun7HlFnQBaCs-ZxA9JLd_QV9wbvrnfoJDzPx96IDLc", + .url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst", + .hash = "gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z", .lazy = true, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 814f0b36c..a28569dc4 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -24,10 +24,10 @@ "url": "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz", "hash": "sha256-FKLtu1Ccs+UamlPj9eQ12/WXFgS0uDPmPmB26MCpl7U=" }, - "gobject-0.3.0-Skun7HlFnQBaCs-ZxA9JLd_QV9wbvrnfoJDzPx96IDLc": { + "gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z": { "name": "gobject", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-30-39-1/ghostty-gobject-0.14.1-2025-08-30-39-1.tar.zst", - "hash": "sha256-e/HM6V8s0flOVMelia453vAs4jH4jy7QArPiIsS/k74=" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst", + "hash": "sha256-h6aKUerGlX2ATVEeoN03eWaqDqvUmKdedgpxrSoHvrY=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { "name": "gtk4_layer_shell", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index e5eda6473..866da31ea 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -123,11 +123,11 @@ in }; } { - name = "gobject-0.3.0-Skun7HlFnQBaCs-ZxA9JLd_QV9wbvrnfoJDzPx96IDLc"; + name = "gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-30-39-1/ghostty-gobject-0.14.1-2025-08-30-39-1.tar.zst"; - hash = "sha256-e/HM6V8s0flOVMelia453vAs4jH4jy7QArPiIsS/k74="; + url = "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst"; + hash = "sha256-h6aKUerGlX2ATVEeoN03eWaqDqvUmKdedgpxrSoHvrY="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index fc2fd64d9..c2d45a16e 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,7 +27,7 @@ https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d6 https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21aeb76c5a7dc99698401f4f6fefc.tar.gz https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-30-39-1/ghostty-gobject-0.14.1-2025-08-30-39-1.tar.zst +https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6cdbc8501d48601302e32d6b53e9e7934bf354b4.tar.gz https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index a452d75c0..0663b41df 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -31,9 +31,9 @@ }, { "type": "archive", - "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.14.1-2025-08-30-39-1/ghostty-gobject-0.14.1-2025-08-30-39-1.tar.zst", - "dest": "vendor/p/gobject-0.3.0-Skun7HlFnQBaCs-ZxA9JLd_QV9wbvrnfoJDzPx96IDLc", - "sha256": "7bf1cce95f2cd1f94e54c7a589ae39def02ce231f88f2ed002b3e222c4bf93be" + "url": "https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst", + "dest": "vendor/p/gobject-0.3.0-Skun7ET3nQAc0LzvO0NAvTiGGnmkF36cnmbeCAF6MB7Z", + "sha256": "87a68a51eac6957d804d511ea0dd377966aa0eabd498a75e760a71ad2a07beb6" }, { "type": "archive", From a590194cd74d11faf6d8513fd0673b73393cee20 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 4 Sep 2025 12:04:12 -0600 Subject: [PATCH 28/42] reduce nesting --- src/font/face/coretext.zig | 112 ++++++++++++++++----------------- src/font/face/freetype.zig | 126 ++++++++++++++++++------------------- 2 files changed, 116 insertions(+), 122 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 2804ce2cb..bd7e16e0d 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -648,69 +648,65 @@ pub const Face = struct { const px_per_unit: f64 = px_per_em / units_per_em; const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: { - if (hhea_) |hhea| { - const hhea_ascent: f64 = @floatFromInt(hhea.ascender); - const hhea_descent: f64 = @floatFromInt(hhea.descender); - const hhea_line_gap: f64 = @floatFromInt(hhea.lineGap); - - if (os2_) |os2| { - const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); - const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); - const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); - - // If the font says to use typo metrics, trust it. - if (os2.fsSelection.use_typo_metrics) break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; - - // Otherwise we prefer the height metrics from 'hhea' if they - // are available, or else OS/2 sTypo* metrics, and if all else - // fails then we use OS/2 usWin* metrics. - // - // This is not "standard" behavior, but it's our best bet to - // account for fonts being... just weird. It's pretty much what - // FreeType does to get its generic ascent and descent metrics. - - if (hhea.ascender != 0 or hhea.descender != 0) break :vertical_metrics .{ - hhea_ascent * px_per_unit, - hhea_descent * px_per_unit, - hhea_line_gap * px_per_unit, - }; - - if (os2_ascent != 0 or os2_descent != 0) break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; - - const win_ascent: f64 = @floatFromInt(os2.usWinAscent); - const win_descent: f64 = @floatFromInt(os2.usWinDescent); - break :vertical_metrics .{ - win_ascent * px_per_unit, - // usWinDescent is *positive* -> down unlike sTypoDescender - // and hhea.Descender, so we flip its sign to fix this. - -win_descent * px_per_unit, - 0.0, - }; - } - - // If our font has no OS/2 table, then we just - // blindly use the metrics from the hhea table. - break :vertical_metrics .{ - hhea_ascent * px_per_unit, - hhea_descent * px_per_unit, - hhea_line_gap * px_per_unit, - }; - } - // If we couldn't get the hhea table, rely on metrics from CoreText. - break :vertical_metrics .{ + const hhea = hhea_ orelse break :vertical_metrics .{ self.font.getAscent(), -self.font.getDescent(), self.font.getLeading(), }; + + const hhea_ascent: f64 = @floatFromInt(hhea.ascender); + const hhea_descent: f64 = @floatFromInt(hhea.descender); + const hhea_line_gap: f64 = @floatFromInt(hhea.lineGap); + + // If our font has no OS/2 table, then we just + // blindly use the metrics from the hhea table. + const os2 = os2_ orelse break :vertical_metrics .{ + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + }; + + const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); + const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); + const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); + + // If the font says to use typo metrics, trust it. + if (os2.fsSelection.use_typo_metrics) break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + + // Otherwise we prefer the height metrics from 'hhea' if they + // are available, or else OS/2 sTypo* metrics, and if all else + // fails then we use OS/2 usWin* metrics. + // + // This is not "standard" behavior, but it's our best bet to + // account for fonts being... just weird. It's pretty much what + // FreeType does to get its generic ascent and descent metrics. + + if (hhea.ascender != 0 or hhea.descender != 0) break :vertical_metrics .{ + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + }; + + if (os2_ascent != 0 or os2_descent != 0) break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + + const win_ascent: f64 = @floatFromInt(os2.usWinAscent); + const win_descent: f64 = @floatFromInt(os2.usWinDescent); + break :vertical_metrics .{ + win_ascent * px_per_unit, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -win_descent * px_per_unit, + 0.0, + }; }; const underline_position, const underline_thickness = ul: { diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 19eef2dac..4fb82c502 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -836,63 +836,58 @@ pub const Face = struct { const px_per_unit = px_per_em / @as(f64, @floatFromInt(units_per_em)); const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: { - if (hhea_) |hhea| { - const hhea_ascent: f64 = @floatFromInt(hhea.Ascender); - const hhea_descent: f64 = @floatFromInt(hhea.Descender); - const hhea_line_gap: f64 = @floatFromInt(hhea.Line_Gap); - - if (os2_) |os2| { - const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); - const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); - const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); - - // If the font says to use typo metrics, trust it. - // (The USE_TYPO_METRICS bit is bit 7) - if (os2.fsSelection & (1 << 7) != 0) { - break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; - } - - // Otherwise we prefer the height metrics from 'hhea' if they - // are available, or else OS/2 sTypo* metrics, and if all else - // fails then we use OS/2 usWin* metrics. + const hhea = hhea_ orelse { + // If we couldn't get the hhea table, rely on metrics from FreeType. + const ascender = f26dot6ToF64(size_metrics.ascender); + const descender = f26dot6ToF64(size_metrics.descender); + const height = f26dot6ToF64(size_metrics.height); + break :vertical_metrics .{ + ascender, + descender, + // We compute the line gap by adding the (negative) descender + // and subtracting the (positive) ascender from the line height + // to get the remaining gap size. // - // This is not "standard" behavior, but it's our best bet to - // account for fonts being... just weird. It's pretty much what - // FreeType does to get its generic ascent and descent metrics. + // NOTE: This might always be 0... but it doesn't hurt to do. + height + descender - ascender, + }; + }; - if (hhea.Ascender != 0 or hhea.Descender != 0) { - break :vertical_metrics .{ - hhea_ascent * px_per_unit, - hhea_descent * px_per_unit, - hhea_line_gap * px_per_unit, - }; - } + const hhea_ascent: f64 = @floatFromInt(hhea.Ascender); + const hhea_descent: f64 = @floatFromInt(hhea.Descender); + const hhea_line_gap: f64 = @floatFromInt(hhea.Line_Gap); - if (os2_ascent != 0 or os2_descent != 0) { - break :vertical_metrics .{ - os2_ascent * px_per_unit, - os2_descent * px_per_unit, - os2_line_gap * px_per_unit, - }; - } + // If our font has no OS/2 table, then we just + // blindly use the metrics from the hhea table. + const os2 = os2_ orelse break :vertical_metrics .{ + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + }; - const win_ascent: f64 = @floatFromInt(os2.usWinAscent); - const win_descent: f64 = @floatFromInt(os2.usWinDescent); - break :vertical_metrics .{ - win_ascent * px_per_unit, - // usWinDescent is *positive* -> down unlike sTypoDescender - // and hhea.Descender, so we flip its sign to fix this. - -win_descent * px_per_unit, - 0.0, - }; - } + const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender); + const os2_descent: f64 = @floatFromInt(os2.sTypoDescender); + const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap); - // If our font has no OS/2 table, then we just - // blindly use the metrics from the hhea table. + // If the font says to use typo metrics, trust it. + // (The USE_TYPO_METRICS bit is bit 7) + if (os2.fsSelection & (1 << 7) != 0) { + break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + } + + // Otherwise we prefer the height metrics from 'hhea' if they + // are available, or else OS/2 sTypo* metrics, and if all else + // fails then we use OS/2 usWin* metrics. + // + // This is not "standard" behavior, but it's our best bet to + // account for fonts being... just weird. It's pretty much what + // FreeType does to get its generic ascent and descent metrics. + + if (hhea.Ascender != 0 or hhea.Descender != 0) { break :vertical_metrics .{ hhea_ascent * px_per_unit, hhea_descent * px_per_unit, @@ -900,19 +895,22 @@ pub const Face = struct { }; } - // If we couldn't get the hhea table, rely on metrics from FreeType. - const ascender = f26dot6ToF64(size_metrics.ascender); - const descender = f26dot6ToF64(size_metrics.descender); - const height = f26dot6ToF64(size_metrics.height); + if (os2_ascent != 0 or os2_descent != 0) { + break :vertical_metrics .{ + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + }; + } + + const win_ascent: f64 = @floatFromInt(os2.usWinAscent); + const win_descent: f64 = @floatFromInt(os2.usWinDescent); break :vertical_metrics .{ - ascender, - descender, - // We compute the line gap by adding the (negative) descender - // and subtracting the (positive) ascender from the line height - // to get the remaining gap size. - // - // NOTE: This might always be 0... but it doesn't hurt to do. - height + descender - ascender, + win_ascent * px_per_unit, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -win_descent * px_per_unit, + 0.0, }; }; From ee573ebd36ad7ca63eaf1328d4ff0de0a1a106cc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Sep 2025 12:29:59 -0700 Subject: [PATCH 29/42] ai: add `gh-issue` command to help diagnose GitHub issues This enables agents (namely Amp) to use `/gh-issue ` to begin diagnosing a GitHub issue, explaining the problem, and suggesting a plan of action. This action explicitly prompts the AI to not write code. I've used this manually for months with good results, so now I'm formalizing it in the repo for other contributors. Example diagnosing #8523: https://ampcode.com/threads/T-3e26e8cc-83d1-4e3c-9b5e-02d9111909a7 --- .agents/commands/gh-issue | 64 +++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 17 +++++++---- HACKING.md | 28 ++++++++++++++++- nix/devShell.nix | 4 +++ 4 files changed, 106 insertions(+), 7 deletions(-) create mode 100755 .agents/commands/gh-issue diff --git a/.agents/commands/gh-issue b/.agents/commands/gh-issue new file mode 100755 index 000000000..de2f37335 --- /dev/null +++ b/.agents/commands/gh-issue @@ -0,0 +1,64 @@ +#!/usr/bin/env nu + +# A command to generate an agent prompt to diagnose and formulate +# a plan for resolving a GitHub issue. +# +# IMPORTANT: This command is prompted to NOT write any code and to ONLY +# produce a plan. You should still be vigilant when running this but that +# is the expected behavior. +# +# The `` parameter can be either an issue number or a full GitHub +# issue URL. +def main [ + issue: any, # Ghostty issue number or URL + --repo: string = "ghostty-org/ghostty" # GitHub repository in the format "owner/repo" +] { + # TODO: This whole script doesn't handle errors very well. I actually + # don't know Nu well enough to know the proper way to handle it all. + + let issueData = gh issue view $issue --json author,title,number,body,comments | from json + let comments = $issueData.comments | each { |comment| + $" +### Comment by ($comment.author.login) +($comment.body) +" | str trim + } | str join "\n\n" + + $" +Deep-dive on this GitHub issue. Find the problem and generate a plan. +Do not write code. Explain the problem clearly and propose a comprehensive plan +to solve it. + +# ($issueData.title) \(($issueData.number)\) + +## Description +($issueData.body) + +## Comments +($comments) + +## Your Tasks + +You are an experienced software developer tasked with diagnosing issues. + +1. Review the issue context and details. +2. Examine the relevant parts of the codebase. Analyze the code thoroughly + until you have a solid understanding of how it works. +3. Explain the issue in detail, including the problem and its root cause. +4. Create a comprehensive plan to solve the issue. The plan should include: + - Required code changes + - Potential impacts on other parts of the system + - Necessary tests to be written or updated + - Documentation updates + - Performance considerations + - Security implications + - Backwards compatibility \(if applicable\) + - Include the reference link to the source issue and any related discussions +4. Think deeply about all aspects of the task. Consider edge cases, potential + challenges, and best practices for addressing the issue. Review the plan + with the oracle and adjust it based on its feedback. + +**ONLY CREATE A PLAN. DO NOT WRITE ANY CODE.** Your task is to create +a thorough, comprehensive strategy for understanding and resolving the issue. +" | str trim +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 777771145..de7df4b71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,11 +45,16 @@ work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! +When using AI assistance, we expect contributors to understand the code +that is produced and be able to answer critical questions about it. It +isn't a maintainers job to review a PR so broken that it requires +significant rework to be acceptable. + Please be respectful to maintainers and disclose AI assistance. ## Quick Guide -### I'd like to contribute! +### I'd like to contribute [All issues are actionable](#issues-are-actionable). Pick one and start working on it. Thank you. If you need help or guidance, comment on the issue. @@ -58,7 +63,7 @@ Issues that are extra friendly to new contributors are tagged with ["contributor friendly"]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22contributor%20friendly%22 -### I'd like to translate Ghostty to my language! +### I'd like to translate Ghostty to my language We have written a [Translator's Guide](po/README_TRANSLATORS.md) for everyone interested in contributing translations to Ghostty. @@ -67,7 +72,7 @@ and you can submit pull requests directly, although please make sure that our [Style Guide](po/README_TRANSLATORS.md#style-guide) is followed before submission. -### I have a bug! / Something isn't working! +### I have a bug! / Something isn't working 1. Search the issue tracker and discussions for similar issues. Tip: also search for [closed issues] and [discussions] — your issue might have already @@ -82,18 +87,18 @@ submission. [discussions]: https://github.com/ghostty-org/ghostty/discussions?discussions_q=is%3Aclosed ["Issue Triage" discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage -### I have an idea for a feature! +### I have an idea for a feature Open a discussion in the ["Feature Requests, Ideas" category](https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas). -### I've implemented a feature! +### I've implemented a feature 1. If there is an issue for the feature, open a pull request straight away. 2. If there is no issue, open a discussion and link to your branch. 3. If you want to live dangerously, open a pull request and [hope for the best](#pull-requests-implement-an-issue). -### I have a question! +### I have a question Open an [Q&A discussion], or join our [Discord Server] and ask away in the `#help` channel. diff --git a/HACKING.md b/HACKING.md index d79d15a4a..2d3640fca 100644 --- a/HACKING.md +++ b/HACKING.md @@ -36,7 +36,7 @@ here: | `zig build test` | Runs unit tests (accepts `-Dtest-filter=` to only run tests whose name matches the filter) | | `zig build update-translations` | Updates Ghostty's translation strings (see the [Contributor's Guide on Localizing Ghostty](po/README_CONTRIBUTORS.md)) | | `zig build dist` | Builds a source tarball | -| `zig build distcheck` | Installs and validates a source tarball | +| `zig build distcheck` | Builds and validates a source tarball | ## Extra Dependencies @@ -69,6 +69,32 @@ sudo xcode-select --switch /Applications/Xcode-beta.app > You do not need to be running on macOS 26 to build Ghostty, you can > still use Xcode 26 beta on macOS 15 stable. +## AI and Agents + +If you're using AI assistance with Ghostty, Ghostty provides an +[AGENTS.md file](https://github.com/ghostty-org/ghostty/blob/main/AGENTS.md) +read by most of the popular AI agents to help produce higher quality +results. + +We also provide commands in `.agents/commands` that have some vetted +prompts for common tasks that have been shown to produce good results. +We provide these to help reduce the amount of time a contributor has to +spend prompting the AI to get good results, and hopefully to lower the slop +produced. + +- `/gh-issue ` - Produces a prompt for diagnosing a GitHub + issue, explaining the problem, and suggesting a plan for resolving it. + Requires `gh` to be installed with read-only access to Ghostty. + +> [!WARNING] +> +> All AI assistance usage [must be disclosed](https://github.com/ghostty-org/ghostty/blob/main/CONTRIBUTING.md#ai-assistance-notice) +> and we expect contributors to understand the code that is produced and +> be able to answer questions about it. If you don't understand the +> code produced, feel free to disclose that, but if it has problems, we +> may ask you to fix it and close the issue. It isn't a maintainers job to +> review a PR so broken that it requires significant rework to be acceptable. + ## Linting ### Prettier diff --git a/nix/devShell.nix b/nix/devShell.nix index 7b78539f7..a54e199c2 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -3,6 +3,7 @@ lib, stdenv, bashInteractive, + nushell, appstream, flatpak-builder, gdb, @@ -124,6 +125,9 @@ in # CI uv + # Scripting + nushell + # We need these GTK-related deps on all platform so we can build # dist tarballs. blueprint-compiler From ac52af27d3e0fa8c1ac466585df1696bc9817670 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 4 Sep 2025 17:19:42 +0200 Subject: [PATCH 30/42] gtk: nuke the legacy apprt from orbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We don't really have any large outstanding regressions on -ng to warrant keeping this alive anymore. ¡Adiós! --- .github/workflows/test.yml | 53 - CODEOWNERS | 1 - src/apprt.zig | 7 - src/apprt/action.zig | 2 +- src/apprt/gtk.zig | 12 - src/apprt/gtk/App.zig | 1900 ------------ src/apprt/gtk/Builder.zig | 77 - src/apprt/gtk/ClipboardConfirmationWindow.zig | 212 -- src/apprt/gtk/CloseDialog.zig | 151 - src/apprt/gtk/CommandPalette.zig | 258 -- src/apprt/gtk/ConfigErrorsDialog.zig | 102 - src/apprt/gtk/GlobalShortcuts.zig | 422 --- src/apprt/gtk/ImguiWidget.zig | 470 --- src/apprt/gtk/ProgressBar.zig | 165 -- src/apprt/gtk/ResizeOverlay.zig | 206 -- src/apprt/gtk/Split.zig | 441 --- src/apprt/gtk/Surface.zig | 2561 ----------------- src/apprt/gtk/Tab.zig | 171 -- src/apprt/gtk/TabView.zig | 284 -- src/apprt/gtk/URLWidget.zig | 115 - src/apprt/gtk/Window.zig | 1190 -------- src/apprt/gtk/adw_version.zig | 122 - src/apprt/gtk/blueprint_compiler.zig | 160 - src/apprt/gtk/cgroup.zig | 205 -- src/apprt/gtk/flatpak.zig | 29 - src/apprt/gtk/gresource.zig | 168 -- src/apprt/gtk/gtk_version.zig | 140 - src/apprt/gtk/headerbar.zig | 54 - src/apprt/gtk/inspector.zig | 184 -- src/apprt/gtk/ipc.zig | 1 - src/apprt/gtk/ipc/new_window.zig | 172 -- src/apprt/gtk/key.zig | 405 --- src/apprt/gtk/menu.zig | 139 - src/apprt/gtk/style-dark.css | 8 - src/apprt/gtk/style-hc-dark.css | 3 - src/apprt/gtk/style-hc.css | 3 - src/apprt/gtk/style.css | 116 - .../gtk/ui/1.0/menu-headerbar-split_menu.blp | 25 - .../gtk/ui/1.0/menu-surface-context_menu.blp | 102 - .../gtk/ui/1.0/menu-window-titlebar_menu.blp | 116 - src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp | 71 - src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp | 71 - src/apprt/gtk/ui/1.2/ccw-paste.blp | 71 - src/apprt/gtk/ui/1.2/config-errors-dialog.blp | 28 - src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp | 85 - src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp | 81 - src/apprt/gtk/ui/1.5/ccw-paste.blp | 71 - src/apprt/gtk/ui/1.5/command-palette.blp | 106 - src/apprt/gtk/ui/1.5/config-errors-dialog.blp | 28 - src/apprt/gtk/ui/1.5/prompt-title-dialog.blp | 16 - src/apprt/gtk/ui/README.md | 15 - src/apprt/gtk/winproto.zig | 155 - src/apprt/gtk/winproto/noop.zig | 75 - src/apprt/gtk/winproto/wayland.zig | 511 ---- src/apprt/gtk/winproto/x11.zig | 507 ---- src/apprt/structs.zig | 6 +- src/apprt/surface.zig | 1 - src/build/GhosttyDist.zig | 5 - src/build/GhosttyI18n.zig | 10 +- src/build/SharedDeps.zig | 229 -- src/cli/version.zig | 6 +- src/config/Config.zig | 4 +- src/datastruct/split_tree.zig | 2 +- src/font/face.zig | 2 +- src/input/Binding.zig | 2 +- src/renderer/OpenGL.zig | 7 +- src/terminal/mouse_shape.zig | 2 +- 67 files changed, 21 insertions(+), 13098 deletions(-) delete mode 100644 src/apprt/gtk.zig delete mode 100644 src/apprt/gtk/App.zig delete mode 100644 src/apprt/gtk/Builder.zig delete mode 100644 src/apprt/gtk/ClipboardConfirmationWindow.zig delete mode 100644 src/apprt/gtk/CloseDialog.zig delete mode 100644 src/apprt/gtk/CommandPalette.zig delete mode 100644 src/apprt/gtk/ConfigErrorsDialog.zig delete mode 100644 src/apprt/gtk/GlobalShortcuts.zig delete mode 100644 src/apprt/gtk/ImguiWidget.zig delete mode 100644 src/apprt/gtk/ProgressBar.zig delete mode 100644 src/apprt/gtk/ResizeOverlay.zig delete mode 100644 src/apprt/gtk/Split.zig delete mode 100644 src/apprt/gtk/Surface.zig delete mode 100644 src/apprt/gtk/Tab.zig delete mode 100644 src/apprt/gtk/TabView.zig delete mode 100644 src/apprt/gtk/URLWidget.zig delete mode 100644 src/apprt/gtk/Window.zig delete mode 100644 src/apprt/gtk/adw_version.zig delete mode 100644 src/apprt/gtk/blueprint_compiler.zig delete mode 100644 src/apprt/gtk/cgroup.zig delete mode 100644 src/apprt/gtk/flatpak.zig delete mode 100644 src/apprt/gtk/gresource.zig delete mode 100644 src/apprt/gtk/gtk_version.zig delete mode 100644 src/apprt/gtk/headerbar.zig delete mode 100644 src/apprt/gtk/inspector.zig delete mode 100644 src/apprt/gtk/ipc.zig delete mode 100644 src/apprt/gtk/ipc/new_window.zig delete mode 100644 src/apprt/gtk/key.zig delete mode 100644 src/apprt/gtk/menu.zig delete mode 100644 src/apprt/gtk/style-dark.css delete mode 100644 src/apprt/gtk/style-hc-dark.css delete mode 100644 src/apprt/gtk/style-hc.css delete mode 100644 src/apprt/gtk/style.css delete mode 100644 src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp delete mode 100644 src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp delete mode 100644 src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp delete mode 100644 src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp delete mode 100644 src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp delete mode 100644 src/apprt/gtk/ui/1.2/ccw-paste.blp delete mode 100644 src/apprt/gtk/ui/1.2/config-errors-dialog.blp delete mode 100644 src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp delete mode 100644 src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp delete mode 100644 src/apprt/gtk/ui/1.5/ccw-paste.blp delete mode 100644 src/apprt/gtk/ui/1.5/command-palette.blp delete mode 100644 src/apprt/gtk/ui/1.5/config-errors-dialog.blp delete mode 100644 src/apprt/gtk/ui/1.5/prompt-title-dialog.blp delete mode 100644 src/apprt/gtk/ui/README.md delete mode 100644 src/apprt/gtk/winproto.zig delete mode 100644 src/apprt/gtk/winproto/noop.zig delete mode 100644 src/apprt/gtk/winproto/wayland.zig delete mode 100644 src/apprt/gtk/winproto/x11.zig diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 419e83235..9ec50e494 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,6 @@ jobs: - build-macos-matrix - build-windows - test - - test-gtk - test-gtk-ng - test-sentry-linux - test-macos @@ -492,9 +491,6 @@ jobs: - name: test run: nix develop -c zig build -Dapp-runtime=none test - - name: Test GTK Build - run: nix develop -c zig build -Dapp-runtime=gtk -Demit-docs -Demit-webdata - - name: Test GTK-NG Build run: nix develop -c zig build -Dapp-runtime=gtk-ng -Demit-docs -Demit-webdata @@ -502,55 +498,6 @@ jobs: - name: Test System Build run: nix develop -c zig build --system ${ZIG_GLOBAL_CACHE_DIR}/p - test-gtk: - strategy: - fail-fast: false - matrix: - x11: ["true", "false"] - wayland: ["true", "false"] - name: GTK x11=${{ matrix.x11 }} wayland=${{ matrix.wayland }} - runs-on: namespace-profile-ghostty-sm - needs: test - env: - ZIG_LOCAL_CACHE_DIR: /zig/local-cache - ZIG_GLOBAL_CACHE_DIR: /zig/global-cache - steps: - - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 - with: - path: | - /nix - /zig - - # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0 - with: - nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 - with: - name: ghostty - authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - - name: Test - run: | - nix develop -c \ - zig build \ - -Dapp-runtime=gtk \ - -Dgtk-x11=${{ matrix.x11 }} \ - -Dgtk-wayland=${{ matrix.wayland }} \ - test - - - name: Build - run: | - nix develop -c \ - zig build \ - -Dapp-runtime=gtk \ - -Dgtk-x11=${{ matrix.x11 }} \ - -Dgtk-wayland=${{ matrix.wayland }} - test-gtk-ng: strategy: fail-fast: false diff --git a/CODEOWNERS b/CODEOWNERS index 770c08860..0f7e18ed8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -118,7 +118,6 @@ /pkg/harfbuzz/ @ghostty-org/font # GTK -/src/apprt/gtk/ @ghostty-org/gtk /src/apprt/gtk-ng/ @ghostty-org/gtk /src/os/cgroup.zig @ghostty-org/gtk /src/os/flatpak.zig @ghostty-org/gtk diff --git a/src/apprt.zig b/src/apprt.zig index 2e3a722a6..cbde56312 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -16,7 +16,6 @@ const structs = @import("apprt/structs.zig"); pub const action = @import("apprt/action.zig"); pub const ipc = @import("apprt/ipc.zig"); -pub const gtk = @import("apprt/gtk.zig"); pub const gtk_ng = @import("apprt/gtk-ng.zig"); pub const none = @import("apprt/none.zig"); pub const browser = @import("apprt/browser.zig"); @@ -43,7 +42,6 @@ pub const SurfaceSize = structs.SurfaceSize; pub const runtime = switch (build_config.artifact) { .exe => switch (build_config.app_runtime) { .none => none, - .gtk => gtk, .@"gtk-ng" => gtk_ng, }, .lib => embedded, @@ -64,11 +62,6 @@ pub const Runtime = enum { /// approach to building the application. @"gtk-ng", - /// GTK-backed. Rich windowed application. GTK is dynamically linked. - /// WARNING: Deprecated. This will be removed very soon. All bug fixes - /// and features should go into the gtk-ng backend. - gtk, - pub fn default(target: std.Target) Runtime { return switch (target.os.tag) { // The Linux and FreeBSD default is GTK because it is a full diff --git a/src/apprt/action.zig b/src/apprt/action.zig index a41a4627f..fdd328a24 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -542,7 +542,7 @@ pub const InitialSize = extern struct { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .@"gtk-ng" => @import("gobject").ext.defineBoxed( InitialSize, .{ .name = "GhosttyApprtInitialSize" }, ), diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig deleted file mode 100644 index 3193065c4..000000000 --- a/src/apprt/gtk.zig +++ /dev/null @@ -1,12 +0,0 @@ -//! Application runtime that uses GTK4. - -pub const App = @import("gtk/App.zig"); -pub const Surface = @import("gtk/Surface.zig"); -pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir; - -test { - @import("std").testing.refAllDecls(@This()); - - _ = @import("gtk/inspector.zig"); - _ = @import("gtk/key.zig"); -} diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig deleted file mode 100644 index ee5f3eb96..000000000 --- a/src/apprt/gtk/App.zig +++ /dev/null @@ -1,1900 +0,0 @@ -/// App is the entrypoint for the application. This is called after all -/// of the runtime-agnostic initialization is complete and we're ready -/// to start. -/// -/// There is only ever one App instance per process. This is because most -/// application frameworks also have this restriction so it simplifies -/// the assumptions. -/// -/// In GTK, the App contains the primary GApplication and GMainContext -/// (event loop) along with any global app state. -const App = @This(); - -const adw = @import("adw"); -const gdk = @import("gdk"); -const gio = @import("gio"); -const glib = @import("glib"); -const gobject = @import("gobject"); -const gtk = @import("gtk"); - -const std = @import("std"); -const assert = std.debug.assert; -const testing = std.testing; -const Allocator = std.mem.Allocator; -const builtin = @import("builtin"); -const build_config = @import("../../build_config.zig"); -const xev = @import("../../global.zig").xev; -const build_options = @import("build_options"); -const apprt = @import("../../apprt.zig"); -const configpkg = @import("../../config.zig"); -const input = @import("../../input.zig"); -const internal_os = @import("../../os/main.zig"); -const systemd = @import("../../os/systemd.zig"); -const terminal = @import("../../terminal/main.zig"); -const Config = configpkg.Config; -const CoreApp = @import("../../App.zig"); -const CoreSurface = @import("../../Surface.zig"); -const ipc = @import("ipc.zig"); - -const cgroup = @import("cgroup.zig"); -const Surface = @import("Surface.zig"); -const Window = @import("Window.zig"); -const ConfigErrorsDialog = @import("ConfigErrorsDialog.zig"); -const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig"); -const CloseDialog = @import("CloseDialog.zig"); -const GlobalShortcuts = @import("GlobalShortcuts.zig"); -const Split = @import("Split.zig"); -const inspector = @import("inspector.zig"); -const key = @import("key.zig"); -const winprotopkg = @import("winproto.zig"); -const gtk_version = @import("gtk_version.zig"); -const adw_version = @import("adw_version.zig"); - -pub const c = @cImport({ - // generated header files - @cInclude("ghostty_resources.h"); -}); - -const log = std.log.scoped(.gtk); - -/// This is detected by the Renderer, in which case it sends a `redraw_surface` -/// message so that we can call `drawFrame` ourselves from the app thread, -/// because GTK's `GLArea` does not support drawing from a different thread. -pub const must_draw_from_app_thread = true; - -pub const Options = struct {}; - -core_app: *CoreApp, -config: Config, - -app: *adw.Application, -ctx: *glib.MainContext, - -/// State and logic for the underlying windowing protocol. -winproto: winprotopkg.App, - -/// True if the app was launched with single instance mode. -single_instance: bool, - -/// The "none" cursor. We use one that is shared across the entire app. -cursor_none: ?*gdk.Cursor, - -/// The clipboard confirmation window, if it is currently open. -clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, - -/// The config errors dialog, if it is currently open. -config_errors_dialog: ?ConfigErrorsDialog = null, - -/// The window containing the quick terminal. -/// Null when never initialized. -quick_terminal: ?*Window = null, - -/// This is set to false when the main loop should exit. -running: bool = true, - -/// The base path of the transient cgroup used to put all surfaces -/// into their own cgroup. This is only set if cgroups are enabled -/// and initialization was successful. -transient_cgroup_base: ?[]const u8 = null, - -/// CSS Provider for any styles based on ghostty configuration values -css_provider: *gtk.CssProvider, - -/// Providers for loading custom stylesheets defined by user -custom_css_providers: std.ArrayListUnmanaged(*gtk.CssProvider) = .{}, - -global_shortcuts: ?GlobalShortcuts, - -/// The timer used to quit the application after the last window is closed. -quit_timer: union(enum) { - off: void, - active: c_uint, - expired: void, -} = .{ .off = {} }, - -pub fn init(self: *App, core_app: *CoreApp, opts: Options) !void { - _ = opts; - - // Log our GTK version - gtk_version.logVersion(); - - // log the adwaita version - adw_version.logVersion(); - - // Set gettext global domain to be our app so that our unqualified - // translations map to our translations. - try internal_os.i18n.initGlobalDomain(); - - // Load our configuration - var config = try Config.load(core_app.alloc); - errdefer config.deinit(); - - // If we had configuration errors, then log them. - if (!config._diagnostics.empty()) { - var buf = std.ArrayList(u8).init(core_app.alloc); - defer buf.deinit(); - for (config._diagnostics.items()) |diag| { - try diag.write(buf.writer()); - log.warn("configuration error: {s}", .{buf.items}); - buf.clearRetainingCapacity(); - } - - // If we have any CLI errors, exit. - if (config._diagnostics.containsLocation(.cli)) { - log.warn("CLI errors detected, exiting", .{}); - std.posix.exit(1); - } - } - - // Setup our event loop backend - if (config.@"async-backend" != .auto) { - const result: bool = switch (config.@"async-backend") { - .auto => unreachable, - .epoll => if (comptime xev.dynamic) xev.prefer(.epoll) else false, - .io_uring => if (comptime xev.dynamic) xev.prefer(.io_uring) else false, - }; - - if (result) { - log.info( - "libxev manual backend={s}", - .{@tagName(xev.backend)}, - ); - } else { - log.warn( - "libxev manual backend failed, using default={s}", - .{@tagName(xev.backend)}, - ); - } - } - - var gdk_debug: struct { - /// output OpenGL debug information - opengl: bool = false, - /// disable GLES, Ghostty can't use GLES - @"gl-disable-gles": bool = false, - // GTK's new renderer can cause blurry font when using fractional scaling. - @"gl-no-fractional": bool = false, - /// Disabling Vulkan can improve startup times by hundreds of - /// milliseconds on some systems. We don't use Vulkan so we can just - /// disable it. - @"vulkan-disable": bool = false, - } = .{ - .opengl = config.@"gtk-opengl-debug", - }; - - var gdk_disable: struct { - @"gles-api": bool = false, - /// current gtk implementation for color management is not good enough. - /// see: https://bugs.kde.org/show_bug.cgi?id=495647 - /// gtk issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6864 - @"color-mgmt": bool = true, - /// Disabling Vulkan can improve startup times by hundreds of - /// milliseconds on some systems. We don't use Vulkan so we can just - /// disable it. - vulkan: bool = false, - } = .{}; - - environment: { - if (gtk_version.runtimeAtLeast(4, 18, 0)) { - gdk_disable.@"color-mgmt" = false; - } - - if (gtk_version.runtimeAtLeast(4, 16, 0)) { - // From gtk 4.16, GDK_DEBUG is split into GDK_DEBUG and GDK_DISABLE. - // For the remainder of "why" see the 4.14 comment below. - gdk_disable.@"gles-api" = true; - gdk_disable.vulkan = true; - break :environment; - } - if (gtk_version.runtimeAtLeast(4, 14, 0)) { - // We need to export GDK_DEBUG to run on Wayland after GTK 4.14. - // Older versions of GTK do not support these values so it is safe - // to always set this. Forwards versions are uncertain so we'll have - // to reassess... - // - // Upstream issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/6589 - gdk_debug.@"gl-disable-gles" = true; - gdk_debug.@"vulkan-disable" = true; - - if (gtk_version.runtimeUntil(4, 17, 5)) { - // Removed at GTK v4.17.5 - gdk_debug.@"gl-no-fractional" = true; - } - break :environment; - } - // Versions prior to 4.14 are a bit of an unknown for Ghostty. It - // is an environment that isn't tested well and we don't have a - // good understanding of what we may need to do. - gdk_debug.@"vulkan-disable" = true; - } - - { - var buf: [128]u8 = undefined; - var fmt = std.io.fixedBufferStream(&buf); - const writer = fmt.writer(); - var first: bool = true; - inline for (@typeInfo(@TypeOf(gdk_debug)).@"struct".fields) |field| { - if (@field(gdk_debug, field.name)) { - if (!first) try writer.writeAll(","); - try writer.writeAll(field.name); - first = false; - } - } - try writer.writeByte(0); - const value = fmt.getWritten(); - log.warn("setting GDK_DEBUG={s}", .{value[0 .. value.len - 1]}); - _ = internal_os.setenv("GDK_DEBUG", value[0 .. value.len - 1 :0]); - } - - { - var buf: [128]u8 = undefined; - var fmt = std.io.fixedBufferStream(&buf); - const writer = fmt.writer(); - var first: bool = true; - inline for (@typeInfo(@TypeOf(gdk_disable)).@"struct".fields) |field| { - if (@field(gdk_disable, field.name)) { - if (!first) try writer.writeAll(","); - try writer.writeAll(field.name); - first = false; - } - } - try writer.writeByte(0); - const value = fmt.getWritten(); - log.warn("setting GDK_DISABLE={s}", .{value[0 .. value.len - 1]}); - _ = internal_os.setenv("GDK_DISABLE", value[0 .. value.len - 1 :0]); - } - - adw.init(); - - const display: *gdk.Display = gdk.Display.getDefault() orelse { - // I'm unsure of any scenario where this happens. Because we don't - // want to litter null checks everywhere, we just exit here. - log.warn("gdk display is null, exiting", .{}); - std.posix.exit(1); - }; - - // The "none" cursor is used for hiding the cursor - const cursor_none = gdk.Cursor.newFromName("none", null); - errdefer if (cursor_none) |cursor| cursor.unref(); - - const single_instance = switch (config.@"gtk-single-instance") { - .true => true, - .false => false, - .desktop => switch (config.@"launched-from".?) { - .desktop, .systemd, .dbus => true, - .cli => false, - }, - }; - - // Setup the flags for our application. - const app_flags: gio.ApplicationFlags = app_flags: { - var flags: gio.ApplicationFlags = .flags_default_flags; - if (!single_instance) flags.non_unique = true; - break :app_flags flags; - }; - - // Our app ID determines uniqueness and maps to our desktop file. - // We append "-debug" to the ID if we're in debug mode so that we - // can develop Ghostty in Ghostty. - const app_id: [:0]const u8 = app_id: { - if (config.class) |class| { - if (gio.Application.idIsValid(class) != 0) { - break :app_id class; - } else { - log.warn("invalid 'class' in config, ignoring", .{}); - } - } - - const default_id = comptime build_config.bundle_id; - break :app_id if (builtin.mode == .Debug) default_id ++ "-debug" else default_id; - }; - - // Create our GTK Application which encapsulates our process. - log.debug("creating GTK application id={s} single-instance={}", .{ - app_id, - single_instance, - }); - - // Using an AdwApplication lets us use Adwaita widgets and access things - // such as the color scheme. - const adw_app = adw.Application.new( - app_id.ptr, - app_flags, - ); - errdefer adw_app.unref(); - - const style_manager = adw_app.getStyleManager(); - style_manager.setColorScheme( - switch (config.@"window-theme") { - .auto, .ghostty => auto: { - const lum = config.background.toTerminalRGB().perceivedLuminance(); - break :auto if (lum > 0.5) - .prefer_light - else - .prefer_dark; - }, - .system => .prefer_light, - .dark => .force_dark, - .light => .force_light, - }, - ); - - const gio_app = adw_app.as(gio.Application); - - // force the resource path to a known value so that it doesn't depend on - // the app id and load in compiled resources - gio_app.setResourceBasePath("/com/mitchellh/ghostty"); - gio.resourcesRegister(@ptrCast(@alignCast(c.ghostty_get_resource() orelse { - log.err("unable to load resources", .{}); - return error.GtkNoResources; - }))); - - // The `activate` signal is used when Ghostty is first launched and when a - // secondary Ghostty is launched and requests a new window. - _ = gio.Application.signals.activate.connect( - adw_app, - *CoreApp, - gtkActivate, - core_app, - .{}, - ); - - // Other signals - _ = gtk.Application.signals.window_added.connect( - adw_app, - *CoreApp, - gtkWindowAdded, - core_app, - .{}, - ); - _ = gtk.Application.signals.window_removed.connect( - adw_app, - *CoreApp, - gtkWindowRemoved, - core_app, - .{}, - ); - - // Setup a listener for SIGUSR2 to reload the configuration. - _ = glib.unixSignalAdd( - std.posix.SIG.USR2, - sigusr2, - self, - ); - - // We don't use g_application_run, we want to manually control the - // loop so we have to do the same things the run function does: - // https://github.com/GNOME/glib/blob/a8e8b742e7926e33eb635a8edceac74cf239d6ed/gio/gapplication.c#L2533 - const ctx = glib.MainContext.default(); - if (glib.MainContext.acquire(ctx) == 0) return error.GtkContextAcquireFailed; - errdefer glib.MainContext.release(ctx); - - var err_: ?*glib.Error = null; - if (gio_app.register( - null, - &err_, - ) == 0) { - if (err_) |err| { - log.warn("error registering application: {s}", .{err.f_message orelse "(unknown)"}); - err.free(); - } - return error.GtkApplicationRegisterFailed; - } - - // Setup our windowing protocol logic - var winproto_app = try winprotopkg.App.init( - core_app.alloc, - display, - app_id, - &config, - ); - errdefer winproto_app.deinit(core_app.alloc); - log.debug("windowing protocol={s}", .{@tagName(winproto_app)}); - - // This just calls the `activate` signal but its part of the normal startup - // routine so we just call it, but only if the config allows it (this allows - // for launching Ghostty in the "background" without immediately opening - // a window). An initial window will not be immediately created if we were - // launched by D-Bus activation or systemd. D-Bus activation will send it's - // own `activate` or `new-window` signal later. - // - // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 - if (config.@"initial-window") switch (config.@"launched-from".?) { - .desktop, .cli => gio_app.activate(), - .dbus, .systemd => {}, - }; - - // Internally, GTK ensures that only one instance of this provider exists in the provider list - // for the display. - const css_provider = gtk.CssProvider.new(); - gtk.StyleContext.addProviderForDisplay( - display, - css_provider.as(gtk.StyleProvider), - gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 3, - ); - - self.* = .{ - .core_app = core_app, - .app = adw_app, - .config = config, - .ctx = ctx, - .cursor_none = cursor_none, - .winproto = winproto_app, - .single_instance = single_instance, - // If we are NOT the primary instance, then we never want to run. - // This means that another instance of the GTK app is running and - // our "activate" call above will open a window. - .running = gio_app.getIsRemote() == 0, - .css_provider = css_provider, - .global_shortcuts = .init(core_app.alloc, gio_app), - }; -} - -// Terminate the application. The application will not be restarted after -// this so all global state can be cleaned up. -pub fn terminate(self: *App) void { - gio.Settings.sync(); - while (glib.MainContext.iteration(self.ctx, 0) != 0) {} - glib.MainContext.release(self.ctx); - self.app.unref(); - - if (self.cursor_none) |cursor| cursor.unref(); - if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path); - - for (self.custom_css_providers.items) |provider| { - provider.unref(); - } - self.custom_css_providers.deinit(self.core_app.alloc); - - self.winproto.deinit(self.core_app.alloc); - - if (self.global_shortcuts) |*shortcuts| shortcuts.deinit(); - - self.config.deinit(); -} - -/// Perform a given action. Returns `true` if the action was able to be -/// performed, `false` otherwise. -pub fn performAction( - self: *App, - target: apprt.Target, - comptime action: apprt.Action.Key, - value: apprt.Action.Value(action), -) !bool { - switch (action) { - .quit => self.quit(), - .new_window => _ = try self.newWindow(switch (target) { - .app => null, - .surface => |v| v, - }), - .close_window => return try self.closeWindow(target), - .toggle_maximize => self.toggleMaximize(target), - .toggle_fullscreen => self.toggleFullscreen(target, value), - .new_tab => try self.newTab(target), - .close_tab => return try self.closeTab(target, value), - .goto_tab => return self.gotoTab(target, value), - .move_tab => self.moveTab(target, value), - .new_split => try self.newSplit(target, value), - .resize_split => self.resizeSplit(target, value), - .equalize_splits => self.equalizeSplits(target), - .goto_split => return self.gotoSplit(target, value), - .open_config => return self.openConfig(), - .config_change => self.configChange(target, value.config), - .reload_config => try self.reloadConfig(target, value), - .inspector => self.controlInspector(target, value), - .show_gtk_inspector => self.showGTKInspector(), - .desktop_notification => self.showDesktopNotification(target, value), - .set_title => try self.setTitle(target, value), - .pwd => try self.setPwd(target, value), - .present_terminal => self.presentTerminal(target), - .initial_size => try self.setInitialSize(target, value), - .size_limit => try self.setSizeLimit(target, value), - .mouse_visibility => self.setMouseVisibility(target, value), - .mouse_shape => try self.setMouseShape(target, value), - .mouse_over_link => self.setMouseOverLink(target, value), - .toggle_tab_overview => self.toggleTabOverview(target), - .toggle_split_zoom => self.toggleSplitZoom(target), - .toggle_window_decorations => self.toggleWindowDecorations(target), - .quit_timer => self.quitTimer(value), - .prompt_title => try self.promptTitle(target), - .toggle_quick_terminal => return try self.toggleQuickTerminal(), - .secure_input => self.setSecureInput(target, value), - .ring_bell => try self.ringBell(target), - .toggle_command_palette => try self.toggleCommandPalette(target), - .open_url => self.openUrl(value), - .show_child_exited => return try self.showChildExited(target, value), - .progress_report => return try self.handleProgressReport(target, value), - .render => self.render(target), - - // Unimplemented - .close_all_windows, - .float_window, - .toggle_visibility, - .cell_size, - .key_sequence, - .render_inspector, - .renderer_health, - .color_change, - .reset_window_size, - .check_for_updates, - .undo, - .redo, - .show_on_screen_keyboard, - => { - log.warn("unimplemented action={}", .{action}); - return false; - }, - } - - // We can assume it was handled because all unknown/unimplemented actions - // are caught above. - return true; -} - -/// Send the given IPC to a running Ghostty. Returns `true` if the action was -/// able to be performed, `false` otherwise. -/// -/// Note that this is a static function. Since this is called from a CLI app (or -/// some other process that is not Ghostty) there is no full-featured apprt App -/// to use. -pub fn performIpc( - alloc: Allocator, - target: apprt.ipc.Target, - comptime action: apprt.ipc.Action.Key, - value: apprt.ipc.Action.Value(action), -) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { - switch (action) { - .new_window => return try ipc.openNewWindow(alloc, target, value), - } -} - -fn newTab(_: *App, target: apprt.Target) !void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "new_tab invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - - try window.newTab(v); - }, - } -} - -fn closeTab(_: *App, target: apprt.Target, value: apprt.Action.Value(.close_tab)) !bool { - switch (target) { - .app => return false, - .surface => |v| { - const tab = v.rt_surface.container.tab() orelse { - log.info( - "close_tab invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return false; - }; - - switch (value) { - .this => { - tab.closeWithConfirmation(); - return true; - }, - .other => { - log.warn("close-tab:other is not implemented", .{}); - return false; - }, - } - }, - } -} - -fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) bool { - switch (target) { - .app => return false, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "gotoTab invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return false; - }; - - return switch (tab) { - .previous => window.gotoPreviousTab(v.rt_surface), - .next => window.gotoNextTab(v.rt_surface), - .last => window.gotoLastTab(), - else => window.gotoTab(@intCast(@intFromEnum(tab))), - }; - }, - } -} - -fn moveTab(_: *App, target: apprt.Target, move_tab: apprt.action.MoveTab) void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "moveTab invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - - window.moveTab(v.rt_surface, @intCast(move_tab.amount)); - }, - } -} - -fn newSplit( - self: *App, - target: apprt.Target, - direction: apprt.action.SplitDirection, -) !void { - switch (target) { - .app => {}, - .surface => |v| { - const alloc = self.core_app.alloc; - _ = try Split.create(alloc, v.rt_surface, direction); - }, - } -} - -fn equalizeSplits(_: *App, target: apprt.Target) void { - switch (target) { - .app => {}, - .surface => |v| { - const tab = v.rt_surface.container.tab() orelse return; - const top_split = switch (tab.elem) { - .split => |s| s, - else => return, - }; - _ = top_split.equalize(); - }, - } -} - -fn gotoSplit( - _: *const App, - target: apprt.Target, - direction: apprt.action.GotoSplit, -) bool { - switch (target) { - .app => return false, - .surface => |v| { - const s = v.rt_surface.container.split() orelse return false; - const map = s.directionMap(switch (v.rt_surface.container) { - .split_tl => .top_left, - .split_br => .bottom_right, - .none, .tab_ => unreachable, - }); - const surface_ = map.get(direction) orelse return false; - if (surface_) |surface| { - surface.grabFocus(); - return true; - } - return false; - }, - } -} - -fn resizeSplit( - _: *const App, - target: apprt.Target, - resize: apprt.action.ResizeSplit, -) void { - switch (target) { - .app => {}, - .surface => |v| { - const s = v.rt_surface.container.firstSplitWithOrientation( - Split.Orientation.fromResizeDirection(resize.direction), - ) orelse return; - s.moveDivider(resize.direction, resize.amount); - }, - } -} - -fn presentTerminal( - _: *const App, - target: apprt.Target, -) void { - switch (target) { - .app => {}, - .surface => |v| v.rt_surface.present(), - } -} - -fn controlInspector( - _: *const App, - target: apprt.Target, - mode: apprt.action.Inspector, -) void { - const surface: *Surface = switch (target) { - .app => return, - .surface => |v| v.rt_surface, - }; - - surface.controlInspector(mode); -} - -fn showGTKInspector( - _: *const App, -) void { - gtk.Window.setInteractiveDebugging(@intFromBool(true)); -} - -fn toggleMaximize(_: *App, target: apprt.Target) void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "toggleMaximize invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - window.toggleMaximize(); - }, - } -} - -fn toggleFullscreen( - _: *App, - target: apprt.Target, - _: apprt.action.Fullscreen, -) void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "toggleFullscreen invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - - window.toggleFullscreen(); - }, - } -} - -fn toggleTabOverview(_: *App, target: apprt.Target) void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "toggleTabOverview invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - - window.toggleTabOverview(); - }, - } -} - -fn toggleSplitZoom(_: *App, target: apprt.Target) void { - switch (target) { - .app => {}, - .surface => |surface| surface.rt_surface.toggleSplitZoom(), - } -} - -fn toggleWindowDecorations( - _: *App, - target: apprt.Target, -) void { - switch (target) { - .app => {}, - .surface => |v| { - const window = v.rt_surface.container.window() orelse { - log.info( - "toggleWindowDecorations invalid for container={s}", - .{@tagName(v.rt_surface.container)}, - ); - return; - }; - - window.toggleWindowDecorations(); - }, - } -} - -fn toggleQuickTerminal(self: *App) !bool { - if (self.quick_terminal) |qt| { - qt.toggleVisibility(); - return true; - } - - if (!self.winproto.supportsQuickTerminal()) return false; - - const qt = Window.create(self.core_app.alloc, self) catch |err| { - log.err("failed to initialize quick terminal={}", .{err}); - return true; - }; - self.quick_terminal = qt; - - // The setup has to happen *before* the window-specific winproto is - // initialized, so we need to initialize it through the app winproto - try self.winproto.initQuickTerminal(qt); - - // Finalize creating the quick terminal - try qt.newTab(null); - qt.present(); - return true; -} - -fn ringBell(_: *App, target: apprt.Target) !void { - switch (target) { - .app => {}, - .surface => |surface| try surface.rt_surface.ringBell(), - } -} - -fn toggleCommandPalette(_: *App, target: apprt.Target) !void { - switch (target) { - .app => {}, - .surface => |surface| { - const window = surface.rt_surface.container.window() orelse { - log.info( - "toggleCommandPalette invalid for container={s}", - .{@tagName(surface.rt_surface.container)}, - ); - return; - }; - - window.toggleCommandPalette(); - }, - } -} - -fn showChildExited(_: *App, target: apprt.Target, value: apprt.surface.Message.ChildExited) error{}!bool { - switch (target) { - .app => return false, - .surface => |surface| return try surface.rt_surface.showChildExited(value), - } -} - -/// Show a native GUI element to indicate the progress of a TUI operation. -fn handleProgressReport(_: *App, target: apprt.Target, value: terminal.osc.Command.ProgressReport) error{}!bool { - switch (target) { - .app => return false, - .surface => |surface| return try surface.rt_surface.progress_bar.handleProgressReport(value), - } -} - -fn render(_: *App, target: apprt.Target) void { - switch (target) { - .app => {}, - .surface => |v| v.rt_surface.redraw(), - } -} - -fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { - switch (mode) { - .start => self.startQuitTimer(), - .stop => self.stopQuitTimer(), - } -} - -fn promptTitle(_: *App, target: apprt.Target) !void { - switch (target) { - .app => {}, - .surface => |v| { - try v.rt_surface.promptTitle(); - }, - } -} - -fn setTitle( - _: *App, - target: apprt.Target, - title: apprt.action.SetTitle, -) !void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setTitle(title.title, .terminal), - } -} - -fn setPwd( - _: *App, - target: apprt.Target, - pwd: apprt.action.Pwd, -) !void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setPwd(pwd.pwd), - } -} - -fn setMouseVisibility( - _: *App, - target: apprt.Target, - visibility: apprt.action.MouseVisibility, -) void { - switch (target) { - .app => {}, - .surface => |v| v.rt_surface.setMouseVisibility(switch (visibility) { - .visible => true, - .hidden => false, - }), - } -} - -fn setMouseShape( - _: *App, - target: apprt.Target, - shape: terminal.MouseShape, -) !void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setMouseShape(shape), - } -} - -fn setMouseOverLink( - _: *App, - target: apprt.Target, - value: apprt.action.MouseOverLink, -) void { - switch (target) { - .app => {}, - .surface => |v| v.rt_surface.mouseOverLink(if (value.url.len > 0) - value.url - else - null), - } -} - -fn setInitialSize( - _: *App, - target: apprt.Target, - value: apprt.action.InitialSize, -) !void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setInitialWindowSize( - value.width, - value.height, - ), - } -} - -fn setSizeLimit( - _: *App, - target: apprt.Target, - value: apprt.action.SizeLimit, -) !void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setSizeLimits(.{ - .width = value.min_width, - .height = value.min_height, - }, if (value.max_width > 0) .{ - .width = value.max_width, - .height = value.max_height, - } else null), - } -} - -fn showDesktopNotification( - self: *App, - target: apprt.Target, - n: apprt.action.DesktopNotification, -) void { - // Set a default title if we don't already have one - const t = switch (n.title.len) { - 0 => "Ghostty", - else => n.title, - }; - - const notification = gio.Notification.new(t); - defer notification.unref(); - notification.setBody(n.body); - - const icon = gio.ThemedIcon.new("com.mitchellh.ghostty"); - defer icon.unref(); - notification.setIcon(icon.as(gio.Icon)); - - const pointer = glib.Variant.newUint64(switch (target) { - .app => 0, - .surface => |v| @intFromPtr(v), - }); - notification.setDefaultActionAndTargetValue("app.present-surface", pointer); - - const gio_app = self.app.as(gio.Application); - - // We set the notification ID to the body content. If the content is the - // same, this notification may replace a previous notification - gio_app.sendNotification(n.body, notification); -} - -fn configChange( - self: *App, - target: apprt.Target, - new_config: *const Config, -) void { - switch (target) { - .surface => |surface| surface: { - surface.rt_surface.updateConfig(new_config) catch |err| { - log.err("unable to update surface config: {}", .{err}); - }; - const window = surface.rt_surface.container.window() orelse break :surface; - window.updateConfig(new_config) catch |err| { - log.warn("error updating config for window err={}", .{err}); - }; - }, - - .app => { - // We clone (to take ownership) and update our configuration. - if (new_config.clone(self.core_app.alloc)) |config_clone| { - self.config.deinit(); - self.config = config_clone; - } else |err| { - log.warn("error cloning configuration err={}", .{err}); - } - - // App changes needs to show a toast that our configuration - // has reloaded. - const window = window: { - if (self.core_app.focusedSurface()) |core_surface| { - const surface = core_surface.rt_surface; - if (surface.container.window()) |window| { - window.onConfigReloaded(); - break :window window; - } - } - break :window null; - }; - - self.syncConfigChanges(window) catch |err| { - log.warn("error handling configuration changes err={}", .{err}); - }; - }, - } -} - -pub fn reloadConfig( - self: *App, - target: apprt.action.Target, - opts: apprt.action.ReloadConfig, -) !void { - // Tell systemd that reloading has started. - systemd.notify.reloading(); - - // When we exit this function tell systemd that reloading has finished. - defer systemd.notify.ready(); - - if (opts.soft) { - switch (target) { - .app => try self.core_app.updateConfig(self, &self.config), - .surface => |core_surface| try core_surface.updateConfig( - &self.config, - ), - } - return; - } - - // Load our configuration - var config = try Config.load(self.core_app.alloc); - errdefer config.deinit(); - - // Call into our app to update - switch (target) { - .app => try self.core_app.updateConfig(self, &config), - .surface => |core_surface| try core_surface.updateConfig(&config), - } - - // Update the existing config, be sure to clean up the old one. - self.config.deinit(); - self.config = config; -} - -/// Call this anytime the configuration changes. -fn syncConfigChanges(self: *App, window: ?*Window) !void { - ConfigErrorsDialog.maybePresent(self, window); - try self.syncActionAccelerators(); - - if (self.global_shortcuts) |*shortcuts| { - shortcuts.refreshSession(self) catch |err| { - log.warn("failed to refresh global shortcuts={}", .{err}); - }; - } - - // Load our runtime and custom CSS. If this fails then our window is just stuck - // with the old CSS but we don't want to fail the entire sync operation. - self.loadRuntimeCss() catch |err| switch (err) { - error.OutOfMemory => log.warn( - "out of memory loading runtime CSS, no runtime CSS applied", - .{}, - ), - }; - self.loadCustomCss() catch |err| { - log.warn("Failed to load custom CSS, no custom CSS applied, err={}", .{err}); - }; -} - -fn syncActionAccelerators(self: *App) !void { - try self.syncActionAccelerator("app.quit", .{ .quit = {} }); - try self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); - try self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); - try self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); - try self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector); - try self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette); - try self.syncActionAccelerator("win.close", .{ .close_window = {} }); - try self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); - try self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} }); - try self.syncActionAccelerator("win.close-tab", .{ .close_tab = .this }); - try self.syncActionAccelerator("win.split-right", .{ .new_split = .right }); - try self.syncActionAccelerator("win.split-down", .{ .new_split = .down }); - try self.syncActionAccelerator("win.split-left", .{ .new_split = .left }); - try self.syncActionAccelerator("win.split-up", .{ .new_split = .up }); - try self.syncActionAccelerator("win.copy", .{ .copy_to_clipboard = {} }); - try self.syncActionAccelerator("win.paste", .{ .paste_from_clipboard = {} }); - try self.syncActionAccelerator("win.reset", .{ .reset = {} }); - try self.syncActionAccelerator("win.clear", .{ .clear_screen = {} }); - try self.syncActionAccelerator("win.prompt-title", .{ .prompt_surface_title = {} }); -} - -fn syncActionAccelerator( - self: *App, - gtk_action: [:0]const u8, - action: input.Binding.Action, -) !void { - const gtk_app = self.app.as(gtk.Application); - - // Reset it initially - const zero = [_:null]?[*:0]const u8{}; - gtk_app.setAccelsForAction(gtk_action, &zero); - - const trigger = self.config.keybind.set.getTrigger(action) orelse return; - var buf: [256]u8 = undefined; - const accel = try key.accelFromTrigger(&buf, trigger) orelse return; - const accels = [_:null]?[*:0]const u8{accel}; - - gtk_app.setAccelsForAction(gtk_action, &accels); -} - -fn loadRuntimeCss( - self: *const App, -) Allocator.Error!void { - const alloc = self.core_app.alloc; - - var buf: std.ArrayListUnmanaged(u8) = .empty; - defer buf.deinit(alloc); - - const writer = buf.writer(alloc); - - const config: *const Config = &self.config; - const window_theme = config.@"window-theme"; - const unfocused_fill: Config.Color = config.@"unfocused-split-fill" orelse config.background; - const headerbar_background = config.@"window-titlebar-background" orelse config.background; - const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground; - - try writer.print( - \\widget.unfocused-split {{ - \\ opacity: {d:.2}; - \\ background-color: rgb({d},{d},{d}); - \\}} - , .{ - 1.0 - config.@"unfocused-split-opacity", - unfocused_fill.r, - unfocused_fill.g, - unfocused_fill.b, - }); - - if (config.@"split-divider-color") |color| { - try writer.print( - \\.terminal-window .notebook separator {{ - \\ color: rgb({[r]d},{[g]d},{[b]d}); - \\ background: rgb({[r]d},{[g]d},{[b]d}); - \\}} - , .{ - .r = color.r, - .g = color.g, - .b = color.b, - }); - } - - if (config.@"window-title-font-family") |font_family| { - try writer.print( - \\.window headerbar {{ - \\ font-family: "{[font_family]s}"; - \\}} - , .{ .font_family = font_family }); - } - - if (gtk_version.runtimeAtLeast(4, 16, 0)) { - switch (window_theme) { - .ghostty => try writer.print( - \\:root {{ - \\ --ghostty-fg: rgb({d},{d},{d}); - \\ --ghostty-bg: rgb({d},{d},{d}); - \\ --headerbar-fg-color: var(--ghostty-fg); - \\ --headerbar-bg-color: var(--ghostty-bg); - \\ --headerbar-backdrop-color: oklab(from var(--headerbar-bg-color) calc(l * 0.9) a b / alpha); - \\ --overview-fg-color: var(--ghostty-fg); - \\ --overview-bg-color: var(--ghostty-bg); - \\ --popover-fg-color: var(--ghostty-fg); - \\ --popover-bg-color: var(--ghostty-bg); - \\ --window-fg-color: var(--ghostty-fg); - \\ --window-bg-color: var(--ghostty-bg); - \\}} - \\windowhandle {{ - \\ background-color: var(--headerbar-bg-color); - \\ color: var(--headerbar-fg-color); - \\}} - \\windowhandle:backdrop {{ - \\ background-color: var(--headerbar-backdrop-color); - \\}} - , .{ - headerbar_foreground.r, - headerbar_foreground.g, - headerbar_foreground.b, - headerbar_background.r, - headerbar_background.g, - headerbar_background.b, - }), - else => {}, - } - } else { - try writer.print( - \\window.window-theme-ghostty .top-bar, - \\window.window-theme-ghostty .bottom-bar, - \\window.window-theme-ghostty box > tabbar {{ - \\ background-color: rgb({d},{d},{d}); - \\ color: rgb({d},{d},{d}); - \\}} - , .{ - headerbar_background.r, - headerbar_background.g, - headerbar_background.b, - headerbar_foreground.r, - headerbar_foreground.g, - headerbar_foreground.b, - }); - } - - const data = try alloc.dupeZ(u8, buf.items); - defer alloc.free(data); - - // Clears any previously loaded CSS from this provider - loadCssProviderFromData(self.css_provider, data); -} - -fn loadCustomCss(self: *App) !void { - const alloc = self.core_app.alloc; - - const display = gdk.Display.getDefault() orelse { - log.warn("unable to get display", .{}); - return; - }; - - // unload the previously loaded style providers - for (self.custom_css_providers.items) |provider| { - gtk.StyleContext.removeProviderForDisplay( - display, - provider.as(gtk.StyleProvider), - ); - provider.unref(); - } - self.custom_css_providers.clearRetainingCapacity(); - - for (self.config.@"gtk-custom-css".value.items) |p| { - const path, const optional = switch (p) { - .optional => |path| .{ path, true }, - .required => |path| .{ path, false }, - }; - const file = std.fs.openFileAbsolute(path, .{}) catch |err| { - if (err != error.FileNotFound or !optional) { - log.err( - "error opening gtk-custom-css file {s}: {}", - .{ path, err }, - ); - } - continue; - }; - defer file.close(); - - log.info("loading gtk-custom-css path={s}", .{path}); - const contents = try file.reader().readAllAlloc( - self.core_app.alloc, - 5 * 1024 * 1024, // 5MB, - ); - defer alloc.free(contents); - - const data = try alloc.dupeZ(u8, contents); - defer alloc.free(data); - - const provider = gtk.CssProvider.new(); - loadCssProviderFromData(provider, data); - gtk.StyleContext.addProviderForDisplay( - display, - provider.as(gtk.StyleProvider), - gtk.STYLE_PROVIDER_PRIORITY_USER, - ); - - try self.custom_css_providers.append(self.core_app.alloc, provider); - } -} - -fn loadCssProviderFromData(provider: *gtk.CssProvider, data: [:0]const u8) void { - if (gtk_version.atLeast(4, 12, 0)) { - const g_bytes = glib.Bytes.new(data.ptr, data.len); - defer g_bytes.unref(); - - provider.loadFromBytes(g_bytes); - } else { - provider.loadFromData(data, @intCast(data.len)); - } -} - -/// Called by CoreApp to wake up the event loop. -pub fn wakeup(_: App) void { - glib.MainContext.wakeup(null); -} - -/// Run the event loop. This doesn't return until the app exits. -pub fn run(self: *App) !void { - // Running will be false when we're not the primary instance and should - // exit (GTK single instance mode). If we're not running, we're done - // right away. - if (!self.running) return; - - // If we are running, then we proceed to setup our app. - - // Setup our cgroup configurations for our surfaces. - if (switch (self.config.@"linux-cgroup") { - .never => false, - .always => true, - .@"single-instance" => self.single_instance, - }) cgroup: { - const path = cgroup.init(self) catch |err| { - // If we can't initialize cgroups then that's okay. We - // want to continue to run so we just won't isolate surfaces. - // NOTE(mitchellh): do we want a config to force it? - log.warn( - "failed to initialize cgroups, terminals will not be isolated err={}", - .{err}, - ); - - // If we have hard fail enabled then we exit now. - if (self.config.@"linux-cgroup-hard-fail") { - log.err("linux-cgroup-hard-fail enabled, exiting", .{}); - return error.CgroupInitFailed; - } - - break :cgroup; - }; - - log.info("cgroup isolation enabled base={s}", .{path}); - self.transient_cgroup_base = path; - } else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"}); - - // Setup color scheme notifications - const style_manager: *adw.StyleManager = self.app.getStyleManager(); - _ = gobject.Object.signals.notify.connect( - style_manager, - *App, - adwNotifyDark, - self, - .{ - .detail = "dark", - }, - ); - - // Make an initial request to set up the color scheme - const light = style_manager.getDark() == 0; - self.colorSchemeEvent(if (light) .light else .dark); - - // Setup our actions - self.initActions(); - - // On startup, we want to check for configuration errors right away - // so we can show our error window. We also need to setup other initial - // state. - self.syncConfigChanges(null) catch |err| { - log.warn("error handling configuration changes err={}", .{err}); - }; - - // Tell systemd that we are ready. - systemd.notify.ready(); - - while (self.running) { - _ = glib.MainContext.iteration(self.ctx, 1); - - // Tick the terminal app and see if we should quit. - try self.core_app.tick(self); - - // Check if we must quit based on the current state. - const must_quit = q: { - // If we are configured to always stay running, don't quit. - if (!self.config.@"quit-after-last-window-closed") break :q false; - - // If the quit timer has expired, quit. - if (self.quit_timer == .expired) break :q true; - - // There's no quit timer running, or it hasn't expired, don't quit. - break :q false; - }; - - if (must_quit) self.quit(); - } -} - -// This timeout function is started when no surfaces are open. It can be -// cancelled if a new surface is opened before the timer expires. -pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.c) c_int { - const self: *App = @ptrCast(@alignCast(ud)); - self.quit_timer = .{ .expired = {} }; - return 0; -} - -/// This will get called when there are no more open surfaces. -fn startQuitTimer(self: *App) void { - // Cancel any previous timer. - self.stopQuitTimer(); - - // This is a no-op unless we are configured to quit after last window is closed. - if (!self.config.@"quit-after-last-window-closed") return; - - if (self.config.@"quit-after-last-window-closed-delay") |v| { - // If a delay is configured, set a timeout function to quit after the delay. - self.quit_timer = .{ - .active = glib.timeoutAdd( - v.asMilliseconds(), - gtkQuitTimerExpired, - self, - ), - }; - } else { - // If no delay is configured, treat it as expired. - self.quit_timer = .{ .expired = {} }; - } -} - -/// This will get called when a new surface gets opened. -fn stopQuitTimer(self: *App) void { - switch (self.quit_timer) { - .off => {}, - .expired => self.quit_timer = .{ .off = {} }, - .active => |source| { - if (glib.Source.remove(source) == 0) { - log.warn("unable to remove quit timer source={d}", .{source}); - } - self.quit_timer = .{ .off = {} }; - }, - } -} - -/// Redraw the inspector for the given surface. -pub fn redrawInspector(self: *App, surface: *Surface) void { - _ = self; - surface.queueInspectorRender(); -} - -/// Called by CoreApp to create a new window with a new surface. -fn newWindow(self: *App, parent_: ?*CoreSurface) !void { - const alloc = self.core_app.alloc; - - // Allocate a fixed pointer for our window. We try to minimize - // allocations but windows and other GUI requirements are so minimal - // compared to the steady-state terminal operation so we use heap - // allocation for this. - // - // The allocation is owned by the GtkWindow created. It will be - // freed when the window is closed. - var window = try Window.create(alloc, self); - - // Add our initial tab - try window.newTab(parent_); - - // Show the new window - window.present(); -} - -fn setSecureInput(_: *App, target: apprt.Target, value: apprt.action.SecureInput) void { - switch (target) { - .app => {}, - .surface => |surface| { - surface.rt_surface.setSecureInput(value); - }, - } -} - -fn closeWindow(_: *App, target: apprt.action.Target) !bool { - switch (target) { - .app => return false, - .surface => |v| { - const window = v.rt_surface.container.window() orelse return false; - window.closeWithConfirmation(); - return true; - }, - } -} - -fn quit(self: *App) void { - // If we're already not running, do nothing. - if (!self.running) return; - - // If the app says we don't need to confirm, then we can quit now. - if (!self.core_app.needsConfirmQuit()) { - self.quitNow(); - return; - } - - CloseDialog.show(.{ .app = self }) catch |err| { - log.err("failed to open close dialog={}", .{err}); - }; -} - -/// This immediately destroys all windows, forcing the application to quit. -pub fn quitNow(self: *App) void { - const list = gtk.Window.listToplevels(); - defer list.free(); - list.foreach(struct { - fn callback(data: ?*anyopaque, _: ?*anyopaque) callconv(.c) void { - const ptr = data orelse return; - const window: *gtk.Window = @ptrCast(@alignCast(ptr)); - - // We only want to destroy our windows. These windows own - // every other type of window that is possible so this will - // trigger a proper shutdown sequence. - // - // We previously just destroyed ALL windows but this leads to - // a double-free with the fcitx ime, because it has a nested - // gtk.Window as a property that we don't own and it later - // tries to free on its own. I think this is probably a bug in - // the fcitx ime widget but still, we don't want a double free! - // - // Since we don't use gobject directly we can't check class, - // so we use a heuristic based on CSS class. - if (window.as(gtk.Widget).hasCssClass("terminal-window") != 0) { - window.destroy(); - } - } - }.callback, null); - - self.running = false; -} - -// SIGUSR2 signal handler via g_unix_signal_add -fn sigusr2(ud: ?*anyopaque) callconv(.c) c_int { - const self: *App = @ptrCast(@alignCast(ud orelse - return @intFromBool(glib.SOURCE_CONTINUE))); - - log.info("received SIGUSR2, reloading configuration", .{}); - self.reloadConfig(.app, .{ .soft = false }) catch |err| { - log.err( - "error reloading configuration for SIGUSR2: {}", - .{err}, - ); - }; - - return @intFromBool(glib.SOURCE_CONTINUE); -} - -/// This is called by the `activate` signal. This is sent on program startup and -/// also when a secondary instance launches and requests a new window. -fn gtkActivate(_: *adw.Application, core_app: *CoreApp) callconv(.c) void { - // Queue a new window - _ = core_app.mailbox.push(.{ - .new_window = .{}, - }, .{ .forever = {} }); -} - -fn gtkWindowAdded( - _: *adw.Application, - window: *gtk.Window, - core_app: *CoreApp, -) callconv(.c) void { - // Request the is-active property change so we can detect - // when our app loses focus. - _ = gobject.Object.signals.notify.connect( - window, - *CoreApp, - gtkWindowIsActive, - core_app, - .{ - .detail = "is-active", - }, - ); -} - -fn gtkWindowRemoved( - _: *adw.Application, - _: *gtk.Window, - core_app: *CoreApp, -) callconv(.c) void { - // Recheck if we are focused - gtkWindowIsActive(null, undefined, core_app); -} - -fn gtkWindowIsActive( - window: ?*gtk.Window, - _: *gobject.ParamSpec, - core_app: *CoreApp, -) callconv(.c) void { - // If our window is active, then we can tell the app - // that we are focused. - if (window) |w| { - if (w.isActive() != 0) { - core_app.focusEvent(true); - return; - } - } - - // If the window becomes inactive, we need to check if any - // other windows are active. If not, then we are no longer - // focused. - { - const list = gtk.Window.listToplevels(); - defer list.free(); - var current: ?*glib.List = list; - while (current) |elem| : (current = elem.f_next) { - // If the window is active then we are still focused. - // This is another window since we did our check above. - // That window should trigger its own is-active - // callback so we don't need to call it here. - const w: *gtk.Window = @alignCast(@ptrCast(elem.f_data)); - if (w.isActive() == 1) return; - } - } - - // We are not focused - core_app.focusEvent(false); -} - -fn adwNotifyDark( - style_manager: *adw.StyleManager, - _: *gobject.ParamSpec, - self: *App, -) callconv(.c) void { - const color_scheme: apprt.ColorScheme = if (style_manager.getDark() == 0) - .light - else - .dark; - - self.colorSchemeEvent(color_scheme); -} - -fn colorSchemeEvent( - self: *App, - scheme: apprt.ColorScheme, -) void { - self.core_app.colorSchemeEvent(self, scheme) catch |err| { - log.err("error updating app color scheme err={}", .{err}); - }; - - for (self.core_app.surfaces.items) |surface| { - surface.core_surface.colorSchemeCallback(scheme) catch |err| { - log.err("unable to tell surface about color scheme change err={}", .{err}); - }; - } -} - -fn gtkActionOpenConfig( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *App, -) callconv(.c) void { - _ = self.core_app.mailbox.push(.{ - .open_config = {}, - }, .{ .forever = {} }); -} - -fn gtkActionReloadConfig( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *App, -) callconv(.c) void { - self.reloadConfig(.app, .{}) catch |err| { - log.err("error reloading configuration: {}", .{err}); - }; -} - -fn gtkActionQuit( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *App, -) callconv(.c) void { - self.core_app.performAction(self, .quit) catch |err| { - log.err("error quitting err={}", .{err}); - }; -} - -/// Action sent by the window manager asking us to present a specific surface to -/// the user. Usually because the user clicked on a desktop notification. -fn gtkActionPresentSurface( - _: *gio.SimpleAction, - parameter_: ?*glib.Variant, - self: *App, -) callconv(.c) void { - const parameter = parameter_ orelse return; - - const t = glib.ext.VariantType.newFor(u64); - defer glib.VariantType.free(t); - - // Make sure that we've receiived a u64 from the system. - if (glib.Variant.isOfType(parameter, t) == 0) { - return; - } - - // Convert that u64 to pointer to a core surface. A value of zero - // means that there was no target surface for the notification so - // we don't focus any surface. - const ptr_int = parameter.getUint64(); - if (ptr_int == 0) return; - const surface: *CoreSurface = @ptrFromInt(ptr_int); - - // Send a message through the core app mailbox rather than presenting the - // surface directly so that it can validate that the surface pointer is - // valid. We could get an invalid pointer if a desktop notification outlives - // a Ghostty instance and a new one starts up, or there are multiple Ghostty - // instances running. - _ = self.core_app.mailbox.push( - .{ - .surface_message = .{ - .surface = surface, - .message = .{ .present_surface = {} }, - }, - }, - .{ .forever = {} }, - ); -} - -fn gtkActionShowGTKInspector( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *App, -) callconv(.c) void { - self.core_app.performAction(self, .show_gtk_inspector) catch |err| { - log.err("error showing GTK inspector err={}", .{err}); - }; -} - -fn gtkActionNewWindow( - _: *gio.SimpleAction, - parameter_: ?*glib.Variant, - self: *App, -) callconv(.c) void { - log.debug("received new window action", .{}); - - parameter: { - // were we given a parameter? - const parameter = parameter_ orelse break :parameter; - - const as = glib.VariantType.new("as"); - defer as.free(); - - // ensure that the supplied parameter is an array of strings - if (glib.Variant.isOfType(parameter, as) == 0) { - log.warn("parameter is of type {s}", .{parameter.getTypeString()}); - break :parameter; - } - - const s = glib.VariantType.new("s"); - defer s.free(); - - var it: glib.VariantIter = undefined; - _ = it.init(parameter); - - while (it.nextValue()) |value| { - defer value.unref(); - - // just to be sure - if (value.isOfType(s) == 0) continue; - - var len: usize = undefined; - const buf = value.getString(&len); - const str = buf[0..len]; - - log.debug("new-window command argument: {s}", .{str}); - } - } - - _ = self.core_app.mailbox.push(.{ - .new_window = .{}, - }, .{ .forever = {} }); -} - -/// This is called to setup the action map that this application supports. -/// This should be called only once on startup. -fn initActions(self: *App) void { - // The set of actions. Each action has (in order): - // [0] The action name - // [1] The callback function - // [2] The GVariantType of the parameter - // - // For action names: - // https://docs.gtk.org/gio/type_func.Action.name_is_valid.html - const t = glib.ext.VariantType.newFor(u64); - defer t.free(); - - const as = glib.VariantType.new("as"); - defer as.free(); - - const actions = .{ - .{ "quit", gtkActionQuit, null }, - .{ "open-config", gtkActionOpenConfig, null }, - .{ "reload-config", gtkActionReloadConfig, null }, - .{ "present-surface", gtkActionPresentSurface, t }, - .{ "show-gtk-inspector", gtkActionShowGTKInspector, null }, - .{ "new-window", gtkActionNewWindow, null }, - .{ "new-window-command", gtkActionNewWindow, as }, - }; - - inline for (actions) |entry| { - const action = gio.SimpleAction.new(entry[0], entry[2]); - defer action.unref(); - _ = gio.SimpleAction.signals.activate.connect( - action, - *App, - entry[1], - self, - .{}, - ); - const action_map = self.app.as(gio.ActionMap); - action_map.addAction(action.as(gio.Action)); - } -} - -fn openConfig(self: *App) !bool { - // Get the config file path - const alloc = self.core_app.alloc; - const path = configpkg.edit.openPath(alloc) catch |err| { - log.warn("error getting config file path: {}", .{err}); - return false; - }; - defer alloc.free(path); - - // Open it using openURL. "path" isn't actually a URL but - // at the time of writing that works just fine for GTK. - self.openUrl(.{ .kind = .text, .url = path }); - return true; -} - -fn openUrl( - app: *App, - value: apprt.action.OpenUrl, -) void { - // TODO: use https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.OpenURI.html - - // Fallback to the minimal cross-platform way of opening a URL. - // This is always a safe fallback and enables for example Windows - // to open URLs (GTK on Windows via WSL is a thing). - internal_os.open( - app.core_app.alloc, - value.kind, - value.url, - ) catch |err| log.warn("unable to open url: {}", .{err}); -} diff --git a/src/apprt/gtk/Builder.zig b/src/apprt/gtk/Builder.zig deleted file mode 100644 index dbd765ba3..000000000 --- a/src/apprt/gtk/Builder.zig +++ /dev/null @@ -1,77 +0,0 @@ -/// Wrapper around GTK's builder APIs that perform some comptime checks. -const Builder = @This(); - -const std = @import("std"); - -const gtk = @import("gtk"); -const gobject = @import("gobject"); - -resource_name: [:0]const u8, -builder: ?*gtk.Builder, - -pub fn init( - /// The "name" of the resource. - comptime name: []const u8, - /// The major version of the minimum Adwaita version that is required to use - /// this resource. - comptime major: u16, - /// The minor version of the minimum Adwaita version that is required to use - /// this resource. - comptime minor: u16, -) Builder { - const resource_path = comptime resource_path: { - const gresource = @import("gresource.zig"); - // Check to make sure that our file is listed as a - // `blueprint_file` in `gresource.zig`. If it isn't Ghostty - // could crash at runtime when we try and load a nonexistent - // GResource. - for (gresource.blueprint_files) |file| { - if (major != file.major or minor != file.minor or !std.mem.eql(u8, file.name, name)) continue; - // Use @embedFile to make sure that the `.blp` file exists - // at compile time. Zig _should_ discard the data so that - // it doesn't end up in the final executable. At runtime we - // will load the data from a GResource. - const blp_filename = std.fmt.comptimePrint( - "ui/{d}.{d}/{s}.blp", - .{ - file.major, - file.minor, - file.name, - }, - ); - _ = @embedFile(blp_filename); - break :resource_path std.fmt.comptimePrint( - "/com/mitchellh/ghostty/ui/{d}.{d}/{s}.ui", - .{ - file.major, - file.minor, - file.name, - }, - ); - } else @compileError("missing blueprint file '" ++ name ++ "' in gresource.zig"); - }; - - return .{ - .resource_name = resource_path, - .builder = null, - }; -} - -pub fn setWidgetClassTemplate(self: *const Builder, class: *gtk.WidgetClass) void { - class.setTemplateFromResource(self.resource_name); -} - -pub fn getObject(self: *Builder, comptime T: type, name: [:0]const u8) ?*T { - const builder = builder: { - if (self.builder) |builder| break :builder builder; - const builder = gtk.Builder.newFromResource(self.resource_name); - self.builder = builder; - break :builder builder; - }; - - return gobject.ext.cast(T, builder.getObject(name) orelse return null); -} - -pub fn deinit(self: *const Builder) void { - if (self.builder) |builder| builder.unref(); -} diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig deleted file mode 100644 index a1d622143..000000000 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ /dev/null @@ -1,212 +0,0 @@ -/// Clipboard Confirmation Window -const ClipboardConfirmation = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const gtk = @import("gtk"); -const adw = @import("adw"); -const gobject = @import("gobject"); -const gio = @import("gio"); - -const apprt = @import("../../apprt.zig"); -const CoreSurface = @import("../../Surface.zig"); -const App = @import("App.zig"); -const Builder = @import("Builder.zig"); -const adw_version = @import("adw_version.zig"); - -const log = std.log.scoped(.gtk); - -const DialogType = if (adw_version.supportsDialogs()) adw.AlertDialog else adw.MessageDialog; - -app: *App, -dialog: *DialogType, -data: [:0]u8, -core_surface: *CoreSurface, -pending_req: apprt.ClipboardRequest, -text_view: *gtk.TextView, -text_view_scroll: *gtk.ScrolledWindow, -reveal_button: *gtk.Button, -hide_button: *gtk.Button, -remember_choice: if (adw_version.supportsSwitchRow()) ?*adw.SwitchRow else ?*anyopaque, - -pub fn create( - app: *App, - data: []const u8, - core_surface: *CoreSurface, - request: apprt.ClipboardRequest, - is_secure_input: bool, -) !void { - if (app.clipboard_confirmation_window != null) return error.WindowAlreadyExists; - - const alloc = app.core_app.alloc; - const self = try alloc.create(ClipboardConfirmation); - errdefer alloc.destroy(self); - - try self.init( - app, - data, - core_surface, - request, - is_secure_input, - ); - - app.clipboard_confirmation_window = self; -} - -/// Not public because this should be called by the GTK lifecycle. -fn destroy(self: *ClipboardConfirmation) void { - const alloc = self.app.core_app.alloc; - self.app.clipboard_confirmation_window = null; - alloc.free(self.data); - alloc.destroy(self); -} - -fn init( - self: *ClipboardConfirmation, - app: *App, - data: []const u8, - core_surface: *CoreSurface, - request: apprt.ClipboardRequest, - is_secure_input: bool, -) !void { - var builder: Builder = switch (DialogType) { - adw.AlertDialog => switch (request) { - .osc_52_read => .init("ccw-osc-52-read", 1, 5), - .osc_52_write => .init("ccw-osc-52-write", 1, 5), - .paste => .init("ccw-paste", 1, 5), - }, - adw.MessageDialog => switch (request) { - .osc_52_read => .init("ccw-osc-52-read", 1, 2), - .osc_52_write => .init("ccw-osc-52-write", 1, 2), - .paste => .init("ccw-paste", 1, 2), - }, - else => unreachable, - }; - defer builder.deinit(); - - const dialog = builder.getObject(DialogType, "clipboard_confirmation_window").?; - const text_view = builder.getObject(gtk.TextView, "text_view").?; - const reveal_button = builder.getObject(gtk.Button, "reveal_button").?; - const hide_button = builder.getObject(gtk.Button, "hide_button").?; - const text_view_scroll = builder.getObject(gtk.ScrolledWindow, "text_view_scroll").?; - const remember_choice = if (adw_version.supportsSwitchRow()) - builder.getObject(adw.SwitchRow, "remember_choice") - else - null; - - const copy = try app.core_app.alloc.dupeZ(u8, data); - errdefer app.core_app.alloc.free(copy); - self.* = .{ - .app = app, - .dialog = dialog, - .data = copy, - .core_surface = core_surface, - .pending_req = request, - .text_view = text_view, - .text_view_scroll = text_view_scroll, - .reveal_button = reveal_button, - .hide_button = hide_button, - .remember_choice = remember_choice, - }; - - const buffer = gtk.TextBuffer.new(null); - errdefer buffer.unref(); - buffer.insertAtCursor(copy.ptr, @intCast(copy.len)); - text_view.setBuffer(buffer); - - if (is_secure_input) { - text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false)); - self.text_view.as(gtk.Widget).addCssClass("blurred"); - - self.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true)); - - _ = gtk.Button.signals.clicked.connect( - reveal_button, - *ClipboardConfirmation, - gtkRevealButtonClicked, - self, - .{}, - ); - _ = gtk.Button.signals.clicked.connect( - hide_button, - *ClipboardConfirmation, - gtkHideButtonClicked, - self, - .{}, - ); - } - - _ = DialogType.signals.response.connect( - dialog, - *ClipboardConfirmation, - gtkResponse, - self, - .{}, - ); - - switch (DialogType) { - adw.AlertDialog => { - const parent: ?*gtk.Widget = widget: { - const window = core_surface.rt_surface.container.window() orelse break :widget null; - break :widget window.window.as(gtk.Widget); - }; - dialog.as(adw.Dialog).present(parent); - }, - adw.MessageDialog => dialog.as(gtk.Window).present(), - else => unreachable, - } -} - -fn handleResponse(self: *ClipboardConfirmation, response: [*:0]const u8) void { - const is_ok = std.mem.orderZ(u8, response, "ok") == .eq; - - if (is_ok) { - self.core_surface.completeClipboardRequest( - self.pending_req, - self.data, - true, - ) catch |err| { - log.err("Failed to requeue clipboard request: {}", .{err}); - }; - } - - if (self.remember_choice) |remember| remember: { - if (!adw_version.supportsSwitchRow()) break :remember; - if (remember.getActive() == 0) break :remember; - - switch (self.pending_req) { - .osc_52_read => self.core_surface.config.clipboard_read = if (is_ok) .allow else .deny, - .osc_52_write => self.core_surface.config.clipboard_write = if (is_ok) .allow else .deny, - .paste => {}, - } - } - - self.destroy(); -} -fn gtkChoose(dialog_: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.c) void { - const dialog = gobject.ext.cast(DialogType, dialog_.?).?; - const self: *ClipboardConfirmation = @ptrCast(@alignCast(ud.?)); - const response = dialog.chooseFinish(result); - self.handleResponse(response); -} - -fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.c) void { - self.handleResponse(response); -} - -fn gtkRevealButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void { - self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true)); - self.text_view.as(gtk.Widget).removeCssClass("blurred"); - - self.hide_button.as(gtk.Widget).setVisible(@intFromBool(true)); - self.reveal_button.as(gtk.Widget).setVisible(@intFromBool(false)); -} - -fn gtkHideButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void { - self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(false)); - self.text_view.as(gtk.Widget).addCssClass("blurred"); - - self.hide_button.as(gtk.Widget).setVisible(@intFromBool(false)); - self.reveal_button.as(gtk.Widget).setVisible(@intFromBool(true)); -} diff --git a/src/apprt/gtk/CloseDialog.zig b/src/apprt/gtk/CloseDialog.zig deleted file mode 100644 index 559737cf4..000000000 --- a/src/apprt/gtk/CloseDialog.zig +++ /dev/null @@ -1,151 +0,0 @@ -const CloseDialog = @This(); -const std = @import("std"); - -const gobject = @import("gobject"); -const gio = @import("gio"); -const adw = @import("adw"); -const gtk = @import("gtk"); - -const i18n = @import("../../os/main.zig").i18n; -const App = @import("App.zig"); -const Window = @import("Window.zig"); -const Tab = @import("Tab.zig"); -const Surface = @import("Surface.zig"); -const adwaita = @import("adw_version.zig"); - -const log = std.log.scoped(.close_dialog); - -// We don't fall back to the GTK Message/AlertDialogs since -// we don't plan to support libadw < 1.2 as of time of writing -// TODO: Switch to just adw.AlertDialog when we drop Debian 12 support -const DialogType = if (adwaita.supportsDialogs()) adw.AlertDialog else adw.MessageDialog; - -/// Open the dialog when the user requests to close a window/tab/split/etc. -/// but there's still one or more running processes inside the target that -/// cannot be closed automatically. We then ask the user whether they want -/// to terminate existing processes. -pub fn show(target: Target) !void { - // If we don't have a possible window to ask the user, - // in most situations (e.g. when a split isn't attached to a window) - // we should just close unconditionally. - const dialog_window = target.dialogWindow() orelse { - target.close(); - return; - }; - - const dialog = switch (DialogType) { - adw.AlertDialog => adw.AlertDialog.new(target.title(), target.body()), - adw.MessageDialog => adw.MessageDialog.new(dialog_window, target.title(), target.body()), - else => unreachable, - }; - - // AlertDialog and MessageDialog have essentially the same API, - // so we can cheat a little here - dialog.addResponse("cancel", i18n._("Cancel")); - dialog.setCloseResponse("cancel"); - - dialog.addResponse("close", i18n._("Close")); - dialog.setResponseAppearance("close", .destructive); - - // Need a stable pointer - const target_ptr = try target.allocator().create(Target); - target_ptr.* = target; - - _ = DialogType.signals.response.connect(dialog, *Target, responseCallback, target_ptr, .{}); - - switch (DialogType) { - adw.AlertDialog => dialog.as(adw.Dialog).present(dialog_window.as(gtk.Widget)), - adw.MessageDialog => dialog.as(gtk.Window).present(), - else => unreachable, - } -} - -fn responseCallback( - _: *DialogType, - response: [*:0]const u8, - target: *Target, -) callconv(.c) void { - const alloc = target.allocator(); - defer alloc.destroy(target); - - if (std.mem.orderZ(u8, response, "close") == .eq) target.close(); -} - -/// The target of a close dialog. -/// -/// This is here so that we can consolidate all logic related to -/// prompting the user and closing windows/tabs/surfaces/etc. -/// together into one struct that is the sole source of truth. -pub const Target = union(enum) { - app: *App, - window: *Window, - tab: *Tab, - surface: *Surface, - - pub fn title(self: Target) [*:0]const u8 { - return switch (self) { - .app => i18n._("Quit Ghostty?"), - .window => i18n._("Close Window?"), - .tab => i18n._("Close Tab?"), - .surface => i18n._("Close Split?"), - }; - } - - pub fn body(self: Target) [*:0]const u8 { - return switch (self) { - .app => i18n._("All terminal sessions will be terminated."), - .window => i18n._("All terminal sessions in this window will be terminated."), - .tab => i18n._("All terminal sessions in this tab will be terminated."), - .surface => i18n._("The currently running process in this split will be terminated."), - }; - } - - pub fn dialogWindow(self: Target) ?*gtk.Window { - return switch (self) { - .app => { - // Find the currently focused window. We don't store this - // anywhere inside the App structure for some reason, so - // we have to query every single open window and see which - // one is active (focused and receiving keyboard input) - const list = gtk.Window.listToplevels(); - defer list.free(); - - const focused = list.findCustom(null, findActiveWindow); - return @ptrCast(@alignCast(focused.f_data)); - }, - .window => |v| v.window.as(gtk.Window), - .tab => |v| v.window.window.as(gtk.Window), - .surface => |v| { - const window_ = v.container.window() orelse return null; - return window_.window.as(gtk.Window); - }, - }; - } - - fn allocator(self: Target) std.mem.Allocator { - return switch (self) { - .app => |v| v.core_app.alloc, - .window => |v| v.app.core_app.alloc, - .tab => |v| v.window.app.core_app.alloc, - .surface => |v| v.app.core_app.alloc, - }; - } - - fn close(self: Target) void { - switch (self) { - .app => |v| v.quitNow(), - .window => |v| v.window.as(gtk.Window).destroy(), - .tab => |v| v.remove(), - .surface => |v| v.container.remove(), - } - } -}; - -fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) c_int { - const window: *gtk.Window = @ptrCast(@alignCast(@constCast(data orelse return -1))); - - // Confusingly, `isActive` returns 1 when active, - // but we want to return 0 to indicate equality. - // Abusing integers to be enums and booleans is a terrible idea, C. - return if (window.isActive() != 0) 0 else -1; -} diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig deleted file mode 100644 index 076459dbd..000000000 --- a/src/apprt/gtk/CommandPalette.zig +++ /dev/null @@ -1,258 +0,0 @@ -const CommandPalette = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const adw = @import("adw"); -const gio = @import("gio"); -const gobject = @import("gobject"); -const gtk = @import("gtk"); - -const configpkg = @import("../../config.zig"); -const inputpkg = @import("../../input.zig"); -const key = @import("key.zig"); -const Builder = @import("Builder.zig"); -const Window = @import("Window.zig"); - -const log = std.log.scoped(.command_palette); - -window: *Window, - -arena: std.heap.ArenaAllocator, - -/// The dialog object containing the palette UI. -dialog: *adw.Dialog, - -/// The search input text field. -search: *gtk.SearchEntry, - -/// The view containing each result row. -view: *gtk.ListView, - -/// The model that provides filtered data for the view to display. -model: *gtk.SingleSelection, - -/// The list that serves as the data source of the model. -/// This is where all command data is ultimately stored. -source: *gio.ListStore, - -pub fn init(self: *CommandPalette, window: *Window) !void { - // Register the custom command type *before* initializing the builder - // If we don't do this now, the builder will complain that it doesn't know - // about this type and fail to initialize - _ = Command.getGObjectType(); - - var builder = Builder.init("command-palette", 1, 5); - defer builder.deinit(); - - self.* = .{ - .window = window, - .arena = .init(window.app.core_app.alloc), - .dialog = builder.getObject(adw.Dialog, "command-palette").?, - .search = builder.getObject(gtk.SearchEntry, "search").?, - .view = builder.getObject(gtk.ListView, "view").?, - .model = builder.getObject(gtk.SingleSelection, "model").?, - .source = builder.getObject(gio.ListStore, "source").?, - }; - - // Manually take a reference here so that the dialog - // remains in memory after closing - self.dialog.ref(); - errdefer self.dialog.unref(); - - _ = gtk.SearchEntry.signals.stop_search.connect( - self.search, - *CommandPalette, - searchStopped, - self, - .{}, - ); - - _ = gtk.SearchEntry.signals.activate.connect( - self.search, - *CommandPalette, - searchActivated, - self, - .{}, - ); - - _ = gtk.ListView.signals.activate.connect( - self.view, - *CommandPalette, - rowActivated, - self, - .{}, - ); - - try self.updateConfig(&self.window.app.config); -} - -pub fn deinit(self: *CommandPalette) void { - self.arena.deinit(); - self.dialog.unref(); -} - -pub fn toggle(self: *CommandPalette) void { - self.dialog.present(self.window.window.as(gtk.Widget)); - // Focus on the search bar when opening the dialog - _ = self.search.as(gtk.Widget).grabFocus(); -} - -pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void { - // Clear existing binds and clear allocated data - self.source.removeAll(); - _ = self.arena.reset(.retain_capacity); - - for (config.@"command-palette-entry".value.items) |command| { - // Filter out actions that are not implemented - // or don't make sense for GTK - switch (command.action) { - .close_all_windows, - .toggle_secure_input, - .check_for_updates, - .redo, - .undo, - .reset_window_size, - .toggle_window_float_on_top, - => continue, - - else => {}, - } - - const cmd = try Command.new( - self.arena.allocator(), - command, - config.keybind.set, - ); - const cmd_ref = cmd.as(gobject.Object); - self.source.append(cmd_ref); - cmd_ref.unref(); - } -} - -fn activated(self: *CommandPalette, pos: c_uint) void { - // Use self.model and not self.source here to use the list of *visible* results - const object = self.model.as(gio.ListModel).getObject(pos) orelse return; - const cmd = gobject.ext.cast(Command, object) orelse return; - - // Close before running the action in order to avoid being replaced by another - // dialog (such as the change title dialog). If that occurs then the command - // palette dialog won't be counted as having closed properly and cannot - // receive focus when reopened. - _ = self.dialog.close(); - - const action = inputpkg.Binding.Action.parse( - std.mem.span(cmd.cmd_c.action_key), - ) catch |err| { - log.err("got invalid action={s} ({})", .{ cmd.cmd_c.action_key, err }); - return; - }; - - self.window.performBindingAction(action); -} - -fn searchStopped(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { - // ESC was pressed - close the palette - _ = self.dialog.close(); -} - -fn searchActivated(_: *gtk.SearchEntry, self: *CommandPalette) callconv(.c) void { - // If Enter is pressed, activate the selected entry - self.activated(self.model.getSelected()); -} - -fn rowActivated(_: *gtk.ListView, pos: c_uint, self: *CommandPalette) callconv(.c) void { - self.activated(pos); -} - -/// Object that wraps around a command. -/// -/// As GTK list models only accept objects that are within the GObject hierarchy, -/// we have to construct a wrapper to be easily consumed by the list model. -const Command = extern struct { - parent: Parent, - cmd_c: inputpkg.Command.C, - - pub const getGObjectType = gobject.ext.defineClass(Command, .{ - .name = "GhosttyCommand", - .classInit = Class.init, - }); - - pub fn new(alloc: Allocator, cmd: inputpkg.Command, keybinds: inputpkg.Binding.Set) !*Command { - const self = gobject.ext.newInstance(Command, .{}); - var buf: [64]u8 = undefined; - - const action = action: { - const trigger = keybinds.getTrigger(cmd.action) orelse break :action null; - const accel = try key.accelFromTrigger(&buf, trigger) orelse break :action null; - break :action try alloc.dupeZ(u8, accel); - }; - - self.cmd_c = .{ - .title = cmd.title.ptr, - .description = cmd.description.ptr, - .action = if (action) |v| v.ptr else "", - .action_key = try std.fmt.allocPrintZ(alloc, "{}", .{cmd.action}), - }; - - return self; - } - - fn as(self: *Command, comptime T: type) *T { - return gobject.ext.as(T, self); - } - - pub const Parent = gobject.Object; - - pub const Class = extern struct { - parent: Parent.Class, - - pub const Instance = Command; - - pub fn init(class: *Class) callconv(.c) void { - const info = @typeInfo(inputpkg.Command.C).@"struct"; - - // Expose all fields on the Command.C struct as properties - // that can be accessed by the GObject type system - // (and by extension, blueprints) - const properties = comptime props: { - var props: [info.fields.len]type = undefined; - - for (info.fields, 0..) |field, i| { - const accessor = struct { - fn getter(cmd: *Command) ?[:0]const u8 { - return std.mem.span(@field(cmd.cmd_c, field.name)); - } - }; - - // "Canonicalize" field names into the format GObject expects - const prop_name = prop_name: { - var buf: [field.name.len:0]u8 = undefined; - _ = std.mem.replace(u8, field.name, "_", "-", &buf); - break :prop_name buf; - }; - - props[i] = gobject.ext.defineProperty( - &prop_name, - Command, - ?[:0]const u8, - .{ - .default = null, - .accessor = gobject.ext.typedAccessor( - Command, - ?[:0]const u8, - .{ - .getter = &accessor.getter, - }, - ), - }, - ); - } - - break :props props; - }; - - gobject.ext.registerProperties(class, &properties); - } - }; -}; diff --git a/src/apprt/gtk/ConfigErrorsDialog.zig b/src/apprt/gtk/ConfigErrorsDialog.zig deleted file mode 100644 index da70ccce1..000000000 --- a/src/apprt/gtk/ConfigErrorsDialog.zig +++ /dev/null @@ -1,102 +0,0 @@ -/// Configuration errors window. -const ConfigErrorsDialog = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const gobject = @import("gobject"); -const gio = @import("gio"); -const gtk = @import("gtk"); -const adw = @import("adw"); - -const build_config = @import("../../build_config.zig"); -const configpkg = @import("../../config.zig"); -const Config = configpkg.Config; - -const App = @import("App.zig"); -const Window = @import("Window.zig"); -const Builder = @import("Builder.zig"); -const adw_version = @import("adw_version.zig"); - -const log = std.log.scoped(.gtk); - -const DialogType = if (adw_version.supportsDialogs()) adw.AlertDialog else adw.MessageDialog; - -builder: Builder, -dialog: *DialogType, -error_message: *gtk.TextBuffer, - -pub fn maybePresent(app: *App, window: ?*Window) void { - if (app.config._diagnostics.empty()) return; - - const config_errors_dialog = config_errors_dialog: { - if (app.config_errors_dialog) |config_errors_dialog| break :config_errors_dialog config_errors_dialog; - - var builder: Builder = switch (DialogType) { - adw.AlertDialog => .init("config-errors-dialog", 1, 5), - adw.MessageDialog => .init("config-errors-dialog", 1, 2), - else => unreachable, - }; - - const dialog = builder.getObject(DialogType, "config_errors_dialog").?; - const error_message = builder.getObject(gtk.TextBuffer, "error_message").?; - - _ = DialogType.signals.response.connect(dialog, *App, onResponse, app, .{}); - - app.config_errors_dialog = .{ - .builder = builder, - .dialog = dialog, - .error_message = error_message, - }; - - break :config_errors_dialog app.config_errors_dialog.?; - }; - - { - var start = std.mem.zeroes(gtk.TextIter); - config_errors_dialog.error_message.getStartIter(&start); - - var end = std.mem.zeroes(gtk.TextIter); - config_errors_dialog.error_message.getEndIter(&end); - - config_errors_dialog.error_message.delete(&start, &end); - } - - var msg_buf: [4095:0]u8 = undefined; - var fbs = std.io.fixedBufferStream(&msg_buf); - - for (app.config._diagnostics.items()) |diag| { - fbs.reset(); - diag.write(fbs.writer()) catch |err| { - log.warn( - "error writing diagnostic to buffer err={}", - .{err}, - ); - continue; - }; - - config_errors_dialog.error_message.insertAtCursor(&msg_buf, @intCast(fbs.pos)); - config_errors_dialog.error_message.insertAtCursor("\n", 1); - } - - switch (DialogType) { - adw.AlertDialog => { - const parent = if (window) |w| w.window.as(gtk.Widget) else null; - config_errors_dialog.dialog.as(adw.Dialog).present(parent); - }, - adw.MessageDialog => config_errors_dialog.dialog.as(gtk.Window).present(), - else => unreachable, - } -} - -fn onResponse(_: *DialogType, response: [*:0]const u8, app: *App) callconv(.c) void { - if (app.config_errors_dialog) |config_errors_dialog| config_errors_dialog.builder.deinit(); - app.config_errors_dialog = null; - - if (std.mem.orderZ(u8, response, "reload") == .eq) { - app.reloadConfig(.app, .{}) catch |err| { - log.warn("error reloading config error={}", .{err}); - return; - }; - } -} diff --git a/src/apprt/gtk/GlobalShortcuts.zig b/src/apprt/gtk/GlobalShortcuts.zig deleted file mode 100644 index 2506bef97..000000000 --- a/src/apprt/gtk/GlobalShortcuts.zig +++ /dev/null @@ -1,422 +0,0 @@ -const GlobalShortcuts = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const gio = @import("gio"); -const glib = @import("glib"); -const gobject = @import("gobject"); - -const App = @import("App.zig"); -const configpkg = @import("../../config.zig"); -const Binding = @import("../../input.zig").Binding; -const key = @import("key.zig"); - -const log = std.log.scoped(.global_shortcuts); -const Token = [16]u8; - -app: *App, -arena: std.heap.ArenaAllocator, -dbus: *gio.DBusConnection, - -/// A mapping from a unique ID to an action. -/// Currently the unique ID is simply the serialized representation of the -/// trigger that was used for the action as triggers are unique in the keymap, -/// but this may change in the future. -map: std.StringArrayHashMapUnmanaged(Binding.Action) = .{}, - -/// The handle of the current global shortcuts portal session, -/// as a D-Bus object path. -handle: ?[:0]const u8 = null, - -/// The D-Bus signal subscription for the response signal on requests. -/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. -response_subscription: c_uint = 0, - -/// The D-Bus signal subscription for the keybind activate signal. -/// The ID is guaranteed to be non-zero, so we can use 0 to indicate null. -activate_subscription: c_uint = 0, - -pub fn init(alloc: Allocator, gio_app: *gio.Application) ?GlobalShortcuts { - const dbus = gio_app.getDbusConnection() orelse return null; - - return .{ - // To be initialized later - .app = undefined, - .arena = .init(alloc), - .dbus = dbus, - }; -} - -pub fn deinit(self: *GlobalShortcuts) void { - self.close(); - self.arena.deinit(); -} - -fn close(self: *GlobalShortcuts) void { - if (self.response_subscription != 0) { - self.dbus.signalUnsubscribe(self.response_subscription); - self.response_subscription = 0; - } - - if (self.activate_subscription != 0) { - self.dbus.signalUnsubscribe(self.activate_subscription); - self.activate_subscription = 0; - } - - if (self.handle) |handle| { - // Close existing session - self.dbus.call( - "org.freedesktop.portal.Desktop", - handle, - "org.freedesktop.portal.Session", - "Close", - null, - null, - .{}, - -1, - null, - null, - null, - ); - self.handle = null; - } -} - -pub fn refreshSession(self: *GlobalShortcuts, app: *App) !void { - // Ensure we have a valid reference to the app - // (it was left uninitialized in `init`) - self.app = app; - - // Close any existing sessions - self.close(); - - // Update map - var trigger_buf: [256]u8 = undefined; - - self.map.clearRetainingCapacity(); - var it = self.app.config.keybind.set.bindings.iterator(); - - while (it.next()) |entry| { - const leaf = switch (entry.value_ptr.*) { - // Global shortcuts can't have leaders - .leader => continue, - .leaf => |leaf| leaf, - }; - if (!leaf.flags.global) continue; - - const trigger = try key.xdgShortcutFromTrigger( - &trigger_buf, - entry.key_ptr.*, - ) orelse continue; - - try self.map.put( - self.arena.allocator(), - try self.arena.allocator().dupeZ(u8, trigger), - leaf.action, - ); - } - - if (self.map.count() > 0) { - try self.request(.create_session); - } -} - -fn shortcutActivated( - _: *gio.DBusConnection, - _: ?[*:0]const u8, - _: [*:0]const u8, - _: [*:0]const u8, - _: [*:0]const u8, - params: *glib.Variant, - ud: ?*anyopaque, -) callconv(.c) void { - const self: *GlobalShortcuts = @ptrCast(@alignCast(ud)); - - // 2nd value in the tuple is the activated shortcut ID - // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-activated - var shortcut_id: [*:0]const u8 = undefined; - params.getChild(1, "&s", &shortcut_id); - log.debug("activated={s}", .{shortcut_id}); - - const action = self.map.get(std.mem.span(shortcut_id)) orelse return; - - self.app.core_app.performAllAction(self.app, action) catch |err| { - log.err("failed to perform action={}", .{err}); - }; -} - -const Method = enum { - create_session, - bind_shortcuts, - - fn name(self: Method) [:0]const u8 { - return switch (self) { - .create_session => "CreateSession", - .bind_shortcuts => "BindShortcuts", - }; - } - - /// Construct the payload expected by the XDG portal call. - fn makePayload( - self: Method, - shortcuts: *GlobalShortcuts, - request_token: [:0]const u8, - ) ?*glib.Variant { - switch (self) { - // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-createsession - .create_session => { - var session_token: Token = undefined; - return glib.Variant.newParsed( - "({'handle_token': <%s>, 'session_handle_token': <%s>},)", - request_token.ptr, - generateToken(&session_token).ptr, - ); - }, - // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html#org-freedesktop-portal-globalshortcuts-bindshortcuts - .bind_shortcuts => { - const handle = shortcuts.handle orelse return null; - - const bind_type = glib.VariantType.new("a(sa{sv})"); - defer glib.free(bind_type); - - var binds: glib.VariantBuilder = undefined; - glib.VariantBuilder.init(&binds, bind_type); - - var action_buf: [256]u8 = undefined; - - var it = shortcuts.map.iterator(); - while (it.next()) |entry| { - const trigger = entry.key_ptr.*.ptr; - const action = std.fmt.bufPrintZ( - &action_buf, - "{}", - .{entry.value_ptr.*}, - ) catch continue; - - binds.addParsed( - "(%s, {'description': <%s>, 'preferred_trigger': <%s>})", - trigger, - action.ptr, - trigger, - ); - } - - return glib.Variant.newParsed( - "(%o, %*, '', {'handle_token': <%s>})", - handle.ptr, - binds.end(), - request_token.ptr, - ); - }, - } - } - - fn onResponse(self: Method, shortcuts: *GlobalShortcuts, vardict: *glib.Variant) void { - switch (self) { - .create_session => { - var handle: ?[*:0]u8 = null; - if (vardict.lookup("session_handle", "&s", &handle) == 0) { - log.err( - "session handle not found in response={s}", - .{vardict.print(@intFromBool(true))}, - ); - return; - } - - shortcuts.handle = shortcuts.arena.allocator().dupeZ(u8, std.mem.span(handle.?)) catch { - log.err("out of memory: failed to clone session handle", .{}); - return; - }; - - log.debug("session_handle={?s}", .{handle}); - - // Subscribe to keybind activations - shortcuts.activate_subscription = shortcuts.dbus.signalSubscribe( - null, - "org.freedesktop.portal.GlobalShortcuts", - "Activated", - "/org/freedesktop/portal/desktop", - handle, - .{ .match_arg0_path = true }, - shortcutActivated, - shortcuts, - null, - ); - - shortcuts.request(.bind_shortcuts) catch |err| { - log.err("failed to bind shortcuts={}", .{err}); - return; - }; - }, - .bind_shortcuts => {}, - } - } -}; - -/// Submit a request to the global shortcuts portal. -fn request( - self: *GlobalShortcuts, - comptime method: Method, -) !void { - // NOTE(pluiedev): - // XDG Portals are really, really poorly-designed pieces of hot garbage. - // How the protocol is _initially_ designed to work is as follows: - // - // 1. The client calls a method which returns the path of a Request object; - // 2. The client waits for the Response signal under said object path; - // 3. When the signal arrives, the actual return value and status code - // become available for the client for further processing. - // - // THIS DOES NOT WORK. Once the first two steps are complete, the client - // needs to immediately start listening for the third step, but an overeager - // server implementation could easily send the Response signal before the - // client is even ready, causing communications to break down over a simple - // race condition/two generals' problem that even _TCP_ had figured out - // decades ago. Worse yet, you get exactly _one_ chance to listen for the - // signal, or else your communication attempt so far has all been in vain. - // - // And they know this. Instead of fixing their freaking protocol, they just - // ask clients to manually construct the expected object path and subscribe - // to the request signal beforehand, making the whole response value of - // the original call COMPLETELY MEANINGLESS. - // - // Furthermore, this is _entirely undocumented_ aside from one tiny - // paragraph under the documentation for the Request interface, and - // anyone would be forgiven for missing it without reading the libportal - // source code. - // - // When in Rome, do as the Romans do, I guess...? - - const callbacks = struct { - fn gotResponseHandle( - source: ?*gobject.Object, - res: *gio.AsyncResult, - _: ?*anyopaque, - ) callconv(.c) void { - const dbus_ = gobject.ext.cast(gio.DBusConnection, source.?).?; - - var err: ?*glib.Error = null; - defer if (err) |err_| err_.free(); - - const params_ = dbus_.callFinish(res, &err) orelse { - if (err) |err_| log.err("request failed={s} ({})", .{ - err_.f_message orelse "(unknown)", - err_.f_code, - }); - return; - }; - defer params_.unref(); - - // TODO: XDG recommends updating the signal subscription if the actual - // returned request path is not the same as the expected request - // path, to retain compatibility with older versions of XDG portals. - // Although it suffers from the race condition outlined above, - // we should still implement this at some point. - } - - // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html#org-freedesktop-portal-request-response - fn responded( - dbus: *gio.DBusConnection, - _: ?[*:0]const u8, - _: [*:0]const u8, - _: [*:0]const u8, - _: [*:0]const u8, - params_: *glib.Variant, - ud: ?*anyopaque, - ) callconv(.c) void { - const self_: *GlobalShortcuts = @ptrCast(@alignCast(ud)); - - // Unsubscribe from the response signal - if (self_.response_subscription != 0) { - dbus.signalUnsubscribe(self_.response_subscription); - self_.response_subscription = 0; - } - - var response: u32 = 0; - var vardict: ?*glib.Variant = null; - defer if (vardict) |v| v.unref(); - params_.get("(u@a{sv})", &response, &vardict); - - switch (response) { - 0 => { - log.debug("request successful", .{}); - method.onResponse(self_, vardict.?); - }, - 1 => log.debug("request was cancelled by user", .{}), - 2 => log.warn("request ended unexpectedly", .{}), - else => log.err("unrecognized response code={}", .{response}), - } - } - }; - - var request_token_buf: Token = undefined; - const request_token = generateToken(&request_token_buf); - - const payload = method.makePayload(self, request_token) orelse return; - const request_path = try self.getRequestPath(request_token); - - self.response_subscription = self.dbus.signalSubscribe( - null, - "org.freedesktop.portal.Request", - "Response", - request_path, - null, - .{}, - callbacks.responded, - self, - null, - ); - - self.dbus.call( - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.GlobalShortcuts", - method.name(), - payload, - null, - .{}, - -1, - null, - callbacks.gotResponseHandle, - null, - ); -} - -/// Generate a random token suitable for use in requests. -fn generateToken(buf: *Token) [:0]const u8 { - // u28 takes up 7 bytes in hex, 8 bytes for "ghostty_" and 1 byte for NUL - // 7 + 8 + 1 = 16 - return std.fmt.bufPrintZ( - buf, - "ghostty_{x:0<7}", - .{std.crypto.random.int(u28)}, - ) catch unreachable; -} - -/// Get the XDG portal request path for the current Ghostty instance. -/// -/// If this sounds like nonsense, see `request` for an explanation as to -/// why we need to do this. -fn getRequestPath(self: *GlobalShortcuts, token: [:0]const u8) ![:0]const u8 { - // See https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html - // for the syntax XDG portals expect. - - // `getUniqueName` should never return null here as we're using an ordinary - // message bus connection. If it doesn't, something is very wrong - const unique_name = std.mem.span(self.dbus.getUniqueName().?); - - const object_path = try std.mem.joinZ(self.arena.allocator(), "/", &.{ - "/org/freedesktop/portal/desktop/request", - unique_name[1..], // Remove leading `:` - token, - }); - - // Sanitize the unique name by replacing every `.` with `_`. - // In effect, this will turn a unique name like `:1.192` into `1_192`. - // Valid D-Bus object path components never contain `.`s anyway, so we're - // free to replace all instances of `.` here and avoid extra allocation. - std.mem.replaceScalar(u8, object_path, '.', '_'); - - return object_path; -} diff --git a/src/apprt/gtk/ImguiWidget.zig b/src/apprt/gtk/ImguiWidget.zig deleted file mode 100644 index 338fd7982..000000000 --- a/src/apprt/gtk/ImguiWidget.zig +++ /dev/null @@ -1,470 +0,0 @@ -const ImguiWidget = @This(); - -const std = @import("std"); -const assert = std.debug.assert; - -const gdk = @import("gdk"); -const gtk = @import("gtk"); -const cimgui = @import("cimgui"); -const gl = @import("opengl"); - -const key = @import("key.zig"); -const input = @import("../../input.zig"); - -const log = std.log.scoped(.gtk_imgui_widget); - -/// This is called every frame to populate the ImGui frame. -render_callback: ?*const fn (?*anyopaque) void = null, -render_userdata: ?*anyopaque = null, - -/// Our OpenGL widget -gl_area: *gtk.GLArea, -im_context: *gtk.IMContext, - -/// ImGui Context -ig_ctx: *cimgui.c.ImGuiContext, - -/// Our previous instant used to calculate delta time for animations. -instant: ?std.time.Instant = null, - -/// Initialize the widget. This must have a stable pointer for events. -pub fn init(self: *ImguiWidget) !void { - // Each widget gets its own imgui context so we can have multiple - // imgui views in the same application. - const ig_ctx = cimgui.c.igCreateContext(null) orelse return error.OutOfMemory; - errdefer cimgui.c.igDestroyContext(ig_ctx); - cimgui.c.igSetCurrentContext(ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - io.BackendPlatformName = "ghostty_gtk"; - - // Our OpenGL area for drawing - const gl_area = gtk.GLArea.new(); - gl_area.setAutoRender(@intFromBool(true)); - - // The GL area has to be focusable so that it can receive events - gl_area.as(gtk.Widget).setFocusable(@intFromBool(true)); - gl_area.as(gtk.Widget).setFocusOnClick(@intFromBool(true)); - - // Clicks - const gesture_click = gtk.GestureClick.new(); - errdefer gesture_click.unref(); - gesture_click.as(gtk.GestureSingle).setButton(0); - gl_area.as(gtk.Widget).addController(gesture_click.as(gtk.EventController)); - - // Mouse movement - const ec_motion = gtk.EventControllerMotion.new(); - errdefer ec_motion.unref(); - gl_area.as(gtk.Widget).addController(ec_motion.as(gtk.EventController)); - - // Scroll events - const ec_scroll = gtk.EventControllerScroll.new(.flags_both_axes); - errdefer ec_scroll.unref(); - gl_area.as(gtk.Widget).addController(ec_scroll.as(gtk.EventController)); - - // Focus controller will tell us about focus enter/exit events - const ec_focus = gtk.EventControllerFocus.new(); - errdefer ec_focus.unref(); - gl_area.as(gtk.Widget).addController(ec_focus.as(gtk.EventController)); - - // Key event controller will tell us about raw keypress events. - const ec_key = gtk.EventControllerKey.new(); - errdefer ec_key.unref(); - gl_area.as(gtk.Widget).addController(ec_key.as(gtk.EventController)); - errdefer gl_area.as(gtk.Widget).removeController(ec_key.as(gtk.EventController)); - - // The input method context that we use to translate key events into - // characters. This doesn't have an event key controller attached because - // we call it manually from our own key controller. - const im_context = gtk.IMMulticontext.new(); - errdefer im_context.unref(); - - // Signals - _ = gtk.Widget.signals.realize.connect( - gl_area, - *ImguiWidget, - gtkRealize, - self, - .{}, - ); - _ = gtk.Widget.signals.unrealize.connect( - gl_area, - *ImguiWidget, - gtkUnrealize, - self, - .{}, - ); - _ = gtk.Widget.signals.destroy.connect( - gl_area, - *ImguiWidget, - gtkDestroy, - self, - .{}, - ); - _ = gtk.GLArea.signals.render.connect( - gl_area, - *ImguiWidget, - gtkRender, - self, - .{}, - ); - _ = gtk.GLArea.signals.resize.connect( - gl_area, - *ImguiWidget, - gtkResize, - self, - .{}, - ); - _ = gtk.EventControllerKey.signals.key_pressed.connect( - ec_key, - *ImguiWidget, - gtkKeyPressed, - self, - .{}, - ); - _ = gtk.EventControllerKey.signals.key_released.connect( - ec_key, - *ImguiWidget, - gtkKeyReleased, - self, - .{}, - ); - _ = gtk.EventControllerFocus.signals.enter.connect( - ec_focus, - *ImguiWidget, - gtkFocusEnter, - self, - .{}, - ); - _ = gtk.EventControllerFocus.signals.leave.connect( - ec_focus, - *ImguiWidget, - gtkFocusLeave, - self, - .{}, - ); - _ = gtk.GestureClick.signals.pressed.connect( - gesture_click, - *ImguiWidget, - gtkMouseDown, - self, - .{}, - ); - _ = gtk.GestureClick.signals.released.connect( - gesture_click, - *ImguiWidget, - gtkMouseUp, - self, - .{}, - ); - _ = gtk.EventControllerMotion.signals.motion.connect( - ec_motion, - *ImguiWidget, - gtkMouseMotion, - self, - .{}, - ); - _ = gtk.EventControllerScroll.signals.scroll.connect( - ec_scroll, - *ImguiWidget, - gtkMouseScroll, - self, - .{}, - ); - _ = gtk.IMContext.signals.commit.connect( - im_context, - *ImguiWidget, - gtkInputCommit, - self, - .{}, - ); - - self.* = .{ - .gl_area = gl_area, - .im_context = im_context.as(gtk.IMContext), - .ig_ctx = ig_ctx, - }; -} - -/// Deinitialize the widget. This should ONLY be called if the widget gl_area -/// was never added to a parent. Otherwise, cleanup automatically happens -/// when the widget is destroyed and this should NOT be called. -pub fn deinit(self: *ImguiWidget) void { - cimgui.c.igDestroyContext(self.ig_ctx); -} - -/// This should be called anytime the underlying data for the UI changes -/// so that the UI can be refreshed. -pub fn queueRender(self: *const ImguiWidget) void { - self.gl_area.queueRender(); -} - -/// Initialize the frame. Expects that the context is already current. -fn newFrame(self: *ImguiWidget) !void { - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - - // Determine our delta time - const now = try std.time.Instant.now(); - io.DeltaTime = if (self.instant) |prev| delta: { - const since_ns = now.since(prev); - const since_s: f32 = @floatFromInt(since_ns / std.time.ns_per_s); - break :delta @max(0.00001, since_s); - } else (1 / 60); - self.instant = now; -} - -fn translateMouseButton(button: c_uint) ?c_int { - return switch (button) { - 1 => cimgui.c.ImGuiMouseButton_Left, - 2 => cimgui.c.ImGuiMouseButton_Middle, - 3 => cimgui.c.ImGuiMouseButton_Right, - else => null, - }; -} - -fn gtkDestroy(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { - log.debug("imgui widget destroy", .{}); - self.deinit(); -} - -fn gtkRealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { - log.debug("gl surface realized", .{}); - - // We need to make the context current so we can call GL functions. - area.makeCurrent(); - if (area.getError()) |err| { - log.err("surface failed to realize: {s}", .{err.f_message orelse "(unknown)"}); - return; - } - - // realize means that our OpenGL context is ready, so we can now - // initialize the ImgUI OpenGL backend for our context. - cimgui.c.igSetCurrentContext(self.ig_ctx); - _ = cimgui.ImGui_ImplOpenGL3_Init(null); -} - -fn gtkUnrealize(area: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { - _ = area; - log.debug("gl surface unrealized", .{}); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - cimgui.ImGui_ImplOpenGL3_Shutdown(); -} - -fn gtkResize(area: *gtk.GLArea, width: c_int, height: c_int, self: *ImguiWidget) callconv(.c) void { - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - const scale_factor = area.as(gtk.Widget).getScaleFactor(); - log.debug("gl resize width={} height={} scale={}", .{ - width, - height, - scale_factor, - }); - - // Our display size is always unscaled. We'll do the scaling in the - // style instead. This creates crisper looking fonts. - io.DisplaySize = .{ .x = @floatFromInt(width), .y = @floatFromInt(height) }; - io.DisplayFramebufferScale = .{ .x = 1, .y = 1 }; - - // Setup a new style and scale it appropriately. - const style = cimgui.c.ImGuiStyle_ImGuiStyle(); - defer cimgui.c.ImGuiStyle_destroy(style); - cimgui.c.ImGuiStyle_ScaleAllSizes(style, @floatFromInt(scale_factor)); - const active_style = cimgui.c.igGetStyle(); - active_style.* = style.*; -} - -fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *ImguiWidget) callconv(.c) c_int { - cimgui.c.igSetCurrentContext(self.ig_ctx); - - // Setup our frame. We render twice because some ImGui behaviors - // take multiple renders to process. I don't know how to make this - // more efficient. - for (0..2) |_| { - cimgui.ImGui_ImplOpenGL3_NewFrame(); - self.newFrame() catch |err| { - log.err("failed to setup frame: {}", .{err}); - return 0; - }; - cimgui.c.igNewFrame(); - - // Build our UI - if (self.render_callback) |cb| cb(self.render_userdata); - - // Render - cimgui.c.igRender(); - } - - // OpenGL final render - gl.clearColor(0x28 / 0xFF, 0x2C / 0xFF, 0x34 / 0xFF, 1.0); - gl.clear(gl.c.GL_COLOR_BUFFER_BIT); - cimgui.ImGui_ImplOpenGL3_RenderDrawData(cimgui.c.igGetDrawData()); - - return 1; -} - -fn gtkMouseMotion( - _: *gtk.EventControllerMotion, - x: f64, - y: f64, - self: *ImguiWidget, -) callconv(.c) void { - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - const scale_factor: f64 = @floatFromInt(self.gl_area.as(gtk.Widget).getScaleFactor()); - cimgui.c.ImGuiIO_AddMousePosEvent( - io, - @floatCast(x * scale_factor), - @floatCast(y * scale_factor), - ); - self.queueRender(); -} - -fn gtkMouseDown( - gesture: *gtk.GestureClick, - _: c_int, - _: f64, - _: f64, - self: *ImguiWidget, -) callconv(.c) void { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - - const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); - if (translateMouseButton(gdk_button)) |button| { - cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, true); - } -} - -fn gtkMouseUp( - gesture: *gtk.GestureClick, - _: c_int, - _: f64, - _: f64, - self: *ImguiWidget, -) callconv(.c) void { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - const gdk_button = gesture.as(gtk.GestureSingle).getCurrentButton(); - if (translateMouseButton(gdk_button)) |button| { - cimgui.c.ImGuiIO_AddMouseButtonEvent(io, button, false); - } -} - -fn gtkMouseScroll( - _: *gtk.EventControllerScroll, - x: f64, - y: f64, - self: *ImguiWidget, -) callconv(.c) c_int { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - cimgui.c.ImGuiIO_AddMouseWheelEvent( - io, - @floatCast(x), - @floatCast(-y), - ); - - return @intFromBool(true); -} - -fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.c) void { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - cimgui.c.ImGuiIO_AddFocusEvent(io, true); -} - -fn gtkFocusLeave(_: *gtk.EventControllerFocus, self: *ImguiWidget) callconv(.c) void { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - cimgui.c.ImGuiIO_AddFocusEvent(io, false); -} - -fn gtkInputCommit( - _: *gtk.IMMulticontext, - bytes: [*:0]u8, - self: *ImguiWidget, -) callconv(.c) void { - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes); -} - -fn gtkKeyPressed( - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, - self: *ImguiWidget, -) callconv(.c) c_int { - return @intFromBool(self.keyEvent( - .press, - ec_key, - keyval, - keycode, - gtk_mods, - )); -} - -fn gtkKeyReleased( - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, - self: *ImguiWidget, -) callconv(.c) void { - _ = self.keyEvent( - .release, - ec_key, - keyval, - keycode, - gtk_mods, - ); -} - -fn keyEvent( - self: *ImguiWidget, - action: input.Action, - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, -) bool { - _ = keycode; - - self.queueRender(); - - cimgui.c.igSetCurrentContext(self.ig_ctx); - const io: *cimgui.c.ImGuiIO = cimgui.c.igGetIO(); - - const mods = key.translateMods(gtk_mods); - cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftShift, mods.shift); - cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftCtrl, mods.ctrl); - cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftAlt, mods.alt); - cimgui.c.ImGuiIO_AddKeyEvent(io, cimgui.c.ImGuiKey_LeftSuper, mods.super); - - // If our keyval has a key, then we send that key event - if (key.keyFromKeyval(keyval)) |inputkey| { - if (inputkey.imguiKey()) |imgui_key| { - cimgui.c.ImGuiIO_AddKeyEvent(io, imgui_key, action == .press); - } - } - - // Try to process the event as text - if (ec_key.as(gtk.EventController).getCurrentEvent()) |event| { - _ = self.im_context.filterKeypress(event); - } - - return true; -} diff --git a/src/apprt/gtk/ProgressBar.zig b/src/apprt/gtk/ProgressBar.zig deleted file mode 100644 index 1518e84c2..000000000 --- a/src/apprt/gtk/ProgressBar.zig +++ /dev/null @@ -1,165 +0,0 @@ -//! Structure for managing GUI progress bar for a surface. -const ProgressBar = @This(); - -const std = @import("std"); - -const glib = @import("glib"); -const gtk = @import("gtk"); - -const Surface = @import("./Surface.zig"); -const terminal = @import("../../terminal/main.zig"); - -const log = std.log.scoped(.gtk_progress_bar); - -/// The surface that we belong to. -surface: *Surface, - -/// Widget for showing progress bar. -progress_bar: ?*gtk.ProgressBar = null, - -/// Timer used to remove the progress bar if we have not received an update from -/// the TUI in a while. -progress_bar_timer: ?c_uint = null, - -pub fn init(surface: *Surface) ProgressBar { - return .{ - .surface = surface, - }; -} - -pub fn deinit(self: *ProgressBar) void { - self.stopProgressBarTimer(); -} - -/// Show (or update if it already exists) a GUI progress bar. -pub fn handleProgressReport(self: *ProgressBar, value: terminal.osc.Command.ProgressReport) error{}!bool { - // Remove the progress bar. - if (value.state == .remove) { - self.stopProgressBarTimer(); - self.removeProgressBar(); - - return true; - } - - const progress_bar = self.addProgressBar(); - self.startProgressBarTimer(); - - switch (value.state) { - // already handled above - .remove => unreachable, - - // Set the progress bar to a fixed value if one was provided, otherwise pulse. - // Remove the `error` CSS class so that the progress bar shows as normal. - .set => { - progress_bar.as(gtk.Widget).removeCssClass("error"); - if (value.progress) |progress| { - progress_bar.setFraction(computeFraction(progress)); - } else { - progress_bar.pulse(); - } - }, - - // Set the progress bar to a fixed value if one was provided, otherwise pulse. - // Set the `error` CSS class so that the progress bar shows as an error color. - .@"error" => { - progress_bar.as(gtk.Widget).addCssClass("error"); - if (value.progress) |progress| { - progress_bar.setFraction(computeFraction(progress)); - } else { - progress_bar.pulse(); - } - }, - - // The state of progress is unknown, so pulse the progress bar to - // indicate that things are still happening. - .indeterminate => { - progress_bar.pulse(); - }, - - // If a progress value was provided, set the progress bar to that value. - // Don't pulse the progress bar as that would indicate that things were - // happening. Otherwise this is mainly used to keep the progress bar on - // screen instead of timing out. - .pause => { - if (value.progress) |progress| { - progress_bar.setFraction(computeFraction(progress)); - } - }, - } - - return true; -} - -/// Compute a fraction [0.0, 1.0] from the supplied progress, which is clamped -/// to [0, 100]. -fn computeFraction(progress: u8) f64 { - return @as(f64, @floatFromInt(std.math.clamp(progress, 0, 100))) / 100.0; -} - -test "computeFraction" { - try std.testing.expectEqual(1.0, computeFraction(100)); - try std.testing.expectEqual(1.0, computeFraction(255)); - try std.testing.expectEqual(0.0, computeFraction(0)); - try std.testing.expectEqual(0.5, computeFraction(50)); -} - -/// Add a progress bar to our overlay. -fn addProgressBar(self: *ProgressBar) *gtk.ProgressBar { - if (self.progress_bar) |progress_bar| return progress_bar; - - const progress_bar = gtk.ProgressBar.new(); - self.progress_bar = progress_bar; - - const progress_bar_widget = progress_bar.as(gtk.Widget); - progress_bar_widget.setHalign(.fill); - progress_bar_widget.setValign(.start); - progress_bar_widget.addCssClass("osd"); - - self.surface.overlay.addOverlay(progress_bar_widget); - - return progress_bar; -} - -/// Remove the progress bar from our overlay. -fn removeProgressBar(self: *ProgressBar) void { - if (self.progress_bar) |progress_bar| { - const progress_bar_widget = progress_bar.as(gtk.Widget); - self.surface.overlay.removeOverlay(progress_bar_widget); - self.progress_bar = null; - } -} - -/// Start a timer that will remove the progress bar if the TUI forgets to remove -/// it. -fn startProgressBarTimer(self: *ProgressBar) void { - const progress_bar_timeout_seconds = 15; - - // Remove an old timer that hasn't fired yet. - self.stopProgressBarTimer(); - - self.progress_bar_timer = glib.timeoutAdd( - progress_bar_timeout_seconds * std.time.ms_per_s, - handleProgressBarTimeout, - self, - ); -} - -/// Stop any existing timer for removing the progress bar. -fn stopProgressBarTimer(self: *ProgressBar) void { - if (self.progress_bar_timer) |timer| { - if (glib.Source.remove(timer) == 0) { - log.warn("unable to remove progress bar timer", .{}); - } - self.progress_bar_timer = null; - } -} - -/// The progress bar hasn't been updated by the TUI recently, remove it. -fn handleProgressBarTimeout(ud: ?*anyopaque) callconv(.c) c_int { - const self: *ProgressBar = @ptrCast(@alignCast(ud.?)); - - self.progress_bar_timer = null; - self.removeProgressBar(); - - return @intFromBool(glib.SOURCE_REMOVE); -} diff --git a/src/apprt/gtk/ResizeOverlay.zig b/src/apprt/gtk/ResizeOverlay.zig deleted file mode 100644 index 2ab59624a..000000000 --- a/src/apprt/gtk/ResizeOverlay.zig +++ /dev/null @@ -1,206 +0,0 @@ -const ResizeOverlay = @This(); - -const std = @import("std"); - -const glib = @import("glib"); -const gtk = @import("gtk"); - -const configpkg = @import("../../config.zig"); -const Surface = @import("Surface.zig"); - -const log = std.log.scoped(.gtk); - -/// local copy of configuration data -const DerivedConfig = struct { - resize_overlay: configpkg.Config.ResizeOverlay, - resize_overlay_position: configpkg.Config.ResizeOverlayPosition, - resize_overlay_duration: configpkg.Config.Duration, - - pub fn init(config: *const configpkg.Config) DerivedConfig { - return .{ - .resize_overlay = config.@"resize-overlay", - .resize_overlay_position = config.@"resize-overlay-position", - .resize_overlay_duration = config.@"resize-overlay-duration", - }; - } -}; - -/// the surface that we are attached to -surface: *Surface, - -/// a copy of the configuration that we need to operate -config: DerivedConfig, - -/// If non-null this is the widget on the overlay that shows the size of the -/// surface when it is resized. -label: ?*gtk.Label = null, - -/// If non-null this is a timer for dismissing the resize overlay. -timer: ?c_uint = null, - -/// If non-null this is a timer for dismissing the resize overlay. -idler: ?c_uint = null, - -/// If true, the next resize event will be the first one. -first: bool = true, - -/// Initialize the ResizeOverlay. This doesn't do anything more than save a -/// pointer to the surface that we are a part of as all of the widget creation -/// is done later. -pub fn init(self: *ResizeOverlay, surface: *Surface, config: *const configpkg.Config) void { - self.* = .{ - .surface = surface, - .config = .init(config), - }; -} - -pub fn updateConfig(self: *ResizeOverlay, config: *const configpkg.Config) void { - self.config = .init(config); -} - -/// De-initialize the ResizeOverlay. This removes any pending idlers/timers that -/// may not have fired yet. -pub fn deinit(self: *ResizeOverlay) void { - if (self.idler) |idler| { - if (glib.Source.remove(idler) == 0) { - log.warn("unable to remove resize overlay idler", .{}); - } - self.idler = null; - } - - if (self.timer) |timer| { - if (glib.Source.remove(timer) == 0) { - log.warn("unable to remove resize overlay timer", .{}); - } - self.timer = null; - } -} - -/// If we're configured to do so, update the text in the resize overlay widget -/// and make it visible. Schedule a timer to hide the widget after the delay -/// expires. -/// -/// If we're not configured to show the overlay, do nothing. -pub fn maybeShow(self: *ResizeOverlay) void { - switch (self.config.resize_overlay) { - .never => return, - .always => {}, - .@"after-first" => if (self.first) { - self.first = false; - return; - }, - } - - self.first = false; - - // When updating a widget, wait until GTK is "idle", i.e. not in the middle - // of doing any other updates. Since we are called in the middle of resizing - // GTK is doing a lot of work rearranging all of the widgets. Not doing this - // results in a lot of warnings from GTK and _horrible_ flickering of the - // resize overlay. - if (self.idler != null) return; - self.idler = glib.idleAdd(gtkUpdate, self); -} - -/// Actually update the overlay widget. This should only be called from a GTK -/// idle handler. -fn gtkUpdate(ud: ?*anyopaque) callconv(.c) c_int { - const self: *ResizeOverlay = @ptrCast(@alignCast(ud orelse return 0)); - - // No matter what our idler is complete with this callback - self.idler = null; - - const grid_size = self.surface.core_surface.size.grid(); - var buf: [32]u8 = undefined; - const text = std.fmt.bufPrintZ( - &buf, - "{d} x {d}", - .{ - grid_size.columns, - grid_size.rows, - }, - ) catch |err| { - log.err("unable to format text: {}", .{err}); - return 0; - }; - - if (self.label) |label| { - // The resize overlay widget already exists, just update it. - label.setText(text.ptr); - setPosition(label, &self.config); - show(label); - } else { - // Create the resize overlay widget. - const label = gtk.Label.new(text.ptr); - label.setJustify(gtk.Justification.center); - label.setSelectable(0); - setPosition(label, &self.config); - - const widget = label.as(gtk.Widget); - widget.addCssClass("view"); - widget.addCssClass("size-overlay"); - widget.setFocusable(0); - widget.setCanTarget(0); - - const overlay: *gtk.Overlay = @ptrCast(@alignCast(self.surface.overlay)); - overlay.addOverlay(widget); - - self.label = label; - } - - if (self.timer) |timer| { - if (glib.Source.remove(timer) == 0) { - log.warn("unable to remove size overlay timer", .{}); - } - } - - self.timer = glib.timeoutAdd( - self.surface.app.config.@"resize-overlay-duration".asMilliseconds(), - gtkTimerExpired, - self, - ); - - return 0; -} - -// This should only be called from a GTK idle handler or timer. -fn show(label: *gtk.Label) void { - const widget = label.as(gtk.Widget); - widget.removeCssClass("hidden"); -} - -// This should only be called from a GTK idle handler or timer. -fn hide(label: *gtk.Label) void { - const widget = label.as(gtk.Widget); - widget.addCssClass("hidden"); -} - -/// Update the position of the resize overlay widget. It might seem excessive to -/// do this often, but it should make hot config reloading of the position work. -/// This should only be called from a GTK idle handler. -fn setPosition(label: *gtk.Label, config: *DerivedConfig) void { - const widget = label.as(gtk.Widget); - widget.setHalign( - switch (config.resize_overlay_position) { - .center, .@"top-center", .@"bottom-center" => gtk.Align.center, - .@"top-left", .@"bottom-left" => gtk.Align.start, - .@"top-right", .@"bottom-right" => gtk.Align.end, - }, - ); - widget.setValign( - switch (config.resize_overlay_position) { - .center => gtk.Align.center, - .@"top-left", .@"top-center", .@"top-right" => gtk.Align.start, - .@"bottom-left", .@"bottom-center", .@"bottom-right" => gtk.Align.end, - }, - ); -} - -/// If this fires, it means that the delay period has expired and the resize -/// overlay widget should be hidden. -fn gtkTimerExpired(ud: ?*anyopaque) callconv(.c) c_int { - const self: *ResizeOverlay = @ptrCast(@alignCast(ud orelse return 0)); - self.timer = null; - if (self.label) |label| hide(label); - return 0; -} diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig deleted file mode 100644 index fb719c3c9..000000000 --- a/src/apprt/gtk/Split.zig +++ /dev/null @@ -1,441 +0,0 @@ -/// Split represents a surface split where two surfaces are shown side-by-side -/// within the same window either vertically or horizontally. -const Split = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -const gobject = @import("gobject"); -const gtk = @import("gtk"); - -const apprt = @import("../../apprt.zig"); -const font = @import("../../font/main.zig"); -const CoreSurface = @import("../../Surface.zig"); - -const Surface = @import("Surface.zig"); -const Tab = @import("Tab.zig"); - -const log = std.log.scoped(.gtk); - -/// The split orientation. -pub const Orientation = enum { - horizontal, - vertical, - - pub fn fromDirection(direction: apprt.action.SplitDirection) Orientation { - return switch (direction) { - .right, .left => .horizontal, - .down, .up => .vertical, - }; - } - - pub fn fromResizeDirection(direction: apprt.action.ResizeSplit.Direction) Orientation { - return switch (direction) { - .up, .down => .vertical, - .left, .right => .horizontal, - }; - } -}; - -/// Our actual GtkPaned widget -paned: *gtk.Paned, - -/// The container for this split panel. -container: Surface.Container, - -/// The orientation of this split panel. -orientation: Orientation, - -/// The elements of this split panel. -top_left: Surface.Container.Elem, -bottom_right: Surface.Container.Elem, - -/// Create a new split panel with the given sibling surface in the given -/// direction. The direction is where the new surface will be initialized. -/// -/// The sibling surface can be in a split already or it can be within a -/// tab. This properly handles updating the surface container so that -/// it represents the new split. -pub fn create( - alloc: Allocator, - sibling: *Surface, - direction: apprt.action.SplitDirection, -) !*Split { - var split = try alloc.create(Split); - errdefer alloc.destroy(split); - try split.init(sibling, direction); - return split; -} - -pub fn init( - self: *Split, - sibling: *Surface, - direction: apprt.action.SplitDirection, -) !void { - // If our sibling is too small to be split in half then we don't - // allow the split to happen. This avoids a situation where the - // split becomes too small. - // - // This is kind of a hack. Ideally we'd use gtk_widget_set_size_request - // properly along the path to ensure minimum sizes. I don't know if - // GTK even respects that all but any way GTK does this for us seems - // better than this. - { - // This is the min size of the sibling split. This means the - // smallest split is half of this. - const multiplier = 4; - - const size = &sibling.core_surface.size; - const small = switch (direction) { - .right, .left => size.screen.width < size.cell.width * multiplier, - .down, .up => size.screen.height < size.cell.height * multiplier, - }; - if (small) return error.SplitTooSmall; - } - - // Create the new child surface for the other direction. - const alloc = sibling.app.core_app.alloc; - var surface = try Surface.create(alloc, sibling.app, .{ - .parent = &sibling.core_surface, - }); - errdefer surface.destroy(alloc); - sibling.dimSurface(); - sibling.setSplitZoom(false); - - // Create the actual GTKPaned, attach the proper children. - const orientation: gtk.Orientation = switch (direction) { - .right, .left => .horizontal, - .down, .up => .vertical, - }; - const paned = gtk.Paned.new(orientation); - errdefer paned.unref(); - - // Keep a long-lived reference, which we unref in destroy. - paned.ref(); - - // Update all of our containers to point to the right place. - // The split has to point to where the sibling pointed to because - // we're inheriting its parent. The sibling points to its location - // in the split, and the surface points to the other location. - const container = sibling.container; - const tl: *Surface, const br: *Surface = switch (direction) { - .right, .down => right_down: { - sibling.container = .{ .split_tl = &self.top_left }; - surface.container = .{ .split_br = &self.bottom_right }; - break :right_down .{ sibling, surface }; - }, - - .left, .up => left_up: { - sibling.container = .{ .split_br = &self.bottom_right }; - surface.container = .{ .split_tl = &self.top_left }; - break :left_up .{ surface, sibling }; - }, - }; - - self.* = .{ - .paned = paned, - .container = container, - .top_left = .{ .surface = tl }, - .bottom_right = .{ .surface = br }, - .orientation = .fromDirection(direction), - }; - - // Replace the previous containers element with our split. This allows a - // non-split to become a split, a split to become a nested split, etc. - container.replace(.{ .split = self }); - - // Update our children so that our GL area is properly added to the paned. - self.updateChildren(); - - // The new surface should always grab focus - surface.grabFocus(); -} - -pub fn destroy(self: *Split, alloc: Allocator) void { - self.top_left.deinit(alloc); - self.bottom_right.deinit(alloc); - - // Clean up our GTK reference. This will trigger all the destroy callbacks - // that are necessary for the surfaces to clean up. - self.paned.unref(); - - alloc.destroy(self); -} - -/// Remove the top left child. -pub fn removeTopLeft(self: *Split) void { - self.removeChild(self.top_left, self.bottom_right); -} - -/// Remove the top left child. -pub fn removeBottomRight(self: *Split) void { - self.removeChild(self.bottom_right, self.top_left); -} - -fn removeChild( - self: *Split, - remove: Surface.Container.Elem, - keep: Surface.Container.Elem, -) void { - const window = self.container.window() orelse return; - const alloc = window.app.core_app.alloc; - - // Remove our children since we are going to no longer be a split anyways. - // This prevents widgets with multiple parents. - self.removeChildren(); - - // Our container must become whatever our top left is - self.container.replace(keep); - - // Grab focus of the left-over side - keep.grabFocus(); - - // When a child is removed we are no longer a split, so destroy ourself - remove.deinit(alloc); - alloc.destroy(self); -} - -/// Move the divider in the given direction by the given amount. -pub fn moveDivider( - self: *Split, - direction: apprt.action.ResizeSplit.Direction, - amount: u16, -) void { - const min_pos = 10; - - const pos = self.paned.getPosition(); - const new = switch (direction) { - .up, .left => @max(pos - amount, min_pos), - .down, .right => new_pos: { - const max_pos: u16 = @as(u16, @intFromFloat(self.maxPosition())) - min_pos; - break :new_pos @min(pos + amount, max_pos); - }, - }; - - self.paned.setPosition(new); -} - -/// Equalize the splits in this split panel. Each split is equalized based on -/// its weight, i.e. the number of Surfaces it contains. -/// -/// It works recursively by equalizing the children of each split. -/// -/// It returns this split's weight. -pub fn equalize(self: *Split) f64 { - // Calculate weights of top_left/bottom_right - const top_left_weight = self.top_left.equalize(); - const bottom_right_weight = self.bottom_right.equalize(); - const weight = top_left_weight + bottom_right_weight; - - // Ratio of top_left weight to overall weight, which gives the split ratio - const ratio = top_left_weight / weight; - - // Convert split ratio into new position for divider - self.paned.setPosition(@intFromFloat(self.maxPosition() * ratio)); - - return weight; -} - -// maxPosition returns the maximum position of the GtkPaned, which is the -// "max-position" attribute. -fn maxPosition(self: *Split) f64 { - var value: gobject.Value = std.mem.zeroes(gobject.Value); - defer value.unset(); - - _ = value.init(gobject.ext.types.int); - self.paned.as(gobject.Object).getProperty( - "max-position", - &value, - ); - - return @floatFromInt(value.getInt()); -} - -// This replaces the element at the given pointer with a new element. -// The ptr must be either top_left or bottom_right (asserted in debug). -// The memory of the old element must be freed or otherwise handled by -// the caller. -pub fn replace( - self: *Split, - ptr: *Surface.Container.Elem, - new: Surface.Container.Elem, -) void { - // We can write our element directly. There's nothing special. - assert(&self.top_left == ptr or &self.bottom_right == ptr); - ptr.* = new; - - // Update our paned children. This will reset the divider - // position but we want to keep it in place so save and restore it. - const pos = self.paned.getPosition(); - defer self.paned.setPosition(pos); - self.updateChildren(); -} - -// grabFocus grabs the focus of the top-left element. -pub fn grabFocus(self: *Split) void { - self.top_left.grabFocus(); -} - -/// Update the paned children to represent the current state. -/// This should be called anytime the top/left or bottom/right -/// element is changed. -pub fn updateChildren(self: *const Split) void { - // We have to set both to null. If we overwrite the pane with - // the same value, then GTK bugs out (the GL area unrealizes - // and never rerealizes). - self.removeChildren(); - - // Set our current children - self.paned.setStartChild(self.top_left.widget()); - self.paned.setEndChild(self.bottom_right.widget()); -} - -/// A mapping of direction to the element (if any) in that direction. -pub const DirectionMap = std.EnumMap( - apprt.action.GotoSplit, - ?*Surface, -); - -pub const Side = enum { top_left, bottom_right }; - -/// Returns the map that can be used to determine elements in various -/// directions (primarily for gotoSplit). -pub fn directionMap(self: *const Split, from: Side) DirectionMap { - var result = DirectionMap.initFull(null); - - if (self.directionPrevious(from)) |prev| { - result.put(.previous, prev.surface); - if (!prev.wrapped) { - result.put(.up, prev.surface); - } - } - - if (self.directionNext(from)) |next| { - result.put(.next, next.surface); - if (!next.wrapped) { - result.put(.down, next.surface); - } - } - - if (self.directionLeft(from)) |left| { - result.put(.left, left); - } - - if (self.directionRight(from)) |right| { - result.put(.right, right); - } - - return result; -} - -fn directionLeft(self: *const Split, from: Side) ?*Surface { - switch (from) { - .bottom_right => { - switch (self.orientation) { - .horizontal => return self.top_left.deepestSurface(.bottom_right), - .vertical => return directionLeft( - self.container.split() orelse return null, - .bottom_right, - ), - } - }, - .top_left => return directionLeft( - self.container.split() orelse return null, - .bottom_right, - ), - } -} - -fn directionRight(self: *const Split, from: Side) ?*Surface { - switch (from) { - .top_left => { - switch (self.orientation) { - .horizontal => return self.bottom_right.deepestSurface(.top_left), - .vertical => return directionRight( - self.container.split() orelse return null, - .top_left, - ), - } - }, - .bottom_right => return directionRight( - self.container.split() orelse return null, - .top_left, - ), - } -} - -fn directionPrevious(self: *const Split, from: Side) ?struct { - surface: *Surface, - wrapped: bool, -} { - switch (from) { - // From the bottom right, our previous is the deepest surface - // in the top-left of our own split. - .bottom_right => return .{ - .surface = self.top_left.deepestSurface(.bottom_right) orelse return null, - .wrapped = false, - }, - - // From the top left its more complicated. It is the de - .top_left => { - // If we have no parent split then there can be no unwrapped prev. - // We can still have a wrapped previous. - const parent = self.container.split() orelse return .{ - .surface = self.bottom_right.deepestSurface(.bottom_right) orelse return null, - .wrapped = true, - }; - - // The previous value is the previous of the side that we are. - const side = self.container.splitSide() orelse return null; - return switch (side) { - .top_left => parent.directionPrevious(.top_left), - .bottom_right => parent.directionPrevious(.bottom_right), - }; - }, - } -} - -fn directionNext(self: *const Split, from: Side) ?struct { - surface: *Surface, - wrapped: bool, -} { - switch (from) { - // From the top left, our next is the earliest surface in the - // top-left direction of the bottom-right side of our split. Fun! - .top_left => return .{ - .surface = self.bottom_right.deepestSurface(.top_left) orelse return null, - .wrapped = false, - }, - - // From the bottom right is more compliated. It is the deepest - // (last) surface in the - .bottom_right => { - // If we have no parent split then there can be no next. - const parent = self.container.split() orelse return .{ - .surface = self.top_left.deepestSurface(.top_left) orelse return null, - .wrapped = true, - }; - - // The previous value is the previous of the side that we are. - const side = self.container.splitSide() orelse return null; - return switch (side) { - .top_left => parent.directionNext(.top_left), - .bottom_right => parent.directionNext(.bottom_right), - }; - }, - } -} - -pub fn detachTopLeft(self: *const Split) void { - self.paned.setStartChild(null); -} - -pub fn detachBottomRight(self: *const Split) void { - self.paned.setEndChild(null); -} - -fn removeChildren(self: *const Split) void { - self.detachTopLeft(); - self.detachBottomRight(); -} diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig deleted file mode 100644 index 231ab0c09..000000000 --- a/src/apprt/gtk/Surface.zig +++ /dev/null @@ -1,2561 +0,0 @@ -/// A surface represents one drawable terminal surface. The surface may be -/// attached to a window or it may be some other kind of surface. This struct -/// is meant to be generic to all scenarios. -const Surface = @This(); - -const std = @import("std"); - -const adw = @import("adw"); -const gtk = @import("gtk"); -const gdk = @import("gdk"); -const glib = @import("glib"); -const gio = @import("gio"); -const gobject = @import("gobject"); - -const Allocator = std.mem.Allocator; -const build_config = @import("../../build_config.zig"); -const build_options = @import("build_options"); -const configpkg = @import("../../config.zig"); -const apprt = @import("../../apprt.zig"); -const font = @import("../../font/main.zig"); -const i18n = @import("../../os/main.zig").i18n; -const input = @import("../../input.zig"); -const renderer = @import("../../renderer.zig"); -const terminal = @import("../../terminal/main.zig"); -const CoreSurface = @import("../../Surface.zig"); -const internal_os = @import("../../os/main.zig"); - -const App = @import("App.zig"); -const Split = @import("Split.zig"); -const Tab = @import("Tab.zig"); -const Window = @import("Window.zig"); -const Menu = @import("menu.zig").Menu; -const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig"); -const ResizeOverlay = @import("ResizeOverlay.zig"); -const URLWidget = @import("URLWidget.zig"); -const CloseDialog = @import("CloseDialog.zig"); -const inspectorpkg = @import("inspector.zig"); -const gtk_key = @import("key.zig"); -const Builder = @import("Builder.zig"); -const ProgressBar = @import("ProgressBar.zig"); -const adw_version = @import("adw_version.zig"); - -const log = std.log.scoped(.gtk_surface); - -pub const Options = struct { - /// The parent surface to inherit settings such as font size, working - /// directory, etc. from. - parent: ?*CoreSurface = null, -}; - -/// The container that this surface is directly attached to. -pub const Container = union(enum) { - /// The surface is not currently attached to anything. This means - /// that the GLArea has been created and potentially initialized - /// but the widget is currently floating and not part of any parent. - none: void, - - /// Directly attached to a tab. (i.e. no splits) - tab_: *Tab, - - /// A split within a split hierarchy. The key determines the - /// position of the split within the parent split. - split_tl: *Elem, - split_br: *Elem, - - /// The side of the split. - pub const SplitSide = enum { top_left, bottom_right }; - - /// Elem is the possible element of any container. A container can - /// hold both a surface and a split. Any valid container should - /// have an Elem value so that it can be properly used with - /// splits. - pub const Elem = union(enum) { - /// A surface is a leaf element of the split -- a terminal - /// surface. - surface: *Surface, - - /// A split is a nested split within a split. This lets you - /// for example have a horizontal split with a vertical split - /// on the left side (amongst all other possible - /// combinations). - split: *Split, - - /// Returns the GTK widget to add to the paned for the given - /// element - pub fn widget(self: Elem) *gtk.Widget { - return switch (self) { - .surface => |s| s.primaryWidget(), - .split => |s| s.paned.as(gtk.Widget), - }; - } - - pub fn containerPtr(self: Elem) *Container { - return switch (self) { - .surface => |s| &s.container, - .split => |s| &s.container, - }; - } - - pub fn deinit(self: Elem, alloc: Allocator) void { - switch (self) { - .surface => |s| s.unref(), - .split => |s| s.destroy(alloc), - } - } - - pub fn grabFocus(self: Elem) void { - switch (self) { - .surface => |s| s.grabFocus(), - .split => |s| s.grabFocus(), - } - } - - pub fn equalize(self: Elem) f64 { - return switch (self) { - .surface => 1, - .split => |s| s.equalize(), - }; - } - - /// The last surface in this container in the direction specified. - /// Direction must be "top_left" or "bottom_right". - pub fn deepestSurface(self: Elem, side: SplitSide) ?*Surface { - return switch (self) { - .surface => |s| s, - .split => |s| (switch (side) { - .top_left => s.top_left, - .bottom_right => s.bottom_right, - }).deepestSurface(side), - }; - } - }; - - /// Returns the window that this surface is attached to. - pub fn window(self: Container) ?*Window { - return switch (self) { - .none => null, - .tab_ => |v| v.window, - .split_tl, .split_br => split: { - const s = self.split() orelse break :split null; - break :split s.container.window(); - }, - }; - } - - /// Returns the tab container if it exists. - pub fn tab(self: Container) ?*Tab { - return switch (self) { - .none => null, - .tab_ => |v| v, - .split_tl, .split_br => split: { - const s = self.split() orelse break :split null; - break :split s.container.tab(); - }, - }; - } - - /// Returns the split containing this surface (if any). - pub fn split(self: Container) ?*Split { - return switch (self) { - .none, .tab_ => null, - .split_tl => |ptr| @fieldParentPtr("top_left", ptr), - .split_br => |ptr| @fieldParentPtr("bottom_right", ptr), - }; - } - - /// The side that we are in the split. - pub fn splitSide(self: Container) ?SplitSide { - return switch (self) { - .none, .tab_ => null, - .split_tl => .top_left, - .split_br => .bottom_right, - }; - } - - /// Returns the first split with the given orientation, walking upwards in - /// the tree. - pub fn firstSplitWithOrientation( - self: Container, - orientation: Split.Orientation, - ) ?*Split { - return switch (self) { - .none, .tab_ => null, - .split_tl, .split_br => split: { - const s = self.split() orelse break :split null; - if (s.orientation == orientation) break :split s; - break :split s.container.firstSplitWithOrientation(orientation); - }, - }; - } - - /// Replace the container's element with this element. This is - /// used by children to modify their parents to for example change - /// from a surface to a split or a split back to a surface or - /// a split to a nested split and so on. - pub fn replace(self: Container, elem: Elem) void { - // Move the element into the container - switch (self) { - .none => {}, - .tab_ => |t| t.replaceElem(elem), - inline .split_tl, .split_br => |ptr| { - const s = self.split().?; - s.replace(ptr, elem); - }, - } - - // Update the reverse reference to the container - elem.containerPtr().* = self; - } - - /// Remove ourselves from the container. This is used by - /// children to effectively notify they're container that - /// all children at this level are exiting. - pub fn remove(self: Container) void { - switch (self) { - .none => {}, - .tab_ => |t| t.remove(), - .split_tl => self.split().?.removeTopLeft(), - .split_br => self.split().?.removeBottomRight(), - } - } -}; - -/// Whether the surface has been realized or not yet. When a surface is -/// "realized" it means that the OpenGL context is ready and the core -/// surface has been initialized. -realized: bool = false, - -/// The config to use to initialize a surface. -init_config: InitConfig, - -/// The GUI container that this surface has been attached to. This -/// dictates some behaviors such as new splits, etc. -container: Container = .{ .none = {} }, - -/// The app we're part of -app: *App, - -/// The overlay, this is the primary widget -overlay: *gtk.Overlay, - -/// Our GTK area -gl_area: *gtk.GLArea, - -/// If non-null this is the widget on the overlay that shows the URL. -url_widget: ?URLWidget = null, - -/// The overlay that shows resizing information. -resize_overlay: ResizeOverlay = undefined, - -/// Whether or not the current surface is zoomed in (see `toggle_split_zoom`). -zoomed_in: bool = false, - -/// If non-null this is the widget on the overlay which dims the surface when it is unfocused -unfocused_widget: ?*gtk.Widget = null, - -/// Any active cursor we may have -cursor: ?*gdk.Cursor = null, - -/// Our title. The raw value of the title. This will be kept up to date and -/// .title will be updated if we have focus. -/// When set the text in this buf will be null-terminated, because we need to -/// pass it to GTK. -title_text: ?[:0]const u8 = null, - -/// The title of the surface as reported by the terminal. If it is null, the -/// title reported by the terminal is currently being used. If the title was -/// manually overridden by the user, this will be set to a non-null value -/// representing the default terminal title. -title_from_terminal: ?[:0]const u8 = null, - -/// Our current working directory. We use this value for setting tooltips in -/// the headerbar subtitle if we have focus. When set, the text in this buf -/// will be null-terminated because we need to pass it to GTK. -pwd: ?[:0]const u8 = null, - -/// The timer used to delay title updates in order to prevent flickering. -update_title_timer: ?c_uint = null, - -/// The core surface backing this surface -core_surface: CoreSurface, - -/// The font size to use for this surface once realized. -font_size: ?font.face.DesiredSize = null, - -/// Cached metrics about the surface from GTK callbacks. -size: apprt.SurfaceSize, -cursor_pos: apprt.CursorPos, - -/// Inspector state. -inspector: ?*inspectorpkg.Inspector = null, - -/// Key input states. See gtkKeyPressed for detailed descriptions. -in_keyevent: IMKeyEvent = .false, -im_context: *gtk.IMMulticontext, -im_composing: bool = false, -im_buf: [128]u8 = undefined, -im_len: u7 = 0, - -/// The surface-specific cgroup path. See App.transient_cgroup_path for -/// details on what this is. -cgroup_path: ?[]const u8 = null, - -/// Our context menu. -context_menu: Menu(Surface, "context_menu", false), - -/// True when we have a precision scroll in progress -precision_scroll: bool = false, - -/// Flag indicating whether the surface is in secure input mode. -is_secure_input: bool = false, - -/// Structure for managing GUI progress bar -progress_bar: ProgressBar, - -/// The state of the key event while we're doing IM composition. -/// See gtkKeyPressed for detailed descriptions. -pub const IMKeyEvent = enum { - /// Not in a key event. - false, - - /// In a key event but im_composing was either true or false - /// prior to the calling IME processing. This is important to - /// work around different input methods calling commit and - /// preedit end in a different order. - composing, - not_composing, -}; - -/// Configuration used for initializing the surface. We have to copy some -/// data since initialization is delayed with GTK (on realize). -pub const InitConfig = struct { - parent: bool = false, - pwd: ?[]const u8 = null, - - pub fn init( - alloc: Allocator, - app: *App, - opts: Options, - ) Allocator.Error!InitConfig { - const parent = opts.parent orelse return .{}; - - const pwd: ?[]const u8 = if (app.config.@"window-inherit-working-directory") - try parent.pwd(alloc) - else - null; - errdefer if (pwd) |p| alloc.free(p); - - return .{ - .parent = true, - .pwd = pwd, - }; - } - - pub fn deinit(self: *InitConfig, alloc: Allocator) void { - if (self.pwd) |pwd| alloc.free(pwd); - } -}; - -pub fn create(alloc: Allocator, app: *App, opts: Options) !*Surface { - var surface = try alloc.create(Surface); - errdefer alloc.destroy(surface); - try surface.init(app, opts); - return surface; -} - -pub fn init(self: *Surface, app: *App, opts: Options) !void { - const gl_area = gtk.GLArea.new(); - const gl_area_widget = gl_area.as(gtk.Widget); - - // Create an overlay so we can layer the GL area with other widgets. - const overlay = gtk.Overlay.new(); - errdefer overlay.unref(); - const overlay_widget = overlay.as(gtk.Widget); - overlay.setChild(gl_area_widget); - - // Overlay is not focusable, but the GL area is. - overlay_widget.setFocusable(0); - overlay_widget.setFocusOnClick(0); - - // We grab the floating reference to the primary widget. This allows the - // widget tree to be moved around i.e. between a split, a tab, etc. - // without having to be really careful about ordering to - // prevent a destroy. - // - // This is unref'd in the unref() method that's called by the - // self.container through Elem.deinit. - _ = overlay.as(gobject.Object).refSink(); - errdefer overlay.unref(); - - // We want the gl area to expand to fill the parent container. - gl_area_widget.setHexpand(1); - gl_area_widget.setVexpand(1); - - // Various other GL properties - gl_area_widget.setCursorFromName("text"); - gl_area.setRequiredVersion( - renderer.OpenGL.MIN_VERSION_MAJOR, - renderer.OpenGL.MIN_VERSION_MINOR, - ); - gl_area.setHasStencilBuffer(0); - gl_area.setHasDepthBuffer(0); - gl_area.setUseEs(0); - - // Key event controller will tell us about raw keypress events. - const ec_key = gtk.EventControllerKey.new(); - errdefer ec_key.unref(); - overlay_widget.addController(ec_key.as(gtk.EventController)); - errdefer overlay_widget.removeController(ec_key.as(gtk.EventController)); - - // Focus controller will tell us about focus enter/exit events - const ec_focus = gtk.EventControllerFocus.new(); - errdefer ec_focus.unref(); - overlay_widget.addController(ec_focus.as(gtk.EventController)); - errdefer overlay_widget.removeController(ec_focus.as(gtk.EventController)); - - // Create a second key controller so we can receive the raw - // key-press events BEFORE the input method gets them. - const ec_key_press = gtk.EventControllerKey.new(); - errdefer ec_key_press.unref(); - overlay_widget.addController(ec_key_press.as(gtk.EventController)); - errdefer overlay_widget.removeController(ec_key_press.as(gtk.EventController)); - - // Clicks - const gesture_click = gtk.GestureClick.new(); - errdefer gesture_click.unref(); - gesture_click.as(gtk.GestureSingle).setButton(0); - overlay_widget.addController(gesture_click.as(gtk.EventController)); - errdefer overlay_widget.removeController(gesture_click.as(gtk.EventController)); - - // Mouse movement - const ec_motion = gtk.EventControllerMotion.new(); - errdefer ec_motion.unref(); - overlay_widget.addController(ec_motion.as(gtk.EventController)); - errdefer overlay_widget.removeController(ec_motion.as(gtk.EventController)); - - // Scroll events - const ec_scroll = gtk.EventControllerScroll.new(.flags_both_axes); - errdefer ec_scroll.unref(); - overlay_widget.addController(ec_scroll.as(gtk.EventController)); - errdefer overlay_widget.removeController(ec_scroll.as(gtk.EventController)); - - // The input method context that we use to translate key events into - // characters. This doesn't have an event key controller attached because - // we call it manually from our own key controller. - const im_context = gtk.IMMulticontext.new(); - errdefer im_context.unref(); - - // The GL area has to be focusable so that it can receive events - gl_area_widget.setFocusable(1); - gl_area_widget.setFocusOnClick(1); - - // Set up to handle items being dropped on our surface. Files can be dropped - // from Nautilus and strings can be dropped from many programs. - const drop_target = gtk.DropTarget.new(gobject.ext.types.invalid, .flags_copy); - errdefer drop_target.unref(); - // The order of the types matters. - var drop_target_types = [_]gobject.Type{ - gdk.FileList.getGObjectType(), - gio.File.getGObjectType(), - gobject.ext.types.string, - }; - drop_target.setGtypes(&drop_target_types, drop_target_types.len); - overlay_widget.addController(drop_target.as(gtk.EventController)); - errdefer overlay_widget.removeController(drop_target.as(gtk.EventController)); - - // Inherit the parent's font size if we have a parent. - const font_size: ?font.face.DesiredSize = font_size: { - if (!app.config.@"window-inherit-font-size") break :font_size null; - const parent = opts.parent orelse break :font_size null; - break :font_size parent.font_size; - }; - - // If the parent has a transient cgroup, then we're creating cgroups - // for each surface if we can. We need to create a child cgroup. - const cgroup_path: ?[]const u8 = cgroup: { - const base = app.transient_cgroup_base orelse break :cgroup null; - - // For the unique group name we use the self pointer. This may - // not be a good idea for security reasons but not sure yet. We - // may want to change this to something else eventually to be safe. - var buf: [256]u8 = undefined; - const name = std.fmt.bufPrint( - &buf, - "surfaces/{X}.scope", - .{@intFromPtr(self)}, - ) catch unreachable; - - // Create the cgroup. If it fails, no big deal... just ignore. - internal_os.cgroup.create(base, name, null) catch |err| { - log.err("failed to create surface cgroup err={}", .{err}); - break :cgroup null; - }; - - // Success, save the cgroup path. - break :cgroup std.fmt.allocPrint( - app.core_app.alloc, - "{s}/{s}", - .{ base, name }, - ) catch null; - }; - errdefer if (cgroup_path) |path| app.core_app.alloc.free(path); - - // Build our initialization config - const init_config = try InitConfig.init(app.core_app.alloc, app, opts); - errdefer init_config.deinit(app.core_app.alloc); - - // Build our result - self.* = .{ - .app = app, - .container = .{ .none = {} }, - .overlay = overlay, - .gl_area = gl_area, - .resize_overlay = undefined, - .title_text = null, - .core_surface = undefined, - .font_size = font_size, - .init_config = init_config, - .size = .{ .width = 800, .height = 600 }, - .cursor_pos = .{ .x = -1, .y = -1 }, - .im_context = im_context, - .cgroup_path = cgroup_path, - .context_menu = undefined, - .progress_bar = .init(self), - }; - errdefer self.* = undefined; - - // initialize the context menu - self.context_menu.init(self); - self.context_menu.setParent(overlay.as(gtk.Widget)); - - // initialize the resize overlay - self.resize_overlay.init(self, &app.config); - - // Set our default mouse shape - try self.setMouseShape(.text); - - // GL events - _ = gtk.Widget.signals.realize.connect( - gl_area, - *Surface, - gtkRealize, - self, - .{}, - ); - _ = gtk.Widget.signals.unrealize.connect( - gl_area, - *Surface, - gtkUnrealize, - self, - .{}, - ); - _ = gtk.Widget.signals.destroy.connect( - gl_area, - *Surface, - gtkDestroy, - self, - .{}, - ); - _ = gtk.GLArea.signals.render.connect( - gl_area, - *Surface, - gtkRender, - self, - .{}, - ); - _ = gtk.GLArea.signals.resize.connect( - gl_area, - *Surface, - gtkResize, - self, - .{}, - ); - _ = gtk.EventControllerKey.signals.key_pressed.connect( - ec_key_press, - *Surface, - gtkKeyPressed, - self, - .{}, - ); - _ = gtk.EventControllerKey.signals.key_released.connect( - ec_key_press, - *Surface, - gtkKeyReleased, - self, - .{}, - ); - _ = gtk.EventControllerFocus.signals.enter.connect( - ec_focus, - *Surface, - gtkFocusEnter, - self, - .{}, - ); - _ = gtk.EventControllerFocus.signals.leave.connect( - ec_focus, - *Surface, - gtkFocusLeave, - self, - .{}, - ); - _ = gtk.GestureClick.signals.pressed.connect( - gesture_click, - *Surface, - gtkMouseDown, - self, - .{}, - ); - _ = gtk.GestureClick.signals.released.connect( - gesture_click, - *Surface, - gtkMouseUp, - self, - .{}, - ); - _ = gtk.EventControllerMotion.signals.motion.connect( - ec_motion, - *Surface, - gtkMouseMotion, - self, - .{}, - ); - _ = gtk.EventControllerMotion.signals.leave.connect( - ec_motion, - *Surface, - gtkMouseLeave, - self, - .{}, - ); - _ = gtk.EventControllerScroll.signals.scroll.connect( - ec_scroll, - *Surface, - gtkMouseScroll, - self, - .{}, - ); - _ = gtk.EventControllerScroll.signals.scroll_begin.connect( - ec_scroll, - *Surface, - gtkMouseScrollPrecisionBegin, - self, - .{}, - ); - _ = gtk.EventControllerScroll.signals.scroll_end.connect( - ec_scroll, - *Surface, - gtkMouseScrollPrecisionEnd, - self, - .{}, - ); - _ = gtk.IMContext.signals.preedit_start.connect( - im_context, - *Surface, - gtkInputPreeditStart, - self, - .{}, - ); - _ = gtk.IMContext.signals.preedit_changed.connect( - im_context, - *Surface, - gtkInputPreeditChanged, - self, - .{}, - ); - _ = gtk.IMContext.signals.preedit_end.connect( - im_context, - *Surface, - gtkInputPreeditEnd, - self, - .{}, - ); - _ = gtk.IMContext.signals.commit.connect( - im_context, - *Surface, - gtkInputCommit, - self, - .{}, - ); - _ = gtk.DropTarget.signals.drop.connect( - drop_target, - *Surface, - gtkDrop, - self, - .{}, - ); -} - -fn realize(self: *Surface) !void { - // If this surface has already been realized, then we don't need to - // reinitialize. This can happen if a surface is moved from one GDK - // surface to another (i.e. a tab is pulled out into a window). - if (self.realized) { - // If we have no OpenGL state though, we do need to reinitialize. - // We allow the renderer to figure that out, and then queue a draw. - try self.core_surface.renderer.displayRealized(); - self.redraw(); - return; - } - - // Add ourselves to the list of surfaces on the app. - try self.app.core_app.addSurface(self); - errdefer self.app.core_app.deleteSurface(self); - - // Get our new surface config - var config = try apprt.surface.newConfig(self.app.core_app, &self.app.config); - defer config.deinit(); - - if (self.init_config.pwd) |pwd| { - // If we have a working directory we want, then we force that. - config.@"working-directory" = pwd; - } else if (!self.init_config.parent) { - // A hack, see the "parent_surface" field for more information. - config.@"working-directory" = self.app.config.@"working-directory"; - } - - // Initialize our surface now that we have the stable pointer. - try self.core_surface.init( - self.app.core_app.alloc, - &config, - self.app.core_app, - self.app, - self, - ); - errdefer self.core_surface.deinit(); - - // If we have a font size we want, set that now - if (self.font_size) |size| { - try self.core_surface.setFontSize(size); - } - - // Note we're realized - self.realized = true; -} - -pub fn deinit(self: *Surface) void { - self.init_config.deinit(self.app.core_app.alloc); - if (self.title_text) |title| self.app.core_app.alloc.free(title); - if (self.title_from_terminal) |title| self.app.core_app.alloc.free(title); - if (self.pwd) |pwd| self.app.core_app.alloc.free(pwd); - - // We don't allocate anything if we aren't realized. - if (!self.realized) return; - - // Cleanup the progress bar. - self.progress_bar.deinit(); - - // Delete our inspector if we have one - self.controlInspector(.hide); - - // Remove ourselves from the list of known surfaces in the app. - self.app.core_app.deleteSurface(self); - - // Clean up our core surface so that all the rendering and IO stop. - self.core_surface.deinit(); - self.core_surface = undefined; - - // Remove the cgroup if we have one. We do this after deiniting the core - // surface to ensure all processes have exited. - if (self.cgroup_path) |path| { - internal_os.cgroup.remove(path) catch |err| { - // We don't want this to be fatal in any way so we just log - // and continue. A dangling empty cgroup is not a big deal - // and this should be rare. - log.warn( - "failed to remove cgroup for surface path={s} err={}", - .{ path, err }, - ); - }; - - self.app.core_app.alloc.free(path); - } - - // Free all our GTK stuff - // - // Note we don't do anything with the "unfocused_overlay" because - // it is attached to the overlay which by this point has been destroyed - // and therefore the unfocused_overlay has been destroyed as well. - self.im_context.unref(); - if (self.cursor) |cursor| cursor.unref(); - if (self.update_title_timer) |timer| _ = glib.Source.remove(timer); - self.resize_overlay.deinit(); -} - -pub fn core(self: *Surface) *CoreSurface { - return &self.core_surface; -} - -pub fn rtApp(self: *const Surface) *App { - return self.app; -} - -/// Update our local copy of any configuration that we use. -pub fn updateConfig(self: *Surface, config: *const configpkg.Config) !void { - self.resize_overlay.updateConfig(config); -} - -// unref removes the long-held reference to the gl_area and kicks off the -// deinit/destroy process for this surface. -pub fn unref(self: *Surface) void { - self.overlay.unref(); -} - -pub fn destroy(self: *Surface, alloc: Allocator) void { - self.deinit(); - alloc.destroy(self); -} - -pub fn primaryWidget(self: *Surface) *gtk.Widget { - return self.overlay.as(gtk.Widget); -} - -fn render(self: *Surface) !void { - try self.core_surface.renderer.drawFrame(true); -} - -/// Called by core surface to get the cgroup. -pub fn cgroup(self: *const Surface) ?[]const u8 { - return self.cgroup_path; -} - -/// Queue the inspector to render if we have one. -pub fn queueInspectorRender(self: *Surface) void { - if (self.inspector) |v| v.queueRender(); -} - -/// Invalidate the surface so that it forces a redraw on the next tick. -pub fn redraw(self: *Surface) void { - self.gl_area.queueRender(); -} - -/// Close this surface. -pub fn close(self: *Surface, process_active: bool) void { - self.closeWithConfirmation(process_active, .{ .surface = self }); -} - -/// Close this surface. -pub fn closeWithConfirmation(self: *Surface, process_active: bool, target: CloseDialog.Target) void { - self.setSplitZoom(false); - - if (!process_active) { - self.container.remove(); - return; - } - - CloseDialog.show(target) catch |err| { - log.err("failed to open close dialog={}", .{err}); - }; -} - -pub fn controlInspector( - self: *Surface, - mode: apprt.action.Inspector, -) void { - const show = switch (mode) { - .toggle => self.inspector == null, - .show => true, - .hide => false, - }; - - if (!show) { - if (self.inspector) |v| { - v.close(); - self.inspector = null; - } - - return; - } - - // If we already have an inspector, we don't need to show anything. - if (self.inspector != null) return; - self.inspector = inspectorpkg.Inspector.create( - self, - .{ .window = {} }, - ) catch |err| { - log.err("failed to control inspector err={}", .{err}); - return; - }; -} - -pub fn getContentScale(self: *const Surface) !apprt.ContentScale { - const gtk_scale: f32 = scale: { - const widget = self.gl_area.as(gtk.Widget); - // Future: detect GTK version 4.12+ and use gdk_surface_get_scale so we - // can support fractional scaling. - const scale = widget.getScaleFactor(); - if (scale <= 0) { - log.warn("gtk_widget_get_scale_factor returned a non-positive number: {}", .{scale}); - break :scale 1.0; - } - break :scale @floatFromInt(scale); - }; - - // Also scale using font-specific DPI, which is often exposed to the user - // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html). - const xft_dpi_scale = xft_scale: { - // gtk-xft-dpi is font DPI multiplied by 1024. See - // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html - const settings = gtk.Settings.getDefault() orelse break :xft_scale 1.0; - var value = std.mem.zeroes(gobject.Value); - defer value.unset(); - _ = value.init(gobject.ext.typeFor(c_int)); - settings.as(gobject.Object).getProperty("gtk-xft-dpi", &value); - const gtk_xft_dpi = value.getInt(); - - // Use a value of 1.0 for the XFT DPI scale if the setting is <= 0 - // See: - // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421 - // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead - if (gtk_xft_dpi <= 0) { - log.warn("gtk-xft-dpi was not set, using default value", .{}); - break :xft_scale 1.0; - } - - // As noted above gtk-xft-dpi is multiplied by 1024, so we divide by - // 1024, then divide by the default value (96) to derive a scale. Note - // gtk-xft-dpi can be fractional, so we use floating point math here. - const xft_dpi: f32 = @as(f32, @floatFromInt(gtk_xft_dpi)) / 1024.0; - break :xft_scale xft_dpi / 96.0; - }; - - const scale = gtk_scale * xft_dpi_scale; - return .{ .x = scale, .y = scale }; -} - -pub fn getSize(self: *const Surface) !apprt.SurfaceSize { - return self.size; -} - -pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void { - // If we've already become realized once then we ignore this - // request. The apprt initial_size action should only modify - // the physical size of the window during initialization. - // Subsequent actions are only informative in case we want to - // implement a "return to default size" action later. - if (self.realized) return; - - // If we are within a split, do not set the size. - if (self.container.split() != null) return; - - // This operation only makes sense if we're within a window view - // hierarchy and we're the first tab in the window. - const window = self.container.window() orelse return; - if (window.notebook.nPages() > 1) return; - - const gtk_window = window.window.as(gtk.Window); - - // Note: this doesn't properly take into account the window decorations. - // I'm not currently sure how to do that. - gtk_window.setDefaultSize(@intCast(width), @intCast(height)); -} - -pub fn setSizeLimits(self: *const Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void { - - // There's no support for setting max size at the moment. - _ = max_; - - // If we are within a split, do not set the size. - if (self.container.split() != null) return; - - // This operation only makes sense if we're within a window view - // hierarchy and we're the first tab in the window. - const window = self.container.window() orelse return; - if (window.notebook.nPages() > 1) return; - - const widget = window.window.as(gtk.Widget); - - // Note: this doesn't properly take into account the window decorations. - // I'm not currently sure how to do that. - widget.setSizeRequest(@intCast(min.width), @intCast(min.height)); -} - -pub fn grabFocus(self: *Surface) void { - if (self.container.tab()) |tab| { - // If any other surface was focused and zoomed in, set it to non zoomed in - // so that self can grab focus. - if (tab.focus_child) |focus_child| { - if (focus_child.zoomed_in and focus_child != self) { - focus_child.setSplitZoom(false); - } - } - tab.focus_child = self; - } - - _ = self.gl_area.as(gtk.Widget).grabFocus(); - - self.updateTitleLabels(); -} - -fn updateTitleLabels(self: *Surface) void { - // If we have no title, then we have nothing to update. - const title = self.getTitle() orelse return; - - // If we have a tab and are the focused child, then we have to update the tab - if (self.container.tab()) |tab| { - if (tab.focus_child == self) tab.setTitleText(title); - } - - // If we have a window and are focused, then we have to update the window title. - if (self.container.window()) |window| { - const widget = self.gl_area.as(gtk.Widget); - if (widget.isFocus() != 0) { - // Changing the title somehow unhides our cursor. - // https://github.com/ghostty-org/ghostty/issues/1419 - // I don't know a way around this yet. I've tried re-hiding the - // cursor after setting the title but it doesn't work, I think - // due to some gtk event loop things... - window.setTitle(title); - } - } -} - -const zoom_title_prefix = "🔍 "; -pub const SetTitleSource = enum { user, terminal }; - -pub fn setTitle(self: *Surface, slice: [:0]const u8, source: SetTitleSource) !void { - const alloc = self.app.core_app.alloc; - - // Always allocate with the "🔍 " at the beginning and slice accordingly - // is the surface is zoomed in or not. - const copy: [:0]const u8 = copy: { - const new_title = try alloc.allocSentinel(u8, zoom_title_prefix.len + slice.len, 0); - @memcpy(new_title[0..zoom_title_prefix.len], zoom_title_prefix); - @memcpy(new_title[zoom_title_prefix.len..], slice); - break :copy new_title; - }; - errdefer alloc.free(copy); - - // The user has overridden the title - // We only want to update the terminal provided title so that it can be restored to the most recent state. - if (self.title_from_terminal != null and source == .terminal) { - alloc.free(self.title_from_terminal.?); - self.title_from_terminal = copy; - return; - } - - if (self.title_text) |old| alloc.free(old); - self.title_text = copy; - - // delay the title update to prevent flickering - if (self.update_title_timer) |timer| { - if (glib.Source.remove(timer) == 0) { - log.warn("unable to remove update title timer", .{}); - } - self.update_title_timer = null; - } - self.update_title_timer = glib.timeoutAdd(75, updateTitleTimerExpired, self); -} - -fn updateTitleTimerExpired(ud: ?*anyopaque) callconv(.c) c_int { - const self: *Surface = @ptrCast(@alignCast(ud.?)); - - self.updateTitleLabels(); - self.update_title_timer = null; - - return 0; -} - -pub fn getTitle(self: *Surface) ?[:0]const u8 { - if (self.title_text) |title_text| { - return self.resolveTitle(title_text); - } - - return null; -} - -pub fn getTerminalTitle(self: *Surface) ?[:0]const u8 { - if (self.title_from_terminal) |title_text| { - return self.resolveTitle(title_text); - } - - return null; -} - -fn resolveTitle(self: *Surface, title: [:0]const u8) [:0]const u8 { - return if (self.zoomed_in) - title - else - title[zoom_title_prefix.len..]; -} - -pub fn promptTitle(self: *Surface) !void { - if (!adw_version.atLeast(1, 5, 0)) return; - const window = self.container.window() orelse return; - - var builder = Builder.init("prompt-title-dialog", 1, 5); - defer builder.deinit(); - - const entry = builder.getObject(gtk.Entry, "title_entry").?; - entry.getBuffer().setText(self.getTitle() orelse "", -1); - - const dialog = builder.getObject(adw.AlertDialog, "prompt_title_dialog").?; - dialog.choose(window.window.as(gtk.Widget), null, gtkPromptTitleResponse, self); -} - -/// Set the current working directory of the surface. -/// -/// In addition, update the tab's tooltip text, and if we are the focused child, -/// update the subtitle of the containing window. -pub fn setPwd(self: *Surface, pwd: [:0]const u8) !void { - if (self.container.tab()) |tab| { - tab.setTooltipText(pwd); - - if (tab.focus_child == self) { - if (self.container.window()) |window| { - if (self.app.config.@"window-subtitle" == .@"working-directory") window.setSubtitle(pwd); - } - } - } - - const alloc = self.app.core_app.alloc; - - // Failing to set the surface's current working directory is not a big - // deal since we just used our slice parameter which is the same value. - if (self.pwd) |old| alloc.free(old); - self.pwd = alloc.dupeZ(u8, pwd) catch null; -} - -pub fn setMouseShape( - self: *Surface, - shape: terminal.MouseShape, -) !void { - const name: [:0]const u8 = switch (shape) { - .default => "default", - .help => "help", - .pointer => "pointer", - .context_menu => "context-menu", - .progress => "progress", - .wait => "wait", - .cell => "cell", - .crosshair => "crosshair", - .text => "text", - .vertical_text => "vertical-text", - .alias => "alias", - .copy => "copy", - .no_drop => "no-drop", - .move => "move", - .not_allowed => "not-allowed", - .grab => "grab", - .grabbing => "grabbing", - .all_scroll => "all-scroll", - .col_resize => "col-resize", - .row_resize => "row-resize", - .n_resize => "n-resize", - .e_resize => "e-resize", - .s_resize => "s-resize", - .w_resize => "w-resize", - .ne_resize => "ne-resize", - .nw_resize => "nw-resize", - .se_resize => "se-resize", - .sw_resize => "sw-resize", - .ew_resize => "ew-resize", - .ns_resize => "ns-resize", - .nesw_resize => "nesw-resize", - .nwse_resize => "nwse-resize", - .zoom_in => "zoom-in", - .zoom_out => "zoom-out", - }; - - const cursor = gdk.Cursor.newFromName(name.ptr, null) orelse { - log.warn("unsupported cursor name={s}", .{name}); - return; - }; - errdefer cursor.unref(); - - // Set our new cursor. We only do this if the cursor we currently - // have is NOT set to "none" because setting the cursor causes it - // to become visible again. - const widget = self.gl_area.as(gtk.Widget); - if (widget.getCursor() != self.app.cursor_none) { - widget.setCursor(cursor); - } - - // Free our existing cursor - if (self.cursor) |old| old.unref(); - self.cursor = cursor; -} - -/// Set the visibility of the mouse cursor. -pub fn setMouseVisibility(self: *Surface, visible: bool) void { - // Note in there that self.cursor or cursor_none may be null. That's - // not a problem because NULL is a valid argument for set cursor - // which means to just use the parent value. - const widget = self.gl_area.as(gtk.Widget); - - if (visible) { - widget.setCursor(self.cursor); - return; - } - - // Set our new cursor to the app "none" cursor - widget.setCursor(self.app.cursor_none); -} - -pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { - const uri = uri_ orelse { - if (self.url_widget) |*widget| { - widget.deinit(self.overlay); - self.url_widget = null; - } - - return; - }; - - // We need a null-terminated string - const alloc = self.app.core_app.alloc; - const uriZ = alloc.dupeZ(u8, uri) catch return; - defer alloc.free(uriZ); - - // If we have a URL widget already just change the text. - if (self.url_widget) |widget| { - widget.setText(uriZ); - return; - } - - self.url_widget = .init(self.overlay, uriZ); -} - -pub fn supportsClipboard( - self: *const Surface, - clipboard_type: apprt.Clipboard, -) bool { - _ = self; - return switch (clipboard_type) { - .standard, - .selection, - .primary, - => true, - }; -} - -pub fn clipboardRequest( - self: *Surface, - clipboard_type: apprt.Clipboard, - state: apprt.ClipboardRequest, -) !void { - // We allocate for userdata for the clipboard request. Not ideal but - // clipboard requests aren't common so probably not a big deal. - const alloc = self.app.core_app.alloc; - const ud_ptr = try alloc.create(ClipboardRequest); - errdefer alloc.destroy(ud_ptr); - ud_ptr.* = .{ .self = self, .state = state }; - - // Start our async request - const clipboard = getClipboard(self.gl_area.as(gtk.Widget), clipboard_type) orelse return; - - clipboard.readTextAsync(null, gtkClipboardRead, ud_ptr); -} - -pub fn setClipboardString( - self: *Surface, - val: [:0]const u8, - clipboard_type: apprt.Clipboard, - confirm: bool, -) !void { - if (!confirm) { - const clipboard = getClipboard(self.gl_area.as(gtk.Widget), clipboard_type) orelse return; - clipboard.setText(val); - - // We only toast if we are copying to the standard clipboard. - if (clipboard_type == .standard and - self.app.config.@"app-notifications".@"clipboard-copy") - toast: { - const window = self.container.window() orelse break :toast; - - if (val.len > 0) - window.sendToast(i18n._("Copied to clipboard")) - else - window.sendToast(i18n._("Cleared clipboard")); - } - return; - } - - ClipboardConfirmationWindow.create( - self.app, - val, - &self.core_surface, - .{ .osc_52_write = clipboard_type }, - self.is_secure_input, - ) catch |window_err| { - log.err("failed to create clipboard confirmation window err={}", .{window_err}); - }; -} - -const ClipboardRequest = struct { - self: *Surface, - state: apprt.ClipboardRequest, -}; - -fn gtkClipboardRead( - source: ?*gobject.Object, - res: *gio.AsyncResult, - ud: ?*anyopaque, -) callconv(.c) void { - const clipboard = gobject.ext.cast(gdk.Clipboard, source orelse return) orelse return; - const req: *ClipboardRequest = @ptrCast(@alignCast(ud orelse return)); - const self = req.self; - const alloc = self.app.core_app.alloc; - defer alloc.destroy(req); - - var gerr: ?*glib.Error = null; - const cstr_ = clipboard.readTextFinish(res, &gerr); - if (gerr) |err| { - defer err.free(); - log.warn("failed to read clipboard err={s}", .{err.f_message orelse "(no message)"}); - return; - } - const cstr = cstr_ orelse return; - defer glib.free(cstr); - const str = std.mem.sliceTo(cstr, 0); - - self.core_surface.completeClipboardRequest( - req.state, - str, - false, - ) catch |err| switch (err) { - error.UnsafePaste, - error.UnauthorizedPaste, - => { - // Create a dialog and ask the user if they want to paste anyway. - ClipboardConfirmationWindow.create( - self.app, - str, - &self.core_surface, - req.state, - self.is_secure_input, - ) catch |window_err| { - log.err("failed to create clipboard confirmation window err={}", .{window_err}); - }; - return; - }, - - else => log.err("failed to complete clipboard request err={}", .{err}), - }; -} - -fn getClipboard(widget: *gtk.Widget, clipboard: apprt.Clipboard) ?*gdk.Clipboard { - return switch (clipboard) { - .standard => widget.getClipboard(), - .selection, .primary => widget.getPrimaryClipboard(), - }; -} - -pub fn getCursorPos(self: *const Surface) !apprt.CursorPos { - return self.cursor_pos; -} - -pub fn showDesktopNotification( - self: *Surface, - title: []const u8, - body: []const u8, -) !void { - // Set a default title if we don't already have one - const t = switch (title.len) { - 0 => "Ghostty", - else => title, - }; - - const notification = gio.Notification.new(t); - defer notification.unref(); - notification.setBody(body); - - const icon = gio.ThemedIcon.new(build_config.bundle_id); - defer icon.unref(); - - notification.setIcon(icon); - - const pointer = glib.Variant.newUint64(@intFromPtr(&self.core_surface)); - notification.setDefaultActionAndTargetValue("app.present-surface", pointer); - - const app = self.app.app.as(gio.Application); - - // We set the notification ID to the body content. If the content is the - // same, this notification may replace a previous notification - app.sendNotification(body.ptr, notification); -} - -fn gtkRealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.c) void { - log.debug("gl surface realized", .{}); - - // We need to make the context current so we can call GL functions. - gl_area.makeCurrent(); - if (gl_area.getError()) |err| { - log.err("surface failed to realize: {s}", .{err.f_message orelse "(no message)"}); - log.warn("this error is usually due to a driver or gtk bug", .{}); - log.warn("this is a common cause of this issue: https://gitlab.gnome.org/GNOME/gtk/-/issues/4950", .{}); - return; - } - - // realize means that our OpenGL context is ready, so we can now - // initialize the core surface which will setup the renderer. - self.realize() catch |err| { - // TODO: we need to destroy the GL area here. - log.err("surface failed to realize: {}", .{err}); - return; - }; - - // When we have a realized surface, we also attach our input method context. - // We do this here instead of init because this allows us to release the ref - // to the GLArea when we unrealized. - self.im_context.as(gtk.IMContext).setClientWidget(self.overlay.as(gtk.Widget)); -} - -/// This is called when the underlying OpenGL resources must be released. -/// This is usually due to the OpenGL area changing GDK surfaces. -fn gtkUnrealize(gl_area: *gtk.GLArea, self: *Surface) callconv(.c) void { - log.debug("gl surface unrealized", .{}); - - // See gtkRealize for why we do this here. - self.im_context.as(gtk.IMContext).setClientWidget(null); - - // There is no guarantee that our GLArea context is current - // when unrealize is emitted, so we need to make it current. - gl_area.makeCurrent(); - if (gl_area.getError()) |err| { - // I don't know a scenario this can happen, but it means - // we probably leaked memory because displayUnrealized - // below frees resources that aren't specifically OpenGL - // related. I didn't make the OpenGL renderer handle this - // scenario because I don't know if its even possible - // under valid circumstances, so let's log. - log.warn( - "gl_area_make_current failed in unrealize msg={s}", - .{err.f_message orelse "(no message)"}, - ); - log.warn("OpenGL resources and memory likely leaked", .{}); - return; - } else { - self.core_surface.renderer.displayUnrealized(); - } -} - -/// render signal -fn gtkRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Surface) callconv(.c) c_int { - self.render() catch |err| { - log.err("surface failed to render: {}", .{err}); - return 0; - }; - - return 1; -} - -/// resize signal -fn gtkResize(gl_area: *gtk.GLArea, width: c_int, height: c_int, self: *Surface) callconv(.c) void { - // Some debug output to help understand what GTK is telling us. - { - const scale_factor = scale: { - const widget = gl_area.as(gtk.Widget); - break :scale widget.getScaleFactor(); - }; - - const window_scale_factor = scale: { - const window = self.container.window() orelse break :scale 0; - const gtk_window = window.window.as(gtk.Window); - const gtk_native = gtk_window.as(gtk.Native); - const gdk_surface = gtk_native.getSurface() orelse break :scale 0; - break :scale gdk_surface.getScaleFactor(); - }; - - log.debug("gl resize width={} height={} scale={} window_scale={}", .{ - width, - height, - scale_factor, - window_scale_factor, - }); - } - - self.size = .{ - .width = @intCast(width), - .height = @intCast(height), - }; - - // We also update the content scale because there is no signal for - // content scale change and it seems to trigger a resize event. - if (self.getContentScale()) |scale| { - self.core_surface.contentScaleCallback(scale) catch |err| { - log.err("error in content scale callback err={}", .{err}); - return; - }; - } else |_| {} - - // Call the primary callback. - if (self.realized) { - self.core_surface.sizeCallback(self.size) catch |err| { - log.err("error in size callback err={}", .{err}); - return; - }; - - if (self.container.window()) |window| { - window.winproto.resizeEvent() catch |err| { - log.warn("failed to notify window protocol of resize={}", .{err}); - }; - } - - self.resize_overlay.maybeShow(); - } -} - -/// "destroy" signal for surface -fn gtkDestroy(_: *gtk.GLArea, self: *Surface) callconv(.c) void { - log.debug("gl destroy", .{}); - - const alloc = self.app.core_app.alloc; - self.deinit(); - alloc.destroy(self); -} - -/// Scale x/y by the GDK device scale. -fn scaledCoordinates( - self: *const Surface, - x: f64, - y: f64, -) struct { - x: f64, - y: f64, -} { - const gl_are_widget = self.gl_area.as(gtk.Widget); - const scale_factor: f64 = @floatFromInt( - gl_are_widget.getScaleFactor(), - ); - - return .{ - .x = x * scale_factor, - .y = y * scale_factor, - }; -} - -fn gtkMouseDown( - gesture: *gtk.GestureClick, - _: c_int, - x: f64, - y: f64, - self: *Surface, -) callconv(.c) void { - const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; - - const gtk_mods = event.getModifierState(); - - const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); - const mods = gtk_key.translateMods(gtk_mods); - - // If we don't have focus, grab it. - const gl_area_widget = self.gl_area.as(gtk.Widget); - if (gl_area_widget.hasFocus() == 0) { - self.grabFocus(); - } - - const consumed = self.core_surface.mouseButtonCallback(.press, button, mods) catch |err| { - log.err("error in key callback err={}", .{err}); - return; - }; - - // If a right click isn't consumed, mouseButtonCallback selects the hovered - // word and returns false. We can use this to handle the context menu - // opening under normal scenarios. - if (!consumed and button == .right) { - self.context_menu.popupAt(@intFromFloat(x), @intFromFloat(y)); - } -} - -fn gtkMouseUp( - gesture: *gtk.GestureClick, - _: c_int, - _: f64, - _: f64, - self: *Surface, -) callconv(.c) void { - const event = gesture.as(gtk.EventController).getCurrentEvent() orelse return; - - const gtk_mods = event.getModifierState(); - - const button = translateMouseButton(gesture.as(gtk.GestureSingle).getCurrentButton()); - const mods = gtk_key.translateMods(gtk_mods); - - _ = self.core_surface.mouseButtonCallback(.release, button, mods) catch |err| { - log.err("error in key callback err={}", .{err}); - return; - }; -} - -fn gtkMouseMotion( - ec: *gtk.EventControllerMotion, - x: f64, - y: f64, - self: *Surface, -) callconv(.c) void { - const event = ec.as(gtk.EventController).getCurrentEvent() orelse return; - - const scaled = self.scaledCoordinates(x, y); - - const pos: apprt.CursorPos = .{ - .x = @floatCast(scaled.x), - .y = @floatCast(scaled.y), - }; - - // There seem to be at least two cases where GTK issues a mouse motion - // event without the cursor actually moving: - // 1. GLArea is resized under the mouse. This has the unfortunate - // side effect of causing focus to potentially change when - // `focus-follows-mouse` is enabled. - // 2. The window title is updated. This can cause the mouse to unhide - // incorrectly when hide-mouse-when-typing is enabled. - // To prevent incorrect behavior, we'll only grab focus and - // continue with callback logic if the cursor has actually moved. - const is_cursor_still = @abs(self.cursor_pos.x - pos.x) < 1 and - @abs(self.cursor_pos.y - pos.y) < 1; - - if (!is_cursor_still) { - // If we don't have focus, and we want it, grab it. - const gl_area_widget = self.gl_area.as(gtk.Widget); - if (gl_area_widget.hasFocus() == 0 and self.app.config.@"focus-follows-mouse") { - self.grabFocus(); - } - - // Our pos changed, update - self.cursor_pos = pos; - - // Get our modifiers - const gtk_mods = event.getModifierState(); - const mods = gtk_key.translateMods(gtk_mods); - - self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| { - log.err("error in cursor pos callback err={}", .{err}); - return; - }; - } -} - -fn gtkMouseLeave( - ec_motion: *gtk.EventControllerMotion, - self: *Surface, -) callconv(.c) void { - const event = ec_motion.as(gtk.EventController).getCurrentEvent() orelse return; - - // Get our modifiers - const gtk_mods = event.getModifierState(); - const mods = gtk_key.translateMods(gtk_mods); - self.core_surface.cursorPosCallback(.{ .x = -1, .y = -1 }, mods) catch |err| { - log.err("error in cursor pos callback err={}", .{err}); - return; - }; -} - -fn gtkMouseScrollPrecisionBegin( - _: *gtk.EventControllerScroll, - self: *Surface, -) callconv(.c) void { - self.precision_scroll = true; -} - -fn gtkMouseScrollPrecisionEnd( - _: *gtk.EventControllerScroll, - self: *Surface, -) callconv(.c) void { - self.precision_scroll = false; -} - -fn gtkMouseScroll( - _: *gtk.EventControllerScroll, - x: f64, - y: f64, - self: *Surface, -) callconv(.c) c_int { - const scaled = self.scaledCoordinates(x, y); - - // GTK doesn't support any of the scroll mods. - const scroll_mods: input.ScrollMods = .{ .precision = self.precision_scroll }; - // Multiply precision scrolls by 10 to get a better response from touchpad scrolling - const multiplier: f64 = if (self.precision_scroll) 10.0 else 1.0; - - self.core_surface.scrollCallback( - // We invert because we apply natural scrolling to the values. - // This behavior has existed for years without Linux users complaining - // but I suspect we'll have to make this configurable in the future - // or read a system setting. - scaled.x * -1 * multiplier, - scaled.y * -1 * multiplier, - scroll_mods, - ) catch |err| { - log.err("error in scroll callback err={}", .{err}); - return 0; - }; - - return 1; -} - -fn gtkKeyPressed( - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, - self: *Surface, -) callconv(.c) c_int { - return @intFromBool(self.keyEvent( - .press, - ec_key, - keyval, - keycode, - gtk_mods, - )); -} - -fn gtkKeyReleased( - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - state: gdk.ModifierType, - self: *Surface, -) callconv(.c) void { - _ = self.keyEvent( - .release, - ec_key, - keyval, - keycode, - state, - ); -} - -/// Key press event (press or release). -/// -/// At a high level, we want to construct an `input.KeyEvent` and -/// pass that to `keyCallback`. At a low level, this is more complicated -/// than it appears because we need to construct all of this information -/// and its not given to us. -/// -/// For all events, we run the GdkEvent through the input method context. -/// This allows the input method to capture the event and trigger -/// callbacks such as preedit, commit, etc. -/// -/// There are a couple important aspects to the prior paragraph: we must -/// send ALL events through the input method context. This is because -/// input methods use both key press and key release events to determine -/// the state of the input method. For example, fcitx uses key release -/// events on modifiers (i.e. ctrl+shift) to switch the input method. -/// -/// We set some state to note we're in a key event (self.in_keyevent) -/// because some of the input method callbacks change behavior based on -/// this state. For example, we don't want to send character events -/// like "a" via the input "commit" event if we're actively processing -/// a keypress because we'd lose access to the keycode information. -/// However, a "commit" event may still happen outside of a keypress -/// event from e.g. a tablet or on-screen keyboard. -/// -/// Finally, we take all of the information in order to determine if we have -/// a unicode character or if we have to map the keyval to a code to -/// get the underlying logical key, etc. -/// -/// Then we can emit the keyCallback. -pub fn keyEvent( - self: *Surface, - action: input.Action, - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, -) bool { - // log.warn("GTKIM: keyEvent action={}", .{action}); - const event = ec_key.as(gtk.EventController).getCurrentEvent() orelse return false; - const key_event = gobject.ext.cast(gdk.KeyEvent, event) orelse return false; - - // The block below is all related to input method handling. See the function - // comment for some high level details and then the comments within - // the block for more specifics. - { - // This can trigger an input method so we need to notify the im context - // where the cursor is so it can render the dropdowns in the correct - // place. - const ime_point = self.core_surface.imePoint(); - self.im_context.as(gtk.IMContext).setCursorLocation(&.{ - .f_x = @intFromFloat(ime_point.x), - .f_y = @intFromFloat(ime_point.y), - .f_width = 1, - .f_height = 1, - }); - - // We note that we're in a keypress because we want some logic to - // depend on this. For example, we don't want to send character events - // like "a" via the input "commit" event if we're actively processing - // a keypress because we'd lose access to the keycode information. - // - // We have to maintain some additional state here of whether we - // were composing because different input methods call the callbacks - // in different orders. For example, ibus calls commit THEN preedit - // end but simple calls preedit end THEN commit. - self.in_keyevent = if (self.im_composing) .composing else .not_composing; - defer self.in_keyevent = .false; - - // Pass the event through the input method which returns true if handled. - // Confusingly, not all events handled by the input method result - // in this returning true so we have to maintain some additional - // state about whether we were composing or not to determine if - // we should proceed with key encoding. - // - // Cases where the input method does not mark the event as handled: - // - // - If we change the input method via keypress while we have preedit - // text, the input method will commit the pending text but will not - // mark it as handled. We use the `.composing` state to detect - // this case. - // - // - If we switch input methods (i.e. via ctrl+shift with fcitx), - // the input method will handle the key release event but will not - // mark it as handled. I don't know any way to detect this case so - // it will result in a key event being sent to the key callback. - // For Kitty text encoding, this will result in modifiers being - // triggered despite being technically consumed. At the time of - // writing, both Kitty and Alacritty have the same behavior. I - // know of no way to fix this. - const im_handled = self.im_context.as(gtk.IMContext).filterKeypress(event) != 0; - // log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{ - // im_handled, - // self.im_len, - // self.im_composing, - // }); - - // If the input method handled the event, you would think we would - // never proceed with key encoding for Ghostty but that is not the - // case. Input methods will handle basic character encoding like - // typing "a" and we want to associate that with the key event. - // So we have to check additional state to determine if we exit. - if (im_handled) { - // If we are composing then we're in a preedit state and do - // not want to encode any keys. For example: type a deadkey - // such as single quote on a US international keyboard layout. - if (self.im_composing) return true; - - // If we were composing and now we're not it means that we committed - // the text. We also don't want to encode a key event for this. - // Example: enable Japanese input method, press "konn" and then - // press enter. The final enter should not be encoded and "konn" - // (in hiragana) should be written as "こん". - if (self.in_keyevent == .composing) return true; - - // Not composing and our input method buffer is empty. This could - // mean that the input method reacted to this event by activating - // an onscreen keyboard or something equivalent. We don't know. - // But the input method handled it and didn't give us text so - // we will just assume we should not encode this. This handles a - // real scenario when ibus starts the emoji input method - // (super+.). - if (self.im_len == 0) return true; - } - - // At this point, for the sake of explanation of internal state: - // it is possible that im_len > 0 and im_composing == false. This - // means that we received a commit event from the input method that - // we want associated with the key event. This is common: its how - // basic character translation for simple inputs like "a" work. - } - - // We always reset the length of the im buffer. There's only one scenario - // we reach this point with im_len > 0 and that's if we received a commit - // event from the input method. We don't want to keep that state around - // since we've handled it here. - defer self.im_len = 0; - - // Get the keyvals for this event. - const keyval_unicode = gdk.keyvalToUnicode(keyval); - const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted( - self.gl_area.as(gtk.Widget), - key_event, - keycode, - ); - - // We want to get the physical unmapped key to process physical keybinds. - // (These are keybinds explicitly marked as requesting physical mapping). - const physical_key = keycode: for (input.keycodes.entries) |entry| { - if (entry.native == keycode) break :keycode entry.key; - } else .unidentified; - - // Get our modifier for the event - const mods: input.Mods = gtk_key.eventMods( - event, - physical_key, - gtk_mods, - action, - &self.app.winproto, - ); - - // Get our consumed modifiers - const consumed_mods: input.Mods = consumed: { - const T = @typeInfo(gdk.ModifierType); - std.debug.assert(T.@"struct".layout == .@"packed"); - const I = T.@"struct".backing_integer.?; - - const masked = @as(I, @bitCast(key_event.getConsumedModifiers())) & @as(I, gdk.MODIFIER_MASK); - break :consumed gtk_key.translateMods(@bitCast(masked)); - }; - - // log.debug("key pressed key={} keyval={x} physical_key={} composing={} text_len={} mods={}", .{ - // key, - // keyval, - // physical_key, - // self.im_composing, - // self.im_len, - // mods, - // }); - - // If we have no UTF-8 text, we try to convert our keyval to - // a text value. We have to do this because GTK will not process - // "Ctrl+Shift+1" (on US keyboards) as "Ctrl+!" but instead as "". - // But the keyval is set correctly so we can at least extract that. - if (self.im_len == 0 and keyval_unicode > 0) im: { - if (std.math.cast(u21, keyval_unicode)) |cp| { - // We don't want to send control characters as IM - // text. Control characters are handled already by - // the encoder directly. - if (cp < 0x20) break :im; - - if (std.unicode.utf8Encode(cp, &self.im_buf)) |len| { - self.im_len = len; - } else |_| {} - } - } - - // Invoke the core Ghostty logic to handle this input. - const effect = self.core_surface.keyCallback(.{ - .action = action, - .key = physical_key, - .mods = mods, - .consumed_mods = consumed_mods, - .composing = self.im_composing, - .utf8 = self.im_buf[0..self.im_len], - .unshifted_codepoint = keyval_unicode_unshifted, - }) catch |err| { - log.err("error in key callback err={}", .{err}); - return false; - }; - - switch (effect) { - .closed => return true, - .ignored => {}, - .consumed => if (action == .press or action == .repeat) { - // If we were in the composing state then we reset our context. - // We do NOT want to reset if we're not in the composing state - // because there is other IME state that we want to preserve, - // such as quotation mark ordering for Chinese input. - if (self.im_composing) { - self.im_context.as(gtk.IMContext).reset(); - self.core_surface.preeditCallback(null) catch {}; - } - - return true; - }, - } - - return false; -} - -fn gtkInputPreeditStart( - _: *gtk.IMMulticontext, - self: *Surface, -) callconv(.c) void { - // log.warn("GTKIM: preedit start", .{}); - - // Start our composing state for the input method and reset our - // input buffer to empty. - self.im_composing = true; - self.im_len = 0; -} - -fn gtkInputPreeditChanged( - ctx: *gtk.IMMulticontext, - self: *Surface, -) callconv(.c) void { - // Any preedit change should mark that we're composing. Its possible this - // is false using fcitx5-hangul and typing "dkssud" ("안녕"). The - // second "s" results in a "commit" for "안" which sets composing to false, - // but then immediately sends a preedit change for the next symbol. With - // composing set to false we won't commit this text. Therefore, we must - // ensure it is set here. - self.im_composing = true; - - // Get our pre-edit string that we'll use to show the user. - var buf: [*:0]u8 = undefined; - ctx.as(gtk.IMContext).getPreeditString(&buf, null, null); - defer glib.free(buf); - - const str = std.mem.sliceTo(buf, 0); - - // Update our preedit state in Ghostty core - // log.warn("GTKIM: preedit change str={s}", .{str}); - self.core_surface.preeditCallback(str) catch |err| { - log.err("error in preedit callback err={}", .{err}); - }; -} - -fn gtkInputPreeditEnd( - _: *gtk.IMMulticontext, - self: *Surface, -) callconv(.c) void { - // log.warn("GTKIM: preedit end", .{}); - - // End our composing state for GTK, allowing us to commit the text. - self.im_composing = false; - - // End our preedit state in Ghostty core - self.core_surface.preeditCallback(null) catch |err| { - log.err("error in preedit callback err={}", .{err}); - }; -} - -fn gtkInputCommit( - _: *gtk.IMMulticontext, - bytes: [*:0]u8, - self: *Surface, -) callconv(.c) void { - const str = std.mem.sliceTo(bytes, 0); - - // log.debug("GTKIM: input commit composing={} keyevent={} str={s}", .{ - // self.im_composing, - // self.in_keyevent, - // str, - // }); - - // We need to handle commit specially if we're in a key event. - // Specifically, GTK will send us a commit event for basic key - // encodings like "a" (on a US layout keyboard). We don't want - // to treat this as IME committed text because we want to associate - // it with a key event (i.e. "a" key press). - switch (self.in_keyevent) { - // If we're not in a key event then this commit is from - // some other source (i.e. on-screen keyboard, tablet, etc.) - // and we want to commit the text to the core surface. - .false => {}, - - // If we're in a composing state and in a key event then this - // key event is resulting in a commit of multiple keypresses - // and we don't want to encode it alongside the keypress. - .composing => {}, - - // If we're not composing then this commit is just a normal - // key encoding and we want our key event to handle it so - // that Ghostty can be aware of the key event alongside - // the text. - .not_composing => { - if (str.len > self.im_buf.len) { - log.warn("not enough buffer space for input method commit", .{}); - return; - } - - // Copy our committed text to the buffer - @memcpy(self.im_buf[0..str.len], str); - self.im_len = @intCast(str.len); - - // log.debug("input commit len={}", .{self.im_len}); - return; - }, - } - - // If we reach this point from above it means we're composing OR - // not in a keypress. In either case, we want to commit the text - // given to us because that's what GTK is asking us to do. If we're - // not in a keypress it means that this commit came via a non-keyboard - // event (i.e. on-screen keyboard, tablet of some kind, etc.). - - // Committing ends composing state - self.im_composing = false; - - // End our preedit state. Well-behaved input methods do this for us - // by triggering a preedit-end event but some do not (ibus 1.5.29). - self.core_surface.preeditCallback(null) catch |err| { - log.err("error in preedit callback err={}", .{err}); - }; - - // Send the text to the core surface, associated with no key (an - // invalid key, which should produce no PTY encoding). - _ = self.core_surface.keyCallback(.{ - .action = .press, - .key = .unidentified, - .mods = .{}, - .consumed_mods = .{}, - .composing = false, - .utf8 = str, - }) catch |err| { - log.warn("error in key callback err={}", .{err}); - return; - }; -} - -fn gtkFocusEnter(_: *gtk.EventControllerFocus, self: *Surface) callconv(.c) void { - if (!self.realized) return; - - // Notify our IM context - self.im_context.as(gtk.IMContext).focusIn(); - - // Remove the unfocused widget overlay, if we have one - if (self.unfocused_widget) |widget| { - self.overlay.removeOverlay(widget); - self.unfocused_widget = null; - } - - if (self.pwd) |pwd| { - if (self.container.window()) |window| { - if (self.app.config.@"window-subtitle" == .@"working-directory") window.setSubtitle(pwd); - } - } - - // Notify our surface - self.core_surface.focusCallback(true) catch |err| { - log.err("error in focus callback err={}", .{err}); - return; - }; -} - -fn gtkFocusLeave(_: *gtk.EventControllerFocus, self: *Surface) callconv(.c) void { - if (!self.realized) return; - - // Notify our IM context - self.im_context.as(gtk.IMContext).focusOut(); - - // We only try dimming the surface if we are a split - switch (self.container) { - .split_br, - .split_tl, - => self.dimSurface(), - else => {}, - } - - self.core_surface.focusCallback(false) catch |err| { - log.err("error in focus callback err={}", .{err}); - return; - }; -} - -/// Adds the unfocused_widget to the overlay. If the unfocused_widget has -/// already been added, this is a no-op. -pub fn dimSurface(self: *Surface) void { - _ = self.container.window() orelse { - log.warn("dimSurface invalid for container={}", .{self.container}); - return; - }; - - // Don't dim surface if context menu is open. - // This means we got unfocused due to it opening. - if (self.context_menu.isVisible()) return; - - // If there's already an unfocused_widget do nothing; - if (self.unfocused_widget) |_| return; - - self.unfocused_widget = unfocused_widget: { - const drawing_area = gtk.DrawingArea.new(); - const unfocused_widget = drawing_area.as(gtk.Widget); - unfocused_widget.addCssClass("unfocused-split"); - self.overlay.addOverlay(unfocused_widget); - break :unfocused_widget unfocused_widget; - }; -} - -fn translateMouseButton(button: c_uint) input.MouseButton { - return switch (button) { - 1 => .left, - 2 => .middle, - 3 => .right, - 4 => .four, - 5 => .five, - 6 => .six, - 7 => .seven, - 8 => .eight, - 9 => .nine, - 10 => .ten, - 11 => .eleven, - else => .unknown, - }; -} - -pub fn present(self: *Surface) void { - if (self.container.window()) |window| { - if (self.container.tab()) |tab| { - if (window.notebook.getTabPosition(tab)) |position| - _ = window.notebook.gotoNthTab(position); - } - window.window.as(gtk.Window).present(); - } - - self.grabFocus(); -} - -fn detachFromSplit(self: *Surface) void { - const split = self.container.split() orelse return; - switch (self.container.splitSide() orelse unreachable) { - .top_left => split.detachTopLeft(), - .bottom_right => split.detachBottomRight(), - } -} - -fn attachToSplit(self: *Surface) void { - const split = self.container.split() orelse return; - split.updateChildren(); -} - -pub fn setSplitZoom(self: *Surface, new_split_zoom: bool) void { - if (new_split_zoom == self.zoomed_in) return; - const tab = self.container.tab() orelse return; - - const tab_widget = tab.elem.widget(); - const surface_widget = self.primaryWidget(); - - if (new_split_zoom) { - self.detachFromSplit(); - tab.box.remove(tab_widget); - tab.box.append(surface_widget); - } else { - tab.box.remove(surface_widget); - self.attachToSplit(); - tab.box.append(tab_widget); - } - - self.zoomed_in = new_split_zoom; - self.grabFocus(); -} - -pub fn toggleSplitZoom(self: *Surface) void { - self.setSplitZoom(!self.zoomed_in); -} - -/// Handle items being dropped on our surface. -fn gtkDrop( - _: *gtk.DropTarget, - value: *gobject.Value, - _: f64, - _: f64, - self: *Surface, -) callconv(.c) c_int { - const alloc = self.app.core_app.alloc; - - if (g_value_holds(value, gdk.FileList.getGObjectType())) { - var data = std.ArrayList(u8).init(alloc); - defer data.deinit(); - - var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{ - .child_writer = data.writer(), - }; - const writer = shell_escape_writer.writer(); - - const list: ?*glib.SList = list: { - const unboxed = value.getBoxed() orelse return 0; - const fl: *gdk.FileList = @ptrCast(@alignCast(unboxed)); - break :list fl.getFiles(); - }; - defer if (list) |v| v.free(); - - { - var current: ?*glib.SList = list; - while (current) |item| : (current = item.f_next) { - const file: *gio.File = @ptrCast(@alignCast(item.f_data orelse continue)); - const path = file.getPath() orelse continue; - const slice = std.mem.span(path); - defer glib.free(path); - - writer.writeAll(slice) catch |err| { - log.err("unable to write path to buffer: {}", .{err}); - continue; - }; - writer.writeAll("\n") catch |err| { - log.err("unable to write to buffer: {}", .{err}); - continue; - }; - } - } - - const string = data.toOwnedSliceSentinel(0) catch |err| { - log.err("unable to convert to a slice: {}", .{err}); - return 0; - }; - defer alloc.free(string); - - self.doPaste(string); - - return 1; - } - - if (g_value_holds(value, gio.File.getGObjectType())) { - const object = value.getObject() orelse return 0; - const file = gobject.ext.cast(gio.File, object) orelse return 0; - const path = file.getPath() orelse return 0; - var data = std.ArrayList(u8).init(alloc); - defer data.deinit(); - - var shell_escape_writer: internal_os.ShellEscapeWriter(std.ArrayList(u8).Writer) = .{ - .child_writer = data.writer(), - }; - const writer = shell_escape_writer.writer(); - writer.writeAll(std.mem.span(path)) catch |err| { - log.err("unable to write path to buffer: {}", .{err}); - return 0; - }; - writer.writeAll("\n") catch |err| { - log.err("unable to write to buffer: {}", .{err}); - return 0; - }; - - const string = data.toOwnedSliceSentinel(0) catch |err| { - log.err("unable to convert to a slice: {}", .{err}); - return 0; - }; - defer alloc.free(string); - - self.doPaste(string); - - return 1; - } - - if (g_value_holds(value, gobject.ext.types.string)) { - if (value.getString()) |string| { - const text = std.mem.span(string); - if (text.len > 0) self.doPaste(text); - } - return 1; - } - - return 1; -} - -fn doPaste(self: *Surface, data: [:0]const u8) void { - if (data.len == 0) return; - - self.core_surface.completeClipboardRequest(.paste, data, false) catch |err| switch (err) { - error.UnsafePaste, - error.UnauthorizedPaste, - => { - ClipboardConfirmationWindow.create( - self.app, - data, - &self.core_surface, - .paste, - self.is_secure_input, - ) catch |window_err| { - log.err("failed to create clipboard confirmation window err={}", .{window_err}); - }; - }, - error.OutOfMemory, - error.NoSpaceLeft, - => log.err("failed to complete clipboard request err={}", .{err}), - }; -} - -pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap { - const alloc = self.app.core_app.alloc; - var env = try internal_os.getEnvMap(alloc); - errdefer env.deinit(); - - // Don't leak these GTK environment variables to child processes. - env.remove("GDK_DEBUG"); - env.remove("GDK_DISABLE"); - env.remove("GSK_RENDERER"); - - // Remove some environment variables that are set when Ghostty is launched - // from a `.desktop` file, by D-Bus activation, or systemd. - env.remove("GIO_LAUNCHED_DESKTOP_FILE"); - env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); - env.remove("DBUS_STARTER_ADDRESS"); - env.remove("DBUS_STARTER_BUS_TYPE"); - env.remove("INVOCATION_ID"); - env.remove("JOURNAL_STREAM"); - env.remove("NOTIFY_SOCKET"); - - // Unset environment varies set by snaps if we're running in a snap. - // This allows Ghostty to further launch additional snaps. - if (env.get("SNAP")) |_| { - env.remove("SNAP"); - env.remove("DRIRC_CONFIGDIR"); - env.remove("__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS"); - env.remove("__EGL_VENDOR_LIBRARY_DIRS"); - env.remove("LD_LIBRARY_PATH"); - env.remove("LIBGL_DRIVERS_PATH"); - env.remove("LIBVA_DRIVERS_PATH"); - env.remove("VK_LAYER_PATH"); - env.remove("XLOCALEDIR"); - env.remove("GDK_PIXBUF_MODULEDIR"); - env.remove("GDK_PIXBUF_MODULE_FILE"); - env.remove("GTK_PATH"); - } - - if (self.container.window()) |window| { - // On some window protocols we might want to add specific - // environment variables to subprocesses, such as WINDOWID on X11. - try window.winproto.addSubprocessEnv(&env); - } - - return env; -} - -/// Check a GValue to see what's type its wrapping. This is equivalent to GTK's -/// `G_VALUE_HOLDS` macro but Zig's C translator does not like it. -fn g_value_holds(value_: ?*gobject.Value, g_type: gobject.Type) bool { - if (value_) |value| { - if (value.f_g_type == g_type) return true; - return gobject.typeCheckValueHolds(value, g_type) != 0; - } - return false; -} - -fn gtkPromptTitleResponse(source_object: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.c) void { - if (!adw_version.supportsDialogs()) return; - const dialog = gobject.ext.cast(adw.AlertDialog, source_object.?).?; - const self: *Surface = @ptrCast(@alignCast(ud)); - - const response = dialog.chooseFinish(result); - if (std.mem.orderZ(u8, "ok", response) == .eq) { - const title_entry = gobject.ext.cast(gtk.Entry, dialog.getExtraChild().?).?; - const title = std.mem.span(title_entry.getBuffer().getText()); - - // if the new title is empty and the user has set the title previously, restore the terminal provided title - if (title.len == 0) { - if (self.getTerminalTitle()) |terminal_title| { - self.setTitle(terminal_title, .user) catch |err| { - log.err("failed to set title={}", .{err}); - }; - self.app.core_app.alloc.free(self.title_from_terminal.?); - self.title_from_terminal = null; - } - } else if (title.len > 0) { - // if this is the first time the user is setting the title, save the current terminal provided title - if (self.title_from_terminal == null and self.title_text != null) { - self.title_from_terminal = self.app.core_app.alloc.dupeZ(u8, self.title_text.?) catch |err| switch (err) { - error.OutOfMemory => { - log.err("failed to allocate memory for title={}", .{err}); - return; - }, - }; - } - - self.setTitle(title, .user) catch |err| { - log.err("failed to set title={}", .{err}); - }; - } - } -} - -pub fn setSecureInput(self: *Surface, value: apprt.action.SecureInput) void { - switch (value) { - .on => self.is_secure_input = true, - .off => self.is_secure_input = false, - .toggle => self.is_secure_input = !self.is_secure_input, - } -} - -pub fn ringBell(self: *Surface) !void { - const features = self.app.config.@"bell-features"; - const window = self.container.window() orelse { - log.warn("failed to ring bell: surface is not attached to any window", .{}); - return; - }; - - // System beep - if (features.system) system: { - const surface = window.window.as(gtk.Native).getSurface() orelse break :system; - surface.beep(); - } - - if (features.audio) audio: { - // Play a user-specified audio file. - - const pathname, const required = switch (self.app.config.@"bell-audio-path" orelse break :audio) { - .optional => |path| .{ path, false }, - .required => |path| .{ path, true }, - }; - - const volume = std.math.clamp(self.app.config.@"bell-audio-volume", 0.0, 1.0); - - std.debug.assert(std.fs.path.isAbsolute(pathname)); - const media_file = gtk.MediaFile.newForFilename(pathname); - - if (required) { - _ = gobject.Object.signals.notify.connect( - media_file, - ?*anyopaque, - gtkStreamError, - null, - .{ .detail = "error" }, - ); - } - _ = gobject.Object.signals.notify.connect( - media_file, - ?*anyopaque, - gtkStreamEnded, - null, - .{ .detail = "ended" }, - ); - - const media_stream = media_file.as(gtk.MediaStream); - media_stream.setVolume(volume); - media_stream.play(); - } - - if (features.attention) { - // Request user attention - window.winproto.setUrgent(true) catch |err| { - log.err("failed to request user attention={}", .{err}); - }; - } - - // Mark tab as needing attention - if (self.container.tab()) |tab| tab: { - const page = window.notebook.getTabPage(tab) orelse break :tab; - - // Need attention if we're not the currently selected tab - if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true)); - } -} - -/// Handle a stream that is in an error state. -fn gtkStreamError(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { - const path = path: { - const file = media_file.getFile() orelse break :path null; - break :path file.getPath(); - }; - defer if (path) |p| glib.free(p); - - const media_stream = media_file.as(gtk.MediaStream); - const err = media_stream.getError() orelse return; - - log.warn("error playing bell from {s}: {s} {d} {s}", .{ - path orelse "<>", - glib.quarkToString(err.f_domain), - err.f_code, - err.f_message orelse "", - }); -} - -/// Stream is finished, release the memory. -fn gtkStreamEnded(media_file: *gtk.MediaFile, _: *gobject.ParamSpec, _: ?*anyopaque) callconv(.c) void { - media_file.unref(); -} - -/// Show native GUI element with a notification that the child process has -/// closed. Return `true` if we are able to show the GUI notification, and -/// `false` if we are not. -pub fn showChildExited(self: *Surface, info: apprt.surface.Message.ChildExited) error{}!bool { - if (!adw_version.supportsBanner()) return false; - - const warning_text, const css_class = if (info.exit_code == 0) - .{ i18n._("Command succeeded"), "child_exited_normally" } - else - .{ i18n._("Command failed"), "child_exited_abnormally" }; - - const banner = adw.Banner.new(warning_text); - banner.setRevealed(1); - banner.setButtonLabel(i18n._("Close")); - - _ = adw.Banner.signals.button_clicked.connect( - banner, - *Surface, - showChildExitedButtonClosed, - self, - .{}, - ); - - const banner_widget = banner.as(gtk.Widget); - banner_widget.setHalign(.fill); - banner_widget.setValign(.end); - banner_widget.addCssClass(css_class); - - self.overlay.addOverlay(banner_widget); - - return true; -} - -fn showChildExitedButtonClosed(_: *adw.Banner, self: *Surface) callconv(.c) void { - self.close(false); -} diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig deleted file mode 100644 index c32fa19fc..000000000 --- a/src/apprt/gtk/Tab.zig +++ /dev/null @@ -1,171 +0,0 @@ -//! The state associated with a single tab in the window. -//! -//! A tab can contain one or more terminals due to splits. -const Tab = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -const gobject = @import("gobject"); -const gtk = @import("gtk"); - -const font = @import("../../font/main.zig"); -const input = @import("../../input.zig"); -const CoreSurface = @import("../../Surface.zig"); - -const Surface = @import("Surface.zig"); -const Window = @import("Window.zig"); -const CloseDialog = @import("CloseDialog.zig"); - -const log = std.log.scoped(.gtk); - -pub const GHOSTTY_TAB = "ghostty_tab"; - -/// The window that owns this tab. -window: *Window, - -/// The tab label. The tab label is the text that appears on the tab. -label_text: *gtk.Label, - -/// We'll put our children into this box instead of packing them -/// directly, so that we can send the box into `c.g_signal_connect_data` -/// for the close button -box: *gtk.Box, - -/// The element of this tab so that we can handle splits and so on. -elem: Surface.Container.Elem, - -// We'll update this every time a Surface gains focus, so that we have it -// when we switch to another Tab. Then when we switch back to this tab, we -// can easily re-focus that terminal. -focus_child: ?*Surface, - -pub fn create(alloc: Allocator, window: *Window, parent_: ?*CoreSurface) !*Tab { - var tab = try alloc.create(Tab); - errdefer alloc.destroy(tab); - try tab.init(window, parent_); - return tab; -} - -/// Initialize the tab, create a surface, and add it to the window. "self" needs -/// to be a stable pointer, since it is used for GTK events. -pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void { - self.* = .{ - .window = window, - .label_text = undefined, - .box = undefined, - .elem = undefined, - .focus_child = null, - }; - - // Create a Box in which we'll later keep either Surface or Split. Using a - // box makes it easier to maintain the tab contents because we never need to - // change the root widget of the notebook page (tab). - const box = gtk.Box.new(.vertical, 0); - errdefer box.unref(); - const box_widget = box.as(gtk.Widget); - box_widget.setHexpand(1); - box_widget.setVexpand(1); - self.box = box; - - // Create the initial surface since all tabs start as a single non-split - var surface = try Surface.create(window.app.core_app.alloc, window.app, .{ - .parent = parent_, - }); - errdefer surface.unref(); - surface.container = .{ .tab_ = self }; - self.elem = .{ .surface = surface }; - - // Add Surface to the Tab - self.box.append(surface.primaryWidget()); - - // Set the userdata of the box to point to this tab. - self.box.as(gobject.Object).setData(GHOSTTY_TAB, self); - window.notebook.addTab(self, "Ghostty"); - - // Attach all events - _ = gtk.Widget.signals.destroy.connect( - self.box, - *Tab, - gtkDestroy, - self, - .{}, - ); - - // We need to grab focus after Surface and Tab is added to the window. When - // creating a Tab we want to always focus on the widget. - surface.grabFocus(); -} - -/// Deinits tab by deiniting child elem. -pub fn deinit(self: *Tab, alloc: Allocator) void { - self.elem.deinit(alloc); -} - -/// Deinit and deallocate the tab. -pub fn destroy(self: *Tab, alloc: Allocator) void { - self.deinit(alloc); - alloc.destroy(self); -} - -// TODO: move this -/// Replace the surface element that this tab is showing. -pub fn replaceElem(self: *Tab, elem: Surface.Container.Elem) void { - // Remove our previous widget - self.box.remove(self.elem.widget()); - - // Add our new one - self.box.append(elem.widget()); - self.elem = elem; -} - -pub fn setTitleText(self: *Tab, title: [:0]const u8) void { - self.window.notebook.setTabTitle(self, title); -} - -pub fn setTooltipText(self: *Tab, tooltip: [:0]const u8) void { - self.window.notebook.setTabTooltip(self, tooltip); -} - -/// Remove this tab from the window. -pub fn remove(self: *Tab) void { - self.window.closeTab(self); -} - -/// Helper function to check if any surface in the split hierarchy needs close confirmation -fn needsConfirm(elem: Surface.Container.Elem) bool { - return switch (elem) { - .surface => |s| s.core_surface.needsConfirmQuit(), - .split => |s| needsConfirm(s.top_left) or needsConfirm(s.bottom_right), - }; -} - -/// Close the tab, asking for confirmation if any surface requests it. -pub fn closeWithConfirmation(tab: *Tab) void { - switch (tab.elem) { - .surface => |s| s.closeWithConfirmation( - s.core_surface.needsConfirmQuit(), - .{ .tab = tab }, - ), - .split => |s| { - if (!needsConfirm(s.top_left) and !needsConfirm(s.bottom_right)) { - tab.remove(); - return; - } - - CloseDialog.show(.{ .tab = tab }) catch |err| { - log.err("failed to open close dialog={}", .{err}); - }; - }, - } -} - -fn gtkDestroy(_: *gtk.Box, self: *Tab) callconv(.c) void { - log.debug("tab box destroy", .{}); - - const alloc = self.window.app.core_app.alloc; - - // When our box is destroyed, we want to destroy our tab, too. - self.destroy(alloc); -} diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig deleted file mode 100644 index 8a4145b5f..000000000 --- a/src/apprt/gtk/TabView.zig +++ /dev/null @@ -1,284 +0,0 @@ -/// An abstraction over the Adwaita tab view to manage all the terminal tabs in -/// a window. -const TabView = @This(); - -const std = @import("std"); - -const gtk = @import("gtk"); -const adw = @import("adw"); -const gobject = @import("gobject"); -const glib = @import("glib"); - -const Window = @import("Window.zig"); -const Tab = @import("Tab.zig"); -const adw_version = @import("adw_version.zig"); - -const log = std.log.scoped(.gtk); - -/// our window -window: *Window, - -/// the tab view -tab_view: *adw.TabView, - -/// Set to true so that the adw close-page handler knows we're forcing -/// and to allow a close to happen with no confirm. This is a bit of a hack -/// because we currently use GTK alerts to confirm tab close and they -/// don't carry with them the ADW state that we are confirming or not. -/// Long term we should move to ADW alerts so we can know if we are -/// confirming or not. -forcing_close: bool = false, - -pub fn init(self: *TabView, window: *Window) void { - self.* = .{ - .window = window, - .tab_view = adw.TabView.new(), - }; - self.tab_view.as(gtk.Widget).addCssClass("notebook"); - - if (adw_version.atLeast(1, 2, 0)) { - // Adwaita enables all of the shortcuts by default. - // We want to manage keybindings ourselves. - self.tab_view.removeShortcuts(.{ - .alt_digits = true, - .alt_zero = true, - .control_end = true, - .control_home = true, - .control_page_down = true, - .control_page_up = true, - .control_shift_end = true, - .control_shift_home = true, - .control_shift_page_down = true, - .control_shift_page_up = true, - .control_shift_tab = true, - .control_tab = true, - }); - } - - _ = adw.TabView.signals.page_attached.connect( - self.tab_view, - *TabView, - adwPageAttached, - self, - .{}, - ); - _ = adw.TabView.signals.close_page.connect( - self.tab_view, - *TabView, - adwClosePage, - self, - .{}, - ); - _ = adw.TabView.signals.create_window.connect( - self.tab_view, - *TabView, - adwTabViewCreateWindow, - self, - .{}, - ); - _ = gobject.Object.signals.notify.connect( - self.tab_view, - *TabView, - adwSelectPage, - self, - .{ - .detail = "selected-page", - }, - ); -} - -pub fn asWidget(self: *TabView) *gtk.Widget { - return self.tab_view.as(gtk.Widget); -} - -pub fn nPages(self: *TabView) c_int { - return self.tab_view.getNPages(); -} - -/// Returns the index of the currently selected page. -/// Returns null if the notebook has no pages. -fn currentPage(self: *TabView) ?c_int { - const page = self.tab_view.getSelectedPage() orelse return null; - return self.tab_view.getPagePosition(page); -} - -/// Returns the currently selected tab or null if there are none. -pub fn currentTab(self: *TabView) ?*Tab { - const page = self.tab_view.getSelectedPage() orelse return null; - const child = page.getChild().as(gobject.Object); - return @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return null)); -} - -pub fn gotoNthTab(self: *TabView, position: c_int) bool { - const page_to_select = self.tab_view.getNthPage(position); - self.tab_view.setSelectedPage(page_to_select); - return true; -} - -pub fn getTabPage(self: *TabView, tab: *Tab) ?*adw.TabPage { - return self.tab_view.getPage(tab.box.as(gtk.Widget)); -} - -pub fn getTabPosition(self: *TabView, tab: *Tab) ?c_int { - return self.tab_view.getPagePosition(self.getTabPage(tab) orelse return null); -} - -pub fn gotoPreviousTab(self: *TabView, tab: *Tab) bool { - const page_idx = self.getTabPosition(tab) orelse return false; - - // The next index is the previous or we wrap around. - const next_idx = if (page_idx > 0) page_idx - 1 else next_idx: { - const max = self.nPages(); - break :next_idx max -| 1; - }; - - // Do nothing if we have one tab - if (next_idx == page_idx) return false; - - return self.gotoNthTab(next_idx); -} - -pub fn gotoNextTab(self: *TabView, tab: *Tab) bool { - const page_idx = self.getTabPosition(tab) orelse return false; - - const max = self.nPages() -| 1; - const next_idx = if (page_idx < max) page_idx + 1 else 0; - if (next_idx == page_idx) return false; - - return self.gotoNthTab(next_idx); -} - -pub fn moveTab(self: *TabView, tab: *Tab, position: c_int) void { - const page_idx = self.getTabPosition(tab) orelse return; - - const max = self.nPages() -| 1; - var new_position: c_int = page_idx + position; - - if (new_position < 0) { - new_position = max + new_position + 1; - } else if (new_position > max) { - new_position = new_position - max - 1; - } - - if (new_position == page_idx) return; - self.reorderPage(tab, new_position); -} - -pub fn reorderPage(self: *TabView, tab: *Tab, position: c_int) void { - _ = self.tab_view.reorderPage(self.getTabPage(tab) orelse return, position); -} - -pub fn setTabTitle(self: *TabView, tab: *Tab, title: [:0]const u8) void { - const page = self.getTabPage(tab) orelse return; - page.setTitle(title.ptr); -} - -pub fn setTabTooltip(self: *TabView, tab: *Tab, tooltip: [:0]const u8) void { - const page = self.getTabPage(tab) orelse return; - page.setTooltip(tooltip.ptr); -} - -fn newTabInsertPosition(self: *TabView, tab: *Tab) c_int { - const numPages = self.nPages(); - return switch (tab.window.app.config.@"window-new-tab-position") { - .current => if (self.currentPage()) |page| page + 1 else numPages, - .end => numPages, - }; -} - -/// Adds a new tab with the given title to the notebook. -pub fn addTab(self: *TabView, tab: *Tab, title: [:0]const u8) void { - const position = self.newTabInsertPosition(tab); - const page = self.tab_view.insert(tab.box.as(gtk.Widget), position); - self.setTabTitle(tab, title); - self.tab_view.setSelectedPage(page); -} - -pub fn closeTab(self: *TabView, tab: *Tab) void { - // closeTab always expects to close unconditionally so we mark this - // as true so that the close_page call below doesn't request - // confirmation. - self.forcing_close = true; - const n = self.nPages(); - defer { - // self becomes invalid if we close the last page because we close - // the whole window - if (n > 1) self.forcing_close = false; - } - - if (self.getTabPage(tab)) |page| self.tab_view.closePage(page); - - // If we have no more tabs we close the window - if (self.nPages() == 0) { - // libadw versions < 1.5.1 leak the final page view - // which causes our surface to not properly cleanup. We - // unref to force the cleanup. This will trigger a critical - // warning from GTK, but I don't know any other workaround. - if (!adw_version.atLeast(1, 5, 1)) { - tab.box.unref(); - } - - self.window.close(); - } -} - -pub fn createWindow(window: *Window) !*Window { - const new_window = try Window.create(window.app.core_app.alloc, window.app); - new_window.present(); - return new_window; -} - -fn adwPageAttached(_: *adw.TabView, page: *adw.TabPage, _: c_int, self: *TabView) callconv(.c) void { - const child = page.getChild().as(gobject.Object); - const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return)); - tab.window = self.window; - - self.window.focusCurrentTab(); -} - -fn adwClosePage( - _: *adw.TabView, - page: *adw.TabPage, - self: *TabView, -) callconv(.c) c_int { - const child = page.getChild().as(gobject.Object); - const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return 0)); - self.tab_view.closePageFinish(page, @intFromBool(self.forcing_close)); - if (!self.forcing_close) { - // We cannot trigger a close directly in here as the page will stay - // alive until this handler returns, breaking the assumption where - // no pages means they are all destroyed. - // - // Schedule the close request to happen in the next event cycle. - _ = glib.idleAddOnce(glibIdleOnceCloseTab, tab); - } - - return 1; -} - -fn adwTabViewCreateWindow( - _: *adw.TabView, - self: *TabView, -) callconv(.c) ?*adw.TabView { - const window = createWindow(self.window) catch |err| { - log.warn("error creating new window error={}", .{err}); - return null; - }; - return window.notebook.tab_view; -} - -fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callconv(.c) void { - const page = self.tab_view.getSelectedPage() orelse return; - - // If the tab was previously marked as needing attention - // (e.g. due to a bell character), we now unmark that - page.setNeedsAttention(@intFromBool(false)); - - const title = page.getTitle(); - self.window.setTitle(std.mem.span(title)); -} - -fn glibIdleOnceCloseTab(data: ?*anyopaque) callconv(.c) void { - const tab: *Tab = @ptrCast(@alignCast(data orelse return)); - tab.closeWithConfirmation(); -} diff --git a/src/apprt/gtk/URLWidget.zig b/src/apprt/gtk/URLWidget.zig deleted file mode 100644 index e59827aaf..000000000 --- a/src/apprt/gtk/URLWidget.zig +++ /dev/null @@ -1,115 +0,0 @@ -//! Represents the URL hover widgets that show the hovered URL. -//! -//! To explain a bit how this all works since its split across a few places: -//! We create a left/right pair of labels. The left label is shown by default, -//! and the right label is hidden. When the mouse enters the left label, we -//! show the right label. When the mouse leaves the left label, we hide the -//! right label. -//! -//! The hover and styling is done with a combination of GTK event controllers -//! and CSS in style.css. -const URLWidget = @This(); - -const gtk = @import("gtk"); - -/// The label that appears on the bottom left. -left: *gtk.Label, - -/// The label that appears on the bottom right. -right: *gtk.Label, - -pub fn init( - /// The overlay that we will attach our labels to. - overlay: *gtk.Overlay, - /// The URL to display. - str: [:0]const u8, -) URLWidget { - // Create the left - const left = left: { - const left = gtk.Label.new(str.ptr); - left.setEllipsize(.middle); - const widget = left.as(gtk.Widget); - widget.addCssClass("view"); - widget.addCssClass("url-overlay"); - widget.addCssClass("left"); - widget.setHalign(.start); - widget.setValign(.end); - break :left left; - }; - - // Create the right - const right = right: { - const right = gtk.Label.new(str.ptr); - right.setEllipsize(.middle); - const widget = right.as(gtk.Widget); - widget.addCssClass("hidden"); - widget.addCssClass("view"); - widget.addCssClass("url-overlay"); - widget.addCssClass("right"); - widget.setHalign(.end); - widget.setValign(.end); - break :right right; - }; - - // Setup our mouse hover event controller for the left label. - const ec_motion = gtk.EventControllerMotion.new(); - errdefer ec_motion.unref(); - - left.as(gtk.Widget).addController(ec_motion.as(gtk.EventController)); - - _ = gtk.EventControllerMotion.signals.enter.connect( - ec_motion, - *gtk.Label, - gtkLeftEnter, - right, - .{}, - ); - _ = gtk.EventControllerMotion.signals.leave.connect( - ec_motion, - *gtk.Label, - gtkLeftLeave, - right, - .{}, - ); - - // Show it - overlay.addOverlay(left.as(gtk.Widget)); - overlay.addOverlay(right.as(gtk.Widget)); - - return .{ - .left = left, - .right = right, - }; -} - -/// Remove our labels from the overlay. -pub fn deinit(self: *URLWidget, overlay: *gtk.Overlay) void { - overlay.removeOverlay(self.left.as(gtk.Widget)); - overlay.removeOverlay(self.right.as(gtk.Widget)); -} - -/// Change the URL that is displayed. -pub fn setText(self: *const URLWidget, str: [:0]const u8) void { - self.left.setText(str.ptr); - self.right.setText(str.ptr); -} - -/// Callback for when the mouse enters the left label. That means that we should -/// show the right label. CSS will handle hiding the left label. -fn gtkLeftEnter( - _: *gtk.EventControllerMotion, - _: f64, - _: f64, - right: *gtk.Label, -) callconv(.c) void { - right.as(gtk.Widget).removeCssClass("hidden"); -} - -/// Callback for when the mouse leaves the left label. That means that we should -/// hide the right label. CSS will handle showing the left label. -fn gtkLeftLeave( - _: *gtk.EventControllerMotion, - right: *gtk.Label, -) callconv(.c) void { - right.as(gtk.Widget).addCssClass("hidden"); -} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig deleted file mode 100644 index 8c02396a6..000000000 --- a/src/apprt/gtk/Window.zig +++ /dev/null @@ -1,1190 +0,0 @@ -/// A Window is a single, real GTK window that holds terminal surfaces. -/// -/// A Window always contains a notebook (what GTK calls a tabbed container) -/// even while no tabs are in use, because a notebook without a tab bar has -/// no visible UI chrome. -const Window = @This(); - -const std = @import("std"); -const builtin = @import("builtin"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -const adw = @import("adw"); -const gdk = @import("gdk"); -const gio = @import("gio"); -const glib = @import("glib"); -const gobject = @import("gobject"); -const gtk = @import("gtk"); - -const build_config = @import("../../build_config.zig"); -const configpkg = @import("../../config.zig"); -const font = @import("../../font/main.zig"); -const i18n = @import("../../os/main.zig").i18n; -const input = @import("../../input.zig"); -const CoreSurface = @import("../../Surface.zig"); - -const App = @import("App.zig"); -const Builder = @import("Builder.zig"); -const Color = configpkg.Config.Color; -const Surface = @import("Surface.zig"); -const Menu = @import("menu.zig").Menu; -const Tab = @import("Tab.zig"); -const gtk_key = @import("key.zig"); -const TabView = @import("TabView.zig"); -const HeaderBar = @import("headerbar.zig"); -const CloseDialog = @import("CloseDialog.zig"); -const CommandPalette = @import("CommandPalette.zig"); -const winprotopkg = @import("winproto.zig"); -const gtk_version = @import("gtk_version.zig"); -const adw_version = @import("adw_version.zig"); - -const log = std.log.scoped(.gtk); - -app: *App, - -/// Used to deduplicate updateConfig invocations -last_config: usize, - -/// Local copy of any configuration -config: DerivedConfig, - -/// Our window -window: *adw.ApplicationWindow, - -/// The header bar for the window. -headerbar: HeaderBar, - -/// The tab bar for the window. -tab_bar: *adw.TabBar, - -/// The tab overview for the window. This is possibly null since there is no -/// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0). -tab_overview: ?*adw.TabOverview, - -/// The notebook (tab grouping) for this window. -notebook: TabView, - -/// The "main" menu that is attached to a button in the headerbar. -titlebar_menu: Menu(Window, "titlebar_menu", true), - -/// The libadwaita widget for receiving toast send requests. -toast_overlay: *adw.ToastOverlay, - -/// The command palette. -command_palette: CommandPalette, - -/// See adwTabOverviewOpen for why we have this. -adw_tab_overview_focus_timer: ?c_uint = null, - -/// State and logic for windowing protocol for a window. -winproto: winprotopkg.Window, - -pub const DerivedConfig = struct { - background_opacity: f64, - background_blur: configpkg.Config.BackgroundBlur, - window_theme: configpkg.Config.WindowTheme, - gtk_titlebar: bool, - gtk_titlebar_hide_when_maximized: bool, - gtk_tabs_location: configpkg.Config.GtkTabsLocation, - gtk_wide_tabs: bool, - gtk_toolbar_style: configpkg.Config.GtkToolbarStyle, - window_show_tab_bar: configpkg.Config.WindowShowTabBar, - - quick_terminal_position: configpkg.Config.QuickTerminalPosition, - quick_terminal_size: configpkg.Config.QuickTerminalSize, - quick_terminal_autohide: bool, - quick_terminal_keyboard_interactivity: configpkg.Config.QuickTerminalKeyboardInteractivity, - - maximize: bool, - fullscreen: bool, - window_decoration: configpkg.Config.WindowDecoration, - - pub fn init(config: *const configpkg.Config) DerivedConfig { - return .{ - .background_opacity = config.@"background-opacity", - .background_blur = config.@"background-blur", - .window_theme = config.@"window-theme", - .gtk_titlebar = config.@"gtk-titlebar", - .gtk_titlebar_hide_when_maximized = config.@"gtk-titlebar-hide-when-maximized", - .gtk_tabs_location = config.@"gtk-tabs-location", - .gtk_wide_tabs = config.@"gtk-wide-tabs", - .gtk_toolbar_style = config.@"gtk-toolbar-style", - .window_show_tab_bar = config.@"window-show-tab-bar", - - .quick_terminal_position = config.@"quick-terminal-position", - .quick_terminal_size = config.@"quick-terminal-size", - .quick_terminal_autohide = config.@"quick-terminal-autohide", - .quick_terminal_keyboard_interactivity = config.@"quick-terminal-keyboard-interactivity", - - .maximize = config.maximize, - .fullscreen = config.fullscreen, - .window_decoration = config.@"window-decoration", - }; - } -}; - -pub fn create(alloc: Allocator, app: *App) !*Window { - // Allocate a fixed pointer for our window. We try to minimize - // allocations but windows and other GUI requirements are so minimal - // compared to the steady-state terminal operation so we use heap - // allocation for this. - // - // The allocation is owned by the GtkWindow created. It will be - // freed when the window is closed. - var window = try alloc.create(Window); - errdefer alloc.destroy(window); - try window.init(app); - return window; -} - -pub fn init(self: *Window, app: *App) !void { - // Set up our own state - self.* = .{ - .app = app, - .last_config = @intFromPtr(&app.config), - .config = .init(&app.config), - .window = undefined, - .headerbar = undefined, - .tab_bar = undefined, - .tab_overview = null, - .notebook = undefined, - .titlebar_menu = undefined, - .toast_overlay = undefined, - .command_palette = undefined, - .winproto = .none, - }; - - // Create the window - self.window = .new(app.app.as(gtk.Application)); - const gtk_window = self.window.as(gtk.Window); - const gtk_widget = self.window.as(gtk.Widget); - errdefer gtk_window.destroy(); - - gtk_window.setTitle("Ghostty"); - gtk_window.setDefaultSize(1000, 600); - gtk_widget.addCssClass("window"); - gtk_widget.addCssClass("terminal-window"); - - // GTK4 grabs F10 input by default to focus the menubar icon. We want - // to disable this so that terminal programs can capture F10 (such as htop) - gtk_window.setHandleMenubarAccel(0); - gtk_window.setIconName(build_config.bundle_id); - - // Create our box which will hold our widgets in the main content area. - const box = gtk.Box.new(.vertical, 0); - - // Set up the menus - self.titlebar_menu.init(self); - - // Setup our notebook - self.notebook.init(self); - - if (adw_version.supportsDialogs()) try self.command_palette.init(self); - - // If we are using Adwaita, then we can support the tab overview. - self.tab_overview = if (adw_version.supportsTabOverview()) overview: { - const tab_overview = adw.TabOverview.new(); - tab_overview.setView(self.notebook.tab_view); - tab_overview.setEnableNewTab(1); - _ = adw.TabOverview.signals.create_tab.connect( - tab_overview, - *Window, - gtkNewTabFromOverview, - self, - .{}, - ); - _ = gobject.Object.signals.notify.connect( - tab_overview, - *Window, - adwTabOverviewOpen, - self, - .{ - .detail = "open", - }, - ); - break :overview tab_overview; - } else null; - - // gtk-titlebar can be used to disable the header bar (but keep the window - // manager's decorations). We create this no matter if we are decorated or - // not because we can have a keybind to toggle the decorations. - self.headerbar.init(self); - - { - const btn = gtk.MenuButton.new(); - btn.as(gtk.Widget).setTooltipText(i18n._("Main Menu")); - btn.as(gtk.Widget).setCanFocus(0); - btn.setIconName("open-menu-symbolic"); - btn.setPopover(self.titlebar_menu.asWidget()); - _ = gobject.Object.signals.notify.connect( - btn, - *Window, - gtkTitlebarMenuActivate, - self, - .{ - .detail = "active", - }, - ); - self.headerbar.packEnd(btn.as(gtk.Widget)); - } - - // If we're using an AdwWindow then we can support the tab overview. - if (self.tab_overview) |tab_overview| { - if (!adw_version.supportsTabOverview()) unreachable; - - const btn = switch (self.config.window_show_tab_bar) { - .always, .auto => btn: { - const btn = gtk.ToggleButton.new(); - btn.as(gtk.Widget).setTooltipText(i18n._("View Open Tabs")); - btn.as(gtk.Button).setIconName("view-grid-symbolic"); - _ = btn.as(gobject.Object).bindProperty( - "active", - tab_overview.as(gobject.Object), - "open", - .{ .bidirectional = true, .sync_create = true }, - ); - break :btn btn.as(gtk.Widget); - }, - .never => btn: { - const btn = adw.TabButton.new(); - btn.setView(self.notebook.tab_view); - btn.as(gtk.Actionable).setActionName("overview.open"); - break :btn btn.as(gtk.Widget); - }, - }; - - btn.setCanFocus(0); - btn.setFocusOnClick(0); - self.headerbar.packEnd(btn); - } - - { - const btn = adw.SplitButton.new(); - btn.setIconName("tab-new-symbolic"); - btn.as(gtk.Widget).setTooltipText(i18n._("New Tab")); - btn.setDropdownTooltip(i18n._("New Split")); - - var builder = Builder.init("menu-headerbar-split_menu", 1, 0); - defer builder.deinit(); - btn.setMenuModel(builder.getObject(gio.MenuModel, "menu")); - - _ = adw.SplitButton.signals.clicked.connect( - btn, - *Window, - adwNewTabClick, - self, - .{}, - ); - self.headerbar.packStart(btn.as(gtk.Widget)); - } - - _ = gobject.Object.signals.notify.connect( - self.window, - *Window, - gtkWindowNotifyMaximized, - self, - .{ - .detail = "maximized", - }, - ); - _ = gobject.Object.signals.notify.connect( - self.window, - *Window, - gtkWindowNotifyFullscreened, - self, - .{ - .detail = "fullscreened", - }, - ); - _ = gobject.Object.signals.notify.connect( - self.window, - *Window, - gtkWindowNotifyIsActive, - self, - .{ - .detail = "is-active", - }, - ); - _ = gobject.Object.signals.notify.connect( - self.window, - *Window, - gtkWindowUpdateScaleFactor, - self, - .{ - .detail = "scale-factor", - }, - ); - - // If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we - // need to stick the headerbar into the content box. - if (!adw_version.supportsTabOverview()) { - box.append(self.headerbar.asWidget()); - } - - // In debug we show a warning and apply the 'devel' class to the window. - // This is a really common issue where people build from source in debug and performance is really bad. - if (comptime std.debug.runtime_safety) { - const warning_box = gtk.Box.new(.vertical, 0); - const warning_text = i18n._("⚠️ You're running a debug build of Ghostty! Performance will be degraded."); - if (adw_version.supportsBanner()) { - const banner = adw.Banner.new(warning_text); - banner.setRevealed(1); - warning_box.append(banner.as(gtk.Widget)); - } else { - const warning = gtk.Label.new(warning_text); - warning.as(gtk.Widget).setMarginTop(10); - warning.as(gtk.Widget).setMarginBottom(10); - warning_box.append(warning.as(gtk.Widget)); - } - gtk_widget.addCssClass("devel"); - warning_box.as(gtk.Widget).addCssClass("background"); - box.append(warning_box.as(gtk.Widget)); - } - - // Setup our toast overlay if we have one - self.toast_overlay = .new(); - self.toast_overlay.setChild(self.notebook.asWidget()); - box.append(self.toast_overlay.as(gtk.Widget)); - - // If we have a tab overview then we can set it on our notebook. - if (self.tab_overview) |tab_overview| { - if (!adw_version.supportsTabOverview()) unreachable; - tab_overview.setView(self.notebook.tab_view); - } - - // We register a key event controller with the window so - // we can catch key events when our surface may not be - // focused (i.e. when the libadw tab overview is shown). - const ec_key_press = gtk.EventControllerKey.new(); - errdefer ec_key_press.unref(); - gtk_widget.addController(ec_key_press.as(gtk.EventController)); - - // All of our events - _ = gtk.Widget.signals.realize.connect( - self.window, - *Window, - gtkRealize, - self, - .{}, - ); - _ = gtk.Window.signals.close_request.connect( - self.window, - *Window, - gtkCloseRequest, - self, - .{}, - ); - _ = gtk.Widget.signals.destroy.connect( - self.window, - *Window, - gtkDestroy, - self, - .{}, - ); - _ = gtk.EventControllerKey.signals.key_pressed.connect( - ec_key_press, - *Window, - gtkKeyPressed, - self, - .{}, - ); - - // Our actions for the menu - initActions(self); - - self.tab_bar = adw.TabBar.new(); - self.tab_bar.setView(self.notebook.tab_view); - - if (adw_version.supportsToolbarView()) { - const toolbar_view = adw.ToolbarView.new(); - toolbar_view.addTopBar(self.headerbar.asWidget()); - - switch (self.config.gtk_tabs_location) { - .top => toolbar_view.addTopBar(self.tab_bar.as(gtk.Widget)), - .bottom => toolbar_view.addBottomBar(self.tab_bar.as(gtk.Widget)), - } - toolbar_view.setContent(box.as(gtk.Widget)); - - const toolbar_style: adw.ToolbarStyle = switch (self.config.gtk_toolbar_style) { - .flat => .flat, - .raised => .raised, - .@"raised-border" => .raised_border, - }; - toolbar_view.setTopBarStyle(toolbar_style); - toolbar_view.setTopBarStyle(toolbar_style); - - // Set our application window content. - self.tab_overview.?.setChild(toolbar_view.as(gtk.Widget)); - self.window.setContent(self.tab_overview.?.as(gtk.Widget)); - } else { - // In earlier adwaita versions, we need to add the tabbar manually since we do not use - // an AdwToolbarView. - self.tab_bar.as(gtk.Widget).addCssClass("inline"); - - switch (self.config.gtk_tabs_location) { - .top => box.insertChildAfter( - self.tab_bar.as(gtk.Widget), - self.headerbar.asWidget(), - ), - .bottom => box.append(self.tab_bar.as(gtk.Widget)), - } - } - - // If we want the window to be maximized, we do that here. - if (self.config.maximize) self.window.as(gtk.Window).maximize(); - - // If we are in fullscreen mode, new windows start fullscreen. - if (self.config.fullscreen) self.window.as(gtk.Window).fullscreen(); -} - -pub fn present(self: *Window) void { - self.window.as(gtk.Window).present(); -} - -pub fn toggleVisibility(self: *Window) void { - const widget = self.window.as(gtk.Widget); - - widget.setVisible(@intFromBool(widget.isVisible() == 0)); -} - -pub fn isQuickTerminal(self: *Window) bool { - return self.app.quick_terminal == self; -} - -pub fn updateConfig( - self: *Window, - config: *const configpkg.Config, -) !void { - // avoid multiple reconfigs when we have many surfaces contained in this - // window using the integer value of config as a simple marker to know if - // we've "seen" this particular config before - const this_config = @intFromPtr(config); - if (self.last_config == this_config) return; - self.last_config = this_config; - - self.config = .init(config); - - // We always resync our appearance whenever the config changes. - try self.syncAppearance(); - - // Update binds inside the command palette - try self.command_palette.updateConfig(config); -} - -/// Updates appearance based on config settings. Will be called once upon window -/// realization, every time the config is reloaded, and every time a window state -/// is toggled (un-/maximized, un-/fullscreened, window decorations toggled, etc.) -/// -/// TODO: Many of the initial style settings in `create` could possibly be made -/// reactive by moving them here. -pub fn syncAppearance(self: *Window) !void { - const csd_enabled = self.winproto.clientSideDecorationEnabled(); - const gtk_window = self.window.as(gtk.Window); - const gtk_widget = self.window.as(gtk.Widget); - gtk_window.setDecorated(@intFromBool(csd_enabled)); - - // Fix any artifacting that may occur in window corners. The .ssd CSS - // class is defined in the GtkWindow documentation: - // https://docs.gtk.org/gtk4/class.Window.html#css-nodes. A definition - // for .ssd is provided by GTK and Adwaita. - toggleCssClass(gtk_widget, "csd", csd_enabled); - toggleCssClass(gtk_widget, "ssd", !csd_enabled); - toggleCssClass(gtk_widget, "no-border-radius", !csd_enabled); - - self.headerbar.setVisible(visible: { - // Never display the header bar when CSDs are disabled. - if (!csd_enabled) break :visible false; - - // Never display the header bar as a quick terminal. - if (self.isQuickTerminal()) break :visible false; - - // Unconditionally disable the header bar when fullscreened. - if (self.window.as(gtk.Window).isFullscreen() != 0) - break :visible false; - - // *Conditionally* disable the header bar when maximized, - // and gtk-titlebar-hide-when-maximized is set - if (self.window.as(gtk.Window).isMaximized() != 0 and - self.config.gtk_titlebar_hide_when_maximized) - break :visible false; - - break :visible self.config.gtk_titlebar; - }); - - toggleCssClass( - gtk_widget, - "background", - self.config.background_opacity >= 1, - ); - - // Apply class to color headerbar if window-theme is set to `ghostty` and - // GTK version is before 4.16. The conditional is because above 4.16 - // we use GTK CSS color variables. - toggleCssClass( - gtk_widget, - "window-theme-ghostty", - !gtk_version.atLeast(4, 16, 0) and self.config.window_theme == .ghostty, - ); - - if (self.tab_overview) |tab_overview| { - if (!adw_version.supportsTabOverview()) unreachable; - - // Disable the title buttons (close, maximize, minimize, ...) - // *inside* the tab overview if CSDs are disabled. - // We do spare the search button, though. - tab_overview.setShowStartTitleButtons(@intFromBool(csd_enabled)); - tab_overview.setShowEndTitleButtons(@intFromBool(csd_enabled)); - - // Update toolbar view style - toolbar_view: { - const tab_overview_child = tab_overview.getChild() orelse break :toolbar_view; - const toolbar_view = gobject.ext.cast( - adw.ToolbarView, - tab_overview_child, - ) orelse break :toolbar_view; - const toolbar_style: adw.ToolbarStyle = switch (self.config.gtk_toolbar_style) { - .flat => .flat, - .raised => .raised, - .@"raised-border" => .raised_border, - }; - toolbar_view.setTopBarStyle(toolbar_style); - toolbar_view.setBottomBarStyle(toolbar_style); - } - } - - self.tab_bar.setExpandTabs(@intFromBool(self.config.gtk_wide_tabs)); - self.tab_bar.setAutohide(switch (self.config.window_show_tab_bar) { - .auto, .never => @intFromBool(true), - .always => @intFromBool(false), - }); - self.tab_bar.as(gtk.Widget).setVisible(switch (self.config.window_show_tab_bar) { - .always, .auto => @intFromBool(true), - .never => @intFromBool(false), - }); - - self.winproto.syncAppearance() catch |err| { - log.warn("failed to sync winproto appearance error={}", .{err}); - }; -} - -fn toggleCssClass( - widget: *gtk.Widget, - class: [:0]const u8, - v: bool, -) void { - if (v) { - widget.addCssClass(class); - } else { - widget.removeCssClass(class); - } -} - -/// Sets up the GTK actions for the window scope. Actions are how GTK handles -/// menus and such. The menu is defined in App.zig but the action is defined -/// here. The string name binds them. -fn initActions(self: *Window) void { - const window = self.window.as(gtk.ApplicationWindow); - const action_map = window.as(gio.ActionMap); - const actions = .{ - .{ "about", gtkActionAbout }, - .{ "close", gtkActionClose }, - .{ "new-window", gtkActionNewWindow }, - .{ "new-tab", gtkActionNewTab }, - .{ "close-tab", gtkActionCloseTab }, - .{ "split-right", gtkActionSplitRight }, - .{ "split-down", gtkActionSplitDown }, - .{ "split-left", gtkActionSplitLeft }, - .{ "split-up", gtkActionSplitUp }, - .{ "toggle-inspector", gtkActionToggleInspector }, - .{ "toggle-command-palette", gtkActionToggleCommandPalette }, - .{ "copy", gtkActionCopy }, - .{ "paste", gtkActionPaste }, - .{ "reset", gtkActionReset }, - .{ "clear", gtkActionClear }, - .{ "prompt-title", gtkActionPromptTitle }, - }; - - inline for (actions) |entry| { - const action = gio.SimpleAction.new(entry[0], null); - defer action.unref(); - _ = gio.SimpleAction.signals.activate.connect( - action, - *Window, - entry[1], - self, - .{}, - ); - action_map.addAction(action.as(gio.Action)); - } -} - -pub fn deinit(self: *Window) void { - self.winproto.deinit(self.app.core_app.alloc); - if (adw_version.supportsDialogs()) self.command_palette.deinit(); - - if (self.adw_tab_overview_focus_timer) |timer| { - _ = glib.Source.remove(timer); - } -} - -/// Set the title of the window. -pub fn setTitle(self: *Window, title: [:0]const u8) void { - self.headerbar.setTitle(title); -} - -/// Set the subtitle of the window if it has one. -pub fn setSubtitle(self: *Window, subtitle: [:0]const u8) void { - self.headerbar.setSubtitle(subtitle); -} - -/// Add a new tab to this window. -pub fn newTab(self: *Window, parent: ?*CoreSurface) !void { - const alloc = self.app.core_app.alloc; - _ = try Tab.create(alloc, self, parent); - - // TODO: When this is triggered through a GTK action, the new surface - // redraws correctly. When it's triggered through keyboard shortcuts, it - // does not (cursor doesn't blink) unless reactivated by refocusing. -} - -/// Close the tab for the given notebook page. This will automatically -/// handle closing the window if there are no more tabs. -pub fn closeTab(self: *Window, tab: *Tab) void { - self.notebook.closeTab(tab); -} - -/// Go to the previous tab for a surface. -pub fn gotoPreviousTab(self: *Window, surface: *Surface) bool { - const tab = surface.container.tab() orelse { - log.info("surface is not attached to a tab bar, cannot navigate", .{}); - return false; - }; - if (!self.notebook.gotoPreviousTab(tab)) return false; - self.focusCurrentTab(); - return true; -} - -/// Go to the next tab for a surface. -pub fn gotoNextTab(self: *Window, surface: *Surface) bool { - const tab = surface.container.tab() orelse { - log.info("surface is not attached to a tab bar, cannot navigate", .{}); - return false; - }; - if (!self.notebook.gotoNextTab(tab)) return false; - self.focusCurrentTab(); - return true; -} - -/// Move the current tab for a surface. -pub fn moveTab(self: *Window, surface: *Surface, position: c_int) void { - const tab = surface.container.tab() orelse { - log.info("surface is not attached to a tab bar, cannot navigate", .{}); - return; - }; - self.notebook.moveTab(tab, position); -} - -/// Go to the last tab for a surface. -pub fn gotoLastTab(self: *Window) bool { - const max = self.notebook.nPages(); - return self.gotoTab(@intCast(max)); -} - -/// Go to the specific tab index. -pub fn gotoTab(self: *Window, n: usize) bool { - if (n == 0) return false; - const max = self.notebook.nPages(); - if (max == 0) return false; - const page_idx = std.math.cast(c_int, n - 1) orelse return false; - if (!self.notebook.gotoNthTab(@min(page_idx, max - 1))) return false; - self.focusCurrentTab(); - return true; -} - -/// Toggle tab overview (if present) -pub fn toggleTabOverview(self: *Window) void { - if (self.tab_overview) |tab_overview| { - if (!adw_version.supportsTabOverview()) unreachable; - const is_open = tab_overview.getOpen() != 0; - tab_overview.setOpen(@intFromBool(!is_open)); - } -} - -/// Toggle the maximized state for this window. -pub fn toggleMaximize(self: *Window) void { - if (self.window.as(gtk.Window).isMaximized() != 0) { - self.window.as(gtk.Window).unmaximize(); - } else { - self.window.as(gtk.Window).maximize(); - } - // We update the config and call syncAppearance - // in the gtkWindowNotifyMaximized callback -} - -/// Toggle fullscreen for this window. -pub fn toggleFullscreen(self: *Window) void { - if (self.window.as(gtk.Window).isFullscreen() != 0) { - self.window.as(gtk.Window).unfullscreen(); - } else { - self.window.as(gtk.Window).fullscreen(); - } - // We update the config and call syncAppearance - // in the gtkWindowNotifyFullscreened callback -} - -/// Toggle the window decorations for this window. -pub fn toggleWindowDecorations(self: *Window) void { - self.config.window_decoration = switch (self.config.window_decoration) { - .none => switch (self.app.config.@"window-decoration") { - // If we started as none, then we switch to auto - .none => .auto, - // Switch back - .auto, .client, .server => |v| v, - }, - // Always set to none - .auto, .client, .server => .none, - }; - - self.syncAppearance() catch |err| { - log.err("failed to sync appearance={}", .{err}); - }; -} - -/// Toggle the window decorations for this window. -pub fn toggleCommandPalette(self: *Window) void { - if (adw_version.supportsDialogs()) { - self.command_palette.toggle(); - } else { - log.warn("libadwaita 1.5+ is required for the command palette", .{}); - } -} - -/// Grabs focus on the currently selected tab. -pub fn focusCurrentTab(self: *Window) void { - const tab = self.notebook.currentTab() orelse return; - const surface = tab.focus_child orelse return; - _ = surface.gl_area.as(gtk.Widget).grabFocus(); - - if (surface.getTitle()) |title| { - self.setTitle(title); - } -} - -pub fn onConfigReloaded(self: *Window) void { - if (self.app.config.@"app-notifications".@"config-reload") { - self.sendToast(i18n._("Reloaded the configuration")); - } -} - -pub fn sendToast(self: *Window, title: [*:0]const u8) void { - const toast = adw.Toast.new(title); - toast.setTimeout(3); - self.toast_overlay.addToast(toast); -} - -fn gtkRealize(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { - // Initialize our window protocol logic - if (winprotopkg.Window.init( - self.app.core_app.alloc, - &self.app.winproto, - self, - )) |wp| { - self.winproto = wp; - } else |err| { - log.warn("failed to initialize window protocol error={}", .{err}); - } - - // When we are realized we always setup our appearance - self.syncAppearance() catch |err| { - log.err("failed to initialize appearance={}", .{err}); - }; -} - -fn gtkWindowNotifyMaximized( - _: *adw.ApplicationWindow, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - self.syncAppearance() catch |err| { - log.err("failed to sync appearance={}", .{err}); - }; -} - -fn gtkWindowNotifyFullscreened( - _: *adw.ApplicationWindow, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - self.syncAppearance() catch |err| { - log.err("failed to sync appearance={}", .{err}); - }; -} - -fn gtkWindowNotifyIsActive( - _: *adw.ApplicationWindow, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - self.winproto.setUrgent(false) catch |err| { - log.err("failed to unrequest user attention={}", .{err}); - }; - - if (self.isQuickTerminal()) { - // Hide when we're unfocused - if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) { - self.toggleVisibility(); - } - } -} - -fn gtkWindowUpdateScaleFactor( - _: *adw.ApplicationWindow, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - // On some platforms (namely X11) we need to refresh our appearance when - // the scale factor changes. In theory this could be more fine-grained as - // a full refresh could be expensive, but a) this *should* be rare, and - // b) quite noticeable visual bugs would occur if this is not present. - self.winproto.syncAppearance() catch |err| { - log.err( - "failed to sync appearance after scale factor has been updated={}", - .{err}, - ); - return; - }; -} - -/// Perform a binding action on the window's action surface. -pub fn performBindingAction(self: *Window, action: input.Binding.Action) void { - const surface = self.actionSurface() orelse return; - _ = surface.performBindingAction(action) catch |err| { - log.warn("error performing binding action error={}", .{err}); - return; - }; -} - -fn gtkTabNewClick(_: *gtk.Button, self: *Window) callconv(.c) void { - self.performBindingAction(.{ .new_tab = {} }); -} - -/// Create a new surface (tab or split). -fn adwNewTabClick(_: *adw.SplitButton, self: *Window) callconv(.c) void { - self.performBindingAction(.{ .new_tab = {} }); -} - -/// Create a new tab from the AdwTabOverview. We can't copy gtkTabNewClick -/// because we need to return an AdwTabPage from this function. -fn gtkNewTabFromOverview(_: *adw.TabOverview, self: *Window) callconv(.c) *adw.TabPage { - if (!adw_version.supportsTabOverview()) unreachable; - - const alloc = self.app.core_app.alloc; - const surface = self.actionSurface(); - const tab = Tab.create(alloc, self, surface) catch unreachable; - return self.notebook.tab_view.getPage(tab.box.as(gtk.Widget)); -} - -fn adwTabOverviewOpen( - tab_overview: *adw.TabOverview, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - if (!adw_version.supportsTabOverview()) unreachable; - - // We only care about when the tab overview is closed. - if (tab_overview.getOpen() != 0) return; - - // On tab overview close, focus is sometimes lost. This is an - // upstream issue in libadwaita[1]. When this is resolved we - // can put a runtime version check here to avoid this workaround. - // - // Our workaround is to start a timer after 500ms to refocus - // the currently selected tab. We choose 500ms because the adw - // animation is 400ms. - // - // [1]: https://gitlab.gnome.org/GNOME/libadwaita/-/issues/670 - - // If we have an old timer remove it - if (self.adw_tab_overview_focus_timer) |timer| { - _ = glib.Source.remove(timer); - } - - // Restart our timer - self.adw_tab_overview_focus_timer = glib.timeoutAdd( - 500, - adwTabOverviewFocusTimer, - self, - ); -} - -fn adwTabOverviewFocusTimer( - ud: ?*anyopaque, -) callconv(.c) c_int { - if (!adw_version.supportsTabOverview()) unreachable; - const self: *Window = @ptrCast(@alignCast(ud orelse return 0)); - self.adw_tab_overview_focus_timer = null; - self.focusCurrentTab(); - - // Remove the timer - return 0; -} - -pub fn close(self: *Window) void { - const window = self.window.as(gtk.Window); - - // Unset the quick terminal on the app level - if (self.isQuickTerminal()) self.app.quick_terminal = null; - - window.destroy(); -} - -pub fn closeWithConfirmation(self: *Window) void { - // If none of our surfaces need confirmation, we can just exit. - for (self.app.core_app.surfaces.items) |surface| { - if (surface.container.window()) |window| { - if (window == self and - surface.core_surface.needsConfirmQuit()) break; - } - } else { - self.close(); - return; - } - - CloseDialog.show(.{ .window = self }) catch |err| { - log.err("failed to open close dialog={}", .{err}); - }; -} - -fn gtkCloseRequest(_: *adw.ApplicationWindow, self: *Window) callconv(.c) c_int { - log.debug("window close request", .{}); - - self.closeWithConfirmation(); - return 1; -} - -/// "destroy" signal for the window -fn gtkDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { - log.debug("window destroy", .{}); - - const alloc = self.app.core_app.alloc; - self.deinit(); - alloc.destroy(self); -} - -fn gtkKeyPressed( - ec_key: *gtk.EventControllerKey, - keyval: c_uint, - keycode: c_uint, - gtk_mods: gdk.ModifierType, - self: *Window, -) callconv(.c) c_int { - // We only process window-level events currently for the tab - // overview. This is primarily defensive programming because - // I'm not 100% certain how our logic below will interact with - // other parts of the application but I know for sure we must - // handle this during the tab overview. - // - // If someone can confidently show or explain that this is not - // necessary, please remove this check. - if (adw_version.supportsTabOverview()) { - if (self.tab_overview) |tab_overview| { - if (tab_overview.getOpen() == 0) return 0; - } - } - - const surface = self.app.core_app.focusedSurface() orelse return 0; - return if (surface.rt_surface.keyEvent( - .press, - ec_key, - keyval, - keycode, - gtk_mods, - )) 1 else 0; -} - -fn gtkActionAbout( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - const name = "Ghostty"; - const icon = "com.mitchellh.ghostty"; - const website = "https://ghostty.org"; - - if (adw_version.supportsDialogs()) { - adw.showAboutDialog( - self.window.as(gtk.Widget), - "application-name", - name, - "developer-name", - i18n._("Ghostty Developers"), - "application-icon", - icon, - "version", - build_config.version_string.ptr, - "issue-url", - "https://github.com/ghostty-org/ghostty/issues", - "website", - website, - @as(?*anyopaque, null), - ); - } else { - gtk.showAboutDialog( - self.window.as(gtk.Window), - "program-name", - name, - "logo-icon-name", - icon, - "title", - i18n._("About Ghostty"), - "version", - build_config.version_string.ptr, - "website", - website, - @as(?*anyopaque, null), - ); - } -} - -fn gtkActionClose( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.closeWithConfirmation(); -} - -fn gtkActionNewWindow( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_window = {} }); -} - -fn gtkActionNewTab( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_tab = {} }); -} - -fn gtkActionCloseTab( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .close_tab = .this }); -} - -fn gtkActionSplitRight( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_split = .right }); -} - -fn gtkActionSplitDown( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_split = .down }); -} - -fn gtkActionSplitLeft( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_split = .left }); -} - -fn gtkActionSplitUp( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .new_split = .up }); -} - -fn gtkActionToggleInspector( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .inspector = .toggle }); -} - -fn gtkActionToggleCommandPalette( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.toggle_command_palette); -} - -fn gtkActionCopy( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .copy_to_clipboard = {} }); -} - -fn gtkActionPaste( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .paste_from_clipboard = {} }); -} - -fn gtkActionReset( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .reset = {} }); -} - -fn gtkActionClear( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .clear_screen = {} }); -} - -fn gtkActionPromptTitle( - _: *gio.SimpleAction, - _: ?*glib.Variant, - self: *Window, -) callconv(.c) void { - self.performBindingAction(.{ .prompt_surface_title = {} }); -} - -/// Returns the surface to use for an action. -pub fn actionSurface(self: *Window) ?*CoreSurface { - const tab = self.notebook.currentTab() orelse return null; - const surface = tab.focus_child orelse return null; - return &surface.core_surface; -} - -fn gtkTitlebarMenuActivate( - btn: *gtk.MenuButton, - _: *gobject.ParamSpec, - self: *Window, -) callconv(.c) void { - // debian 12 is stuck on GTK 4.8 - if (!gtk_version.atLeast(4, 10, 0)) return; - const active = btn.getActive() != 0; - if (active) { - self.titlebar_menu.refresh(); - } else { - self.focusCurrentTab(); - } -} diff --git a/src/apprt/gtk/adw_version.zig b/src/apprt/gtk/adw_version.zig deleted file mode 100644 index 7ce88f585..000000000 --- a/src/apprt/gtk/adw_version.zig +++ /dev/null @@ -1,122 +0,0 @@ -const std = @import("std"); - -// Until the gobject bindings are built at the same time we are building -// Ghostty, we need to import `adwaita.h` directly to ensure that the version -// macros match the version of `libadwaita` that we are building/linking -// against. -const c = @cImport({ - @cInclude("adwaita.h"); -}); - -const adw = @import("adw"); - -const log = std.log.scoped(.gtk); - -pub const comptime_version: std.SemanticVersion = .{ - .major = c.ADW_MAJOR_VERSION, - .minor = c.ADW_MINOR_VERSION, - .patch = c.ADW_MICRO_VERSION, -}; - -pub fn getRuntimeVersion() std.SemanticVersion { - return .{ - .major = adw.getMajorVersion(), - .minor = adw.getMinorVersion(), - .patch = adw.getMicroVersion(), - }; -} - -pub fn logVersion() void { - log.info("libadwaita version build={} runtime={}", .{ - comptime_version, - getRuntimeVersion(), - }); -} - -/// Verifies that the running libadwaita version is at least the given -/// version. This will return false if Ghostty is configured to not build with -/// libadwaita. -/// -/// This can be run in both a comptime and runtime context. If it is run in a -/// comptime context, it will only check the version in the headers. If it is -/// run in a runtime context, it will check the actual version of the library we -/// are linked against. So generally you probably want to do both checks! -/// -/// This is inlined so that the comptime checks will disable the runtime checks -/// if the comptime checks fail. -pub inline fn atLeast( - comptime major: u16, - comptime minor: u16, - comptime micro: u16, -) bool { - // If our header has lower versions than the given version, we can return - // false immediately. This prevents us from compiling against unknown - // symbols and makes runtime checks very slightly faster. - if (comptime comptime_version.order(.{ - .major = major, - .minor = minor, - .patch = micro, - }) == .lt) return false; - - // If we're in comptime then we can't check the runtime version. - if (@inComptime()) return true; - - return runtimeAtLeast(major, minor, micro); -} - -/// Verifies that the libadwaita version at runtime is at least the given version. -/// -/// This function should be used in cases where the only the runtime behavior -/// is affected by the version check. For checks which would affect code -/// generation, use `atLeast`. -pub inline fn runtimeAtLeast( - comptime major: u16, - comptime minor: u16, - comptime micro: u16, -) bool { - // We use the functions instead of the constants such as c.GTK_MINOR_VERSION - // because the function gets the actual runtime version. - const runtime_version = getRuntimeVersion(); - return runtime_version.order(.{ - .major = major, - .minor = minor, - .patch = micro, - }) != .lt; -} - -test "versionAtLeast" { - const testing = std.testing; - - const funs = &.{ atLeast, runtimeAtLeast }; - inline for (funs) |fun| { - try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); - try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); - try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); - try testing.expect(!fun(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); - try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); - try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); - try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); - try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1)); - } -} - -// Whether AdwDialog, AdwAlertDialog, etc. are supported (1.5+) -pub inline fn supportsDialogs() bool { - return atLeast(1, 5, 0); -} - -pub inline fn supportsTabOverview() bool { - return atLeast(1, 4, 0); -} - -pub inline fn supportsSwitchRow() bool { - return atLeast(1, 4, 0); -} - -pub inline fn supportsToolbarView() bool { - return atLeast(1, 4, 0); -} - -pub inline fn supportsBanner() bool { - return atLeast(1, 3, 0); -} diff --git a/src/apprt/gtk/blueprint_compiler.zig b/src/apprt/gtk/blueprint_compiler.zig deleted file mode 100644 index 9bc515655..000000000 --- a/src/apprt/gtk/blueprint_compiler.zig +++ /dev/null @@ -1,160 +0,0 @@ -const std = @import("std"); - -pub const c = @cImport({ - @cInclude("adwaita.h"); -}); - -const adwaita_version = std.SemanticVersion{ - .major = c.ADW_MAJOR_VERSION, - .minor = c.ADW_MINOR_VERSION, - .patch = c.ADW_MICRO_VERSION, -}; -const required_blueprint_version = std.SemanticVersion{ - .major = 0, - .minor = 16, - .patch = 0, -}; - -pub fn main() !void { - var debug_allocator: std.heap.DebugAllocator(.{}) = .init; - defer _ = debug_allocator.deinit(); - const alloc = debug_allocator.allocator(); - - var it = try std.process.argsWithAllocator(alloc); - defer it.deinit(); - - _ = it.next(); - - const required_adwaita_version = std.SemanticVersion{ - .major = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMajorVersion, 10), - .minor = try std.fmt.parseUnsigned(u8, it.next() orelse return error.NoMinorVersion, 10), - .patch = 0, - }; - const output = it.next() orelse return error.NoOutput; - const input = it.next() orelse return error.NoInput; - - if (adwaita_version.order(required_adwaita_version) == .lt) { - std.debug.print( - \\`libadwaita` is too old. - \\ - \\Ghostty requires a version {} or newer of `libadwaita` to - \\compile this blueprint. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - , .{required_adwaita_version}); - std.posix.exit(1); - } - - { - var stdout: std.ArrayListUnmanaged(u8) = .empty; - defer stdout.deinit(alloc); - var stderr: std.ArrayListUnmanaged(u8) = .empty; - defer stderr.deinit(alloc); - - var blueprint_compiler = std.process.Child.init( - &.{ - "blueprint-compiler", - "--version", - }, - alloc, - ); - blueprint_compiler.stdout_behavior = .Pipe; - blueprint_compiler.stderr_behavior = .Pipe; - try blueprint_compiler.spawn(); - try blueprint_compiler.collectOutput( - alloc, - &stdout, - &stderr, - std.math.maxInt(u16), - ); - const term = blueprint_compiler.wait() catch |err| switch (err) { - error.FileNotFound => { - std.debug.print( - \\`blueprint-compiler` not found. - \\ - \\Ghostty requires version {} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); - std.posix.exit(1); - }, - else => return err, - }; - switch (term) { - .Exited => |rc| { - if (rc != 0) std.process.exit(1); - }, - else => std.process.exit(1), - } - - const version = try std.SemanticVersion.parse(std.mem.trim(u8, stdout.items, &std.ascii.whitespace)); - if (version.order(required_blueprint_version) == .lt) { - std.debug.print( - \\`blueprint-compiler` is the wrong version. - \\ - \\Ghostty requires version {} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); - std.posix.exit(1); - } - } - - { - var stdout: std.ArrayListUnmanaged(u8) = .empty; - defer stdout.deinit(alloc); - var stderr: std.ArrayListUnmanaged(u8) = .empty; - defer stderr.deinit(alloc); - - var blueprint_compiler = std.process.Child.init( - &.{ - "blueprint-compiler", - "compile", - "--output", - output, - input, - }, - alloc, - ); - blueprint_compiler.stdout_behavior = .Pipe; - blueprint_compiler.stderr_behavior = .Pipe; - try blueprint_compiler.spawn(); - try blueprint_compiler.collectOutput( - alloc, - &stdout, - &stderr, - std.math.maxInt(u16), - ); - const term = blueprint_compiler.wait() catch |err| switch (err) { - error.FileNotFound => { - std.debug.print( - \\`blueprint-compiler` not found. - \\ - \\Ghostty requires version {} or newer of - \\`blueprint-compiler` as a build-time dependency starting - \\from version 1.2. Please install it, ensure that it is - \\available on your PATH, and then retry building Ghostty. - \\ - , .{required_blueprint_version}); - std.posix.exit(1); - }, - else => return err, - }; - - switch (term) { - .Exited => |rc| { - if (rc != 0) { - std.debug.print("{s}", .{stderr.items}); - std.process.exit(1); - } - }, - else => { - std.debug.print("{s}", .{stderr.items}); - std.process.exit(1); - }, - } - } -} diff --git a/src/apprt/gtk/cgroup.zig b/src/apprt/gtk/cgroup.zig deleted file mode 100644 index 2f5104d09..000000000 --- a/src/apprt/gtk/cgroup.zig +++ /dev/null @@ -1,205 +0,0 @@ -/// Contains all the logic for putting the Ghostty process and -/// each individual surface into its own cgroup. -const std = @import("std"); -const assert = std.debug.assert; - -const gio = @import("gio"); -const glib = @import("glib"); -const gobject = @import("gobject"); - -const Allocator = std.mem.Allocator; -const App = @import("App.zig"); -const internal_os = @import("../../os/main.zig"); - -const log = std.log.scoped(.gtk_systemd_cgroup); - -/// Initialize the cgroup for the app. This will create our -/// transient scope, initialize the cgroups we use for the app, -/// configure them, and return the cgroup path for the app. -pub fn init(app: *App) ![]const u8 { - const pid = std.os.linux.getpid(); - const alloc = app.core_app.alloc; - - // Get our initial cgroup. We need this so we can compare - // and detect when we've switched to our transient group. - const original = try internal_os.cgroup.current( - alloc, - pid, - ) orelse ""; - defer alloc.free(original); - - // Create our transient scope. If this succeeds then the unit - // was created, but we may not have moved into it yet, so we need - // to do a dumb busy loop to wait for the move to complete. - try createScope(app, pid); - const transient = transient: while (true) { - const current = try internal_os.cgroup.current( - alloc, - pid, - ) orelse ""; - if (!std.mem.eql(u8, original, current)) break :transient current; - alloc.free(current); - std.time.sleep(25 * std.time.ns_per_ms); - }; - errdefer alloc.free(transient); - log.info("transient scope created cgroup={s}", .{transient}); - - // Create the app cgroup and put ourselves in it. This is - // required because controllers can't be configured while a - // process is in a cgroup. - try internal_os.cgroup.create(transient, "app", pid); - - // Create a cgroup that will contain all our surfaces. We will - // enable the controllers and configure resource limits for surfaces - // only on this cgroup so that it doesn't affect our main app. - try internal_os.cgroup.create(transient, "surfaces", null); - const surfaces = try std.fmt.allocPrint(alloc, "{s}/surfaces", .{transient}); - defer alloc.free(surfaces); - - // Enable all of our cgroup controllers. If these fail then - // we just log. We can't reasonably undo what we've done above - // so we log the warning and still return the transient group. - // I don't know a scenario where this fails yet. - try enableControllers(alloc, transient); - try enableControllers(alloc, surfaces); - - // Configure the "high" memory limit. This limit is used instead - // of "max" because it's a soft limit that can be exceeded and - // can be monitored by things like systemd-oomd to kill if needed, - // versus an instant hard kill. - if (app.config.@"linux-cgroup-memory-limit") |limit| { - try internal_os.cgroup.configureLimit(surfaces, .{ - .memory_high = limit, - }); - } - - // Configure the "max" pids limit. This is a hard limit and cannot be - // exceeded. - if (app.config.@"linux-cgroup-processes-limit") |limit| { - try internal_os.cgroup.configureLimit(surfaces, .{ - .pids_max = limit, - }); - } - - return transient; -} - -/// Enable all the cgroup controllers for the given cgroup. -fn enableControllers(alloc: Allocator, cgroup: []const u8) !void { - const raw = try internal_os.cgroup.controllers(alloc, cgroup); - defer alloc.free(raw); - - // Build our string builder for enabling all controllers - var builder = std.ArrayList(u8).init(alloc); - defer builder.deinit(); - - // Controllers are space-separated - var it = std.mem.splitScalar(u8, raw, ' '); - while (it.next()) |controller| { - try builder.append('+'); - try builder.appendSlice(controller); - if (it.rest().len > 0) try builder.append(' '); - } - - // Enable them all - try internal_os.cgroup.configureControllers( - cgroup, - builder.items, - ); -} - -/// Create a transient systemd scope unit for the current process. -/// -/// On success this will return the name of the transient scope -/// cgroup prefix, allocated with the given allocator. -fn createScope(app: *App, pid_: std.os.linux.pid_t) !void { - const gio_app = app.app.as(gio.Application); - const connection = gio_app.getDbusConnection() orelse - return error.DbusConnectionRequired; - - const pid: u32 = @intCast(pid_); - - // The unit name needs to be unique. We use the pid for this. - var name_buf: [256]u8 = undefined; - const name = std.fmt.bufPrintZ( - &name_buf, - "app-ghostty-transient-{}.scope", - .{pid}, - ) catch unreachable; - - const builder_type = glib.VariantType.new("(ssa(sv)a(sa(sv)))"); - defer glib.free(builder_type); - - // Initialize our builder to build up our parameters - var builder: glib.VariantBuilder = undefined; - builder.init(builder_type); - - builder.add("s", name.ptr); - builder.add("s", "fail"); - - { - // Properties - const properties_type = glib.VariantType.new("a(sv)"); - defer glib.free(properties_type); - - builder.open(properties_type); - defer builder.close(); - - // https://www.freedesktop.org/software/systemd/man/latest/systemd-oomd.service.html - const pressure_value = glib.Variant.newString("kill"); - - builder.add("(sv)", "ManagedOOMMemoryPressure", pressure_value); - - // Delegate - const delegate_value = glib.Variant.newBoolean(1); - builder.add("(sv)", "Delegate", delegate_value); - - // Pid to move into the unit - const pids_value_type = glib.VariantType.new("u"); - defer glib.free(pids_value_type); - - const pids_value = glib.Variant.newFixedArray(pids_value_type, &pid, 1, @sizeOf(u32)); - - builder.add("(sv)", "PIDs", pids_value); - } - - { - // Aux - const aux_type = glib.VariantType.new("a(sa(sv))"); - defer glib.free(aux_type); - - builder.open(aux_type); - defer builder.close(); - } - - var err: ?*glib.Error = null; - defer if (err) |e| e.free(); - - const reply_type = glib.VariantType.new("(o)"); - defer glib.free(reply_type); - - const value = builder.end(); - - const reply = connection.callSync( - "org.freedesktop.systemd1", - "/org/freedesktop/systemd1", - "org.freedesktop.systemd1.Manager", - "StartTransientUnit", - value, - reply_type, - .{}, - -1, - null, - &err, - ) orelse { - if (err) |e| log.err( - "creating transient cgroup scope failed code={} err={s}", - .{ - e.f_code, - if (e.f_message) |msg| msg else "(no message)", - }, - ); - return error.DbusCallFailed; - }; - defer reply.unref(); -} diff --git a/src/apprt/gtk/flatpak.zig b/src/apprt/gtk/flatpak.zig deleted file mode 100644 index dc47c671b..000000000 --- a/src/apprt/gtk/flatpak.zig +++ /dev/null @@ -1,29 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const build_config = @import("../../build_config.zig"); -const internal_os = @import("../../os/main.zig"); -const glib = @import("glib"); - -pub fn resourcesDir(alloc: Allocator) !internal_os.ResourcesDir { - if (comptime build_config.flatpak) { - // Only consult Flatpak runtime data for host case. - if (internal_os.isFlatpak()) { - var result: internal_os.ResourcesDir = .{ - .app_path = try alloc.dupe(u8, "/app/share/ghostty"), - }; - errdefer alloc.free(result.app_path.?); - - const keyfile = glib.KeyFile.new(); - defer keyfile.unref(); - - if (keyfile.loadFromFile("/.flatpak-info", .{}, null) == 0) return result; - const app_dir = std.mem.span(keyfile.getString("Instance", "app-path", null)) orelse return result; - defer glib.free(app_dir.ptr); - - result.host_path = try std.fs.path.join(alloc, &[_][]const u8{ app_dir, "share", "ghostty" }); - return result; - } - } - - return try internal_os.resourcesDir(alloc); -} diff --git a/src/apprt/gtk/gresource.zig b/src/apprt/gtk/gresource.zig deleted file mode 100644 index 4a2e42085..000000000 --- a/src/apprt/gtk/gresource.zig +++ /dev/null @@ -1,168 +0,0 @@ -const std = @import("std"); - -const css_files = [_][]const u8{ - "style.css", - "style-dark.css", - "style-hc.css", - "style-hc-dark.css", -}; - -const icons = [_]struct { - alias: []const u8, - source: []const u8, -}{ - .{ - .alias = "16x16", - .source = "16", - }, - .{ - .alias = "16x16@2", - .source = "32", - }, - .{ - .alias = "32x32", - .source = "32", - }, - .{ - .alias = "32x32@2", - .source = "64", - }, - .{ - .alias = "128x128", - .source = "128", - }, - .{ - .alias = "128x128@2", - .source = "256", - }, - .{ - .alias = "256x256", - .source = "256", - }, - .{ - .alias = "256x256@2", - .source = "512", - }, - .{ - .alias = "512x512", - .source = "512", - }, - .{ - .alias = "1024x1024", - .source = "1024", - }, -}; - -pub const VersionedBlueprint = struct { - major: u16, - minor: u16, - name: []const u8, -}; - -pub const blueprint_files = [_]VersionedBlueprint{ - .{ .major = 1, .minor = 5, .name = "prompt-title-dialog" }, - .{ .major = 1, .minor = 5, .name = "config-errors-dialog" }, - .{ .major = 1, .minor = 0, .name = "menu-headerbar-split_menu" }, - .{ .major = 1, .minor = 5, .name = "command-palette" }, - .{ .major = 1, .minor = 0, .name = "menu-surface-context_menu" }, - .{ .major = 1, .minor = 0, .name = "menu-window-titlebar_menu" }, - .{ .major = 1, .minor = 5, .name = "ccw-osc-52-read" }, - .{ .major = 1, .minor = 5, .name = "ccw-osc-52-write" }, - .{ .major = 1, .minor = 5, .name = "ccw-paste" }, - .{ .major = 1, .minor = 2, .name = "config-errors-dialog" }, - .{ .major = 1, .minor = 2, .name = "ccw-osc-52-read" }, - .{ .major = 1, .minor = 2, .name = "ccw-osc-52-write" }, - .{ .major = 1, .minor = 2, .name = "ccw-paste" }, -}; - -pub fn main() !void { - var debug_allocator: std.heap.DebugAllocator(.{}) = .init; - defer _ = debug_allocator.deinit(); - const alloc = debug_allocator.allocator(); - - var extra_ui_files: std.ArrayListUnmanaged([]const u8) = .empty; - defer { - for (extra_ui_files.items) |item| alloc.free(item); - extra_ui_files.deinit(alloc); - } - - var it = try std.process.argsWithAllocator(alloc); - defer it.deinit(); - - while (it.next()) |argument| { - if (std.mem.eql(u8, std.fs.path.extension(argument), ".ui")) { - try extra_ui_files.append(alloc, try alloc.dupe(u8, argument)); - } - } - - const writer = std.io.getStdOut().writer(); - - try writer.writeAll( - \\ - \\ - \\ - \\ - ); - for (css_files) |css_file| { - try writer.print( - " src/apprt/gtk/{s}\n", - .{ css_file, css_file }, - ); - } - try writer.writeAll( - \\ - \\ - \\ - ); - for (icons) |icon| { - try writer.print( - " images/gnome/{s}.png\n", - .{ icon.alias, icon.source }, - ); - } - try writer.writeAll( - \\ - \\ - \\ - ); - for (extra_ui_files.items) |ui_file| { - for (blueprint_files) |file| { - const expected = try std.fmt.allocPrint(alloc, "/{d}.{d}/{s}.ui", .{ file.major, file.minor, file.name }); - defer alloc.free(expected); - if (!std.mem.endsWith(u8, ui_file, expected)) continue; - try writer.print( - " {s}\n", - .{ file.major, file.minor, file.name, ui_file }, - ); - break; - } else return error.BlueprintNotFound; - } - try writer.writeAll( - \\ - \\ - \\ - ); -} - -pub const dependencies = deps: { - const total = css_files.len + icons.len + blueprint_files.len; - var deps: [total][]const u8 = undefined; - var index: usize = 0; - for (css_files) |css_file| { - deps[index] = std.fmt.comptimePrint("src/apprt/gtk/{s}", .{css_file}); - index += 1; - } - for (icons) |icon| { - deps[index] = std.fmt.comptimePrint("images/gnome/{s}.png", .{icon.source}); - index += 1; - } - for (blueprint_files) |blueprint_file| { - deps[index] = std.fmt.comptimePrint("src/apprt/gtk/ui/{d}.{d}/{s}.blp", .{ - blueprint_file.major, - blueprint_file.minor, - blueprint_file.name, - }); - index += 1; - } - break :deps deps; -}; diff --git a/src/apprt/gtk/gtk_version.zig b/src/apprt/gtk/gtk_version.zig deleted file mode 100644 index 6f3d733a5..000000000 --- a/src/apprt/gtk/gtk_version.zig +++ /dev/null @@ -1,140 +0,0 @@ -const std = @import("std"); - -// Until the gobject bindings are built at the same time we are building -// Ghostty, we need to import `gtk/gtk.h` directly to ensure that the version -// macros match the version of `gtk4` that we are building/linking against. -const c = @cImport({ - @cInclude("gtk/gtk.h"); -}); - -const gtk = @import("gtk"); - -const log = std.log.scoped(.gtk); - -pub const comptime_version: std.SemanticVersion = .{ - .major = c.GTK_MAJOR_VERSION, - .minor = c.GTK_MINOR_VERSION, - .patch = c.GTK_MICRO_VERSION, -}; - -pub fn getRuntimeVersion() std.SemanticVersion { - return .{ - .major = gtk.getMajorVersion(), - .minor = gtk.getMinorVersion(), - .patch = gtk.getMicroVersion(), - }; -} - -pub fn logVersion() void { - log.info("GTK version build={} runtime={}", .{ - comptime_version, - getRuntimeVersion(), - }); -} - -/// Verifies that the GTK version is at least the given version. -/// -/// This can be run in both a comptime and runtime context. If it is run in a -/// comptime context, it will only check the version in the headers. If it is -/// run in a runtime context, it will check the actual version of the library we -/// are linked against. -/// -/// This function should be used in cases where the version check would affect -/// code generation, such as using symbols that are only available beyond a -/// certain version. For checks which only depend on GTK's runtime behavior, -/// use `runtimeAtLeast`. -/// -/// This is inlined so that the comptime checks will disable the runtime checks -/// if the comptime checks fail. -pub inline fn atLeast( - comptime major: u16, - comptime minor: u16, - comptime micro: u16, -) bool { - // If our header has lower versions than the given version, - // we can return false immediately. This prevents us from - // compiling against unknown symbols and makes runtime checks - // very slightly faster. - if (comptime comptime_version.order(.{ - .major = major, - .minor = minor, - .patch = micro, - }) == .lt) return false; - - // If we're in comptime then we can't check the runtime version. - if (@inComptime()) return true; - - return runtimeAtLeast(major, minor, micro); -} - -/// Verifies that the GTK version at runtime is at least the given version. -/// -/// This function should be used in cases where the only the runtime behavior -/// is affected by the version check. For checks which would affect code -/// generation, use `atLeast`. -pub inline fn runtimeAtLeast( - comptime major: u16, - comptime minor: u16, - comptime micro: u16, -) bool { - // We use the functions instead of the constants such as c.GTK_MINOR_VERSION - // because the function gets the actual runtime version. - const runtime_version = getRuntimeVersion(); - return runtime_version.order(.{ - .major = major, - .minor = minor, - .patch = micro, - }) != .lt; -} - -pub inline fn runtimeUntil( - comptime major: u16, - comptime minor: u16, - comptime micro: u16, -) bool { - const runtime_version = getRuntimeVersion(); - return runtime_version.order(.{ - .major = major, - .minor = minor, - .patch = micro, - }) == .lt; -} - -test "atLeast" { - const testing = std.testing; - - const funs = &.{ atLeast, runtimeAtLeast }; - inline for (funs) |fun| { - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - - try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); - } -} - -test "runtimeUntil" { - const testing = std.testing; - - // This is an array in case we add a comptime variant. - const funs = &.{runtimeUntil}; - inline for (funs) |fun| { - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - - try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); - } -} diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig deleted file mode 100644 index 03c1b427b..000000000 --- a/src/apprt/gtk/headerbar.zig +++ /dev/null @@ -1,54 +0,0 @@ -const HeaderBar = @This(); - -const std = @import("std"); - -const adw = @import("adw"); -const gtk = @import("gtk"); - -const Window = @import("Window.zig"); - -/// the Adwaita headerbar widget -headerbar: *adw.HeaderBar, - -/// the Window that we belong to -window: *Window, - -/// the Adwaita window title widget -title: *adw.WindowTitle, - -pub fn init(self: *HeaderBar, window: *Window) void { - self.* = .{ - .headerbar = adw.HeaderBar.new(), - .window = window, - .title = adw.WindowTitle.new( - window.window.as(gtk.Window).getTitle() orelse "Ghostty", - "", - ), - }; - self.headerbar.setTitleWidget(self.title.as(gtk.Widget)); -} - -pub fn setVisible(self: *const HeaderBar, visible: bool) void { - self.headerbar.as(gtk.Widget).setVisible(@intFromBool(visible)); -} - -pub fn asWidget(self: *const HeaderBar) *gtk.Widget { - return self.headerbar.as(gtk.Widget); -} - -pub fn packEnd(self: *const HeaderBar, widget: *gtk.Widget) void { - self.headerbar.packEnd(widget); -} - -pub fn packStart(self: *const HeaderBar, widget: *gtk.Widget) void { - self.headerbar.packStart(widget); -} - -pub fn setTitle(self: *const HeaderBar, title: [:0]const u8) void { - self.window.window.as(gtk.Window).setTitle(title); - self.title.setTitle(title); -} - -pub fn setSubtitle(self: *const HeaderBar, subtitle: [:0]const u8) void { - self.title.setSubtitle(subtitle); -} diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig deleted file mode 100644 index 3adeb9711..000000000 --- a/src/apprt/gtk/inspector.zig +++ /dev/null @@ -1,184 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -const gtk = @import("gtk"); - -const build_config = @import("../../build_config.zig"); -const i18n = @import("../../os/main.zig").i18n; -const App = @import("App.zig"); -const Surface = @import("Surface.zig"); -const TerminalWindow = @import("Window.zig"); -const ImguiWidget = @import("ImguiWidget.zig"); -const CoreInspector = @import("../../inspector/main.zig").Inspector; - -const log = std.log.scoped(.inspector); - -/// Inspector is the primary stateful object that represents a terminal -/// inspector. An inspector is 1:1 with a Surface and is owned by a Surface. -/// Closing a surface must close its inspector. -pub const Inspector = struct { - /// The surface that owns this inspector. - surface: *Surface, - - /// The current state of where this inspector is rendered. The Inspector - /// is the state of the inspector but this is the state of the GUI. - location: LocationState, - - /// This is true if we want to destroy this inspector as soon as the - /// location is closed. For example: set this to true, request the - /// window be closed, let GTK do its cleanup, then note this to destroy - /// the inner state. - destroy_on_close: bool = true, - - /// Location where the inspector will be launched. - pub const Location = union(LocationKey) { - hidden: void, - window: void, - }; - - /// The internal state for each possible location. - const LocationState = union(LocationKey) { - hidden: void, - window: Window, - }; - - const LocationKey = enum { - /// No GUI, but load the inspector state. - hidden, - - /// A dedicated window for the inspector. - window, - }; - - /// Create an inspector for the given surface in the given location. - pub fn create(surface: *Surface, location: Location) !*Inspector { - const alloc = surface.app.core_app.alloc; - var ptr = try alloc.create(Inspector); - errdefer alloc.destroy(ptr); - try ptr.init(surface, location); - return ptr; - } - - /// Destroy all memory associated with this inspector. You generally - /// should NOT call this publicly and should call `close` instead to - /// use the GTK lifecycle. - pub fn destroy(self: *Inspector) void { - assert(self.location == .hidden); - const alloc = self.allocator(); - self.surface.inspector = null; - self.deinit(); - alloc.destroy(self); - } - - fn init(self: *Inspector, surface: *Surface, location: Location) !void { - self.* = .{ - .surface = surface, - .location = undefined, - }; - - // Activate the inspector. If it doesn't work we ignore the error - // because we can just show an error in the inspector window. - self.surface.core_surface.activateInspector() catch |err| { - log.err("failed to activate inspector err={}", .{err}); - }; - - switch (location) { - .hidden => self.location = .{ .hidden = {} }, - .window => try self.initWindow(), - } - } - - fn deinit(self: *Inspector) void { - self.surface.core_surface.deactivateInspector(); - } - - /// Request the inspector is closed. - pub fn close(self: *Inspector) void { - switch (self.location) { - .hidden => self.locationDidClose(), - .window => |v| v.close(), - } - } - - fn locationDidClose(self: *Inspector) void { - self.location = .{ .hidden = {} }; - if (self.destroy_on_close) self.destroy(); - } - - pub fn queueRender(self: *const Inspector) void { - switch (self.location) { - .hidden => {}, - .window => |v| v.imgui_widget.queueRender(), - } - } - - fn allocator(self: *const Inspector) Allocator { - return self.surface.app.core_app.alloc; - } - - fn initWindow(self: *Inspector) !void { - self.location = .{ .window = undefined }; - try self.location.window.init(self); - } -}; - -/// A dedicated window to hold an inspector instance. -const Window = struct { - inspector: *Inspector, - window: *gtk.ApplicationWindow, - imgui_widget: ImguiWidget, - - pub fn init(self: *Window, inspector: *Inspector) !void { - // Initialize to undefined - self.* = .{ - .inspector = inspector, - .window = undefined, - .imgui_widget = undefined, - }; - - // Create the window - self.window = .new(inspector.surface.app.app.as(gtk.Application)); - errdefer self.window.as(gtk.Window).destroy(); - - self.window.as(gtk.Window).setTitle(i18n._("Ghostty: Terminal Inspector")); - self.window.as(gtk.Window).setDefaultSize(1000, 600); - self.window.as(gtk.Window).setIconName(build_config.bundle_id); - self.window.as(gtk.Widget).addCssClass("window"); - self.window.as(gtk.Widget).addCssClass("inspector-window"); - - // Initialize our imgui widget - try self.imgui_widget.init(); - errdefer self.imgui_widget.deinit(); - self.imgui_widget.render_callback = &imguiRender; - self.imgui_widget.render_userdata = self; - CoreInspector.setup(); - - // Signals - _ = gtk.Widget.signals.destroy.connect(self.window, *Window, gtkDestroy, self, .{}); - // Show the window - self.window.as(gtk.Window).setChild(self.imgui_widget.gl_area.as(gtk.Widget)); - self.window.as(gtk.Window).present(); - } - - pub fn deinit(self: *Window) void { - self.inspector.locationDidClose(); - } - - pub fn close(self: *const Window) void { - self.window.as(gtk.Window).destroy(); - } - - fn imguiRender(ud: ?*anyopaque) void { - const self: *Window = @ptrCast(@alignCast(ud orelse return)); - const surface = &self.inspector.surface.core_surface; - const inspector = surface.inspector orelse return; - inspector.render(); - } - - /// "destroy" signal for the window - fn gtkDestroy(_: *gtk.ApplicationWindow, self: *Window) callconv(.c) void { - log.debug("window destroy", .{}); - self.deinit(); - } -}; diff --git a/src/apprt/gtk/ipc.zig b/src/apprt/gtk/ipc.zig deleted file mode 100644 index 7c2dc3887..000000000 --- a/src/apprt/gtk/ipc.zig +++ /dev/null @@ -1 +0,0 @@ -pub const openNewWindow = @import("ipc/new_window.zig").openNewWindow; diff --git a/src/apprt/gtk/ipc/new_window.zig b/src/apprt/gtk/ipc/new_window.zig deleted file mode 100644 index 1c29ebd3f..000000000 --- a/src/apprt/gtk/ipc/new_window.zig +++ /dev/null @@ -1,172 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const Allocator = std.mem.Allocator; - -const gio = @import("gio"); -const glib = @import("glib"); -const apprt = @import("../../../apprt.zig"); - -// Use a D-Bus method call to open a new window on GTK. -// See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI -// -// `ghostty +new-window` is equivalent to the following command (on a release build): -// -// ``` -// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window [] [] -// ``` -// -// `ghostty +new-window -e echo hello` would be equivalent to the following command (on a release build): -// -// ``` -// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-window-command '[<@as ["echo" "hello"]>]' [] -// ``` -pub fn openNewWindow(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewWindow) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { - const stderr = std.io.getStdErr().writer(); - - // Get the appropriate bus name and object path for contacting the - // Ghostty instance we're interested in. - const bus_name: [:0]const u8, const object_path: [:0]const u8 = switch (target) { - .class => |class| result: { - // Force the usage of the class specified on the CLI to determine the - // bus name and object path. - const object_path = try std.fmt.allocPrintZ(alloc, "/{s}", .{class}); - - std.mem.replaceScalar(u8, object_path, '.', '/'); - std.mem.replaceScalar(u8, object_path, '-', '_'); - - break :result .{ class, object_path }; - }, - .detect => switch (builtin.mode) { - .Debug, .ReleaseSafe => .{ "com.mitchellh.ghostty-debug", "/com/mitchellh/ghostty_debug" }, - .ReleaseFast, .ReleaseSmall => .{ "com.mitchellh.ghostty", "/com/mitchellh/ghostty" }, - }, - }; - defer { - switch (target) { - .class => alloc.free(object_path), - .detect => {}, - } - } - - if (gio.Application.idIsValid(bus_name.ptr) == 0) { - try stderr.print("D-Bus bus name is not valid: {s}\n", .{bus_name}); - return error.IPCFailed; - } - - if (glib.Variant.isObjectPath(object_path.ptr) == 0) { - try stderr.print("D-Bus object path is not valid: {s}\n", .{object_path}); - return error.IPCFailed; - } - - const dbus = dbus: { - var err_: ?*glib.Error = null; - defer if (err_) |err| err.free(); - - const dbus_ = gio.busGetSync(.session, null, &err_); - if (err_) |err| { - try stderr.print( - "Unable to establish connection to D-Bus session bus: {s}\n", - .{err.f_message orelse "(unknown)"}, - ); - return error.IPCFailed; - } - - break :dbus dbus_ orelse { - try stderr.print("gio.busGetSync returned null\n", .{}); - return error.IPCFailed; - }; - }; - defer dbus.unref(); - - // use a builder to create the D-Bus method call payload - const payload = payload: { - const builder_type = glib.VariantType.new("(sava{sv})"); - defer glib.free(builder_type); - - // Initialize our builder to build up our parameters - var builder: glib.VariantBuilder = undefined; - builder.init(builder_type); - errdefer builder.clear(); - - // action - if (value.arguments == null) { - builder.add("s", "new-window"); - } else { - builder.add("s", "new-window-command"); - } - - // parameters - { - const av = glib.VariantType.new("av"); - defer av.free(); - - var parameters: glib.VariantBuilder = undefined; - parameters.init(av); - errdefer parameters.clear(); - - if (value.arguments) |arguments| { - // If `-e` was specified on the command line, the first - // parameter is an array of strings that contain the arguments - // that came after `-e`, which will be interpreted as a command - // to run. - { - const as = glib.VariantType.new("as"); - defer as.free(); - - var command: glib.VariantBuilder = undefined; - command.init(as); - errdefer command.clear(); - - for (arguments) |argument| { - command.add("s", argument.ptr); - } - - parameters.add("v", command.end()); - } - } - - builder.addValue(parameters.end()); - } - - { - const platform_data = glib.VariantType.new("a{sv}"); - defer platform_data.free(); - - builder.open(platform_data); - defer builder.close(); - - // we have no platform data - } - - break :payload builder.end(); - }; - - { - var err_: ?*glib.Error = null; - defer if (err_) |err| err.free(); - - const result_ = dbus.callSync( - bus_name, - object_path, - "org.gtk.Actions", - "Activate", - payload, - null, // We don't care about the return type, we don't do anything with it. - .{}, // no flags - -1, // default timeout - null, // not cancellable - &err_, - ); - defer if (result_) |result| result.unref(); - - if (err_) |err| { - try stderr.print( - "D-Bus method call returned an error err={s}\n", - .{err.f_message orelse "(unknown)"}, - ); - return error.IPCFailed; - } - } - - return true; -} diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig deleted file mode 100644 index fc3296366..000000000 --- a/src/apprt/gtk/key.zig +++ /dev/null @@ -1,405 +0,0 @@ -const std = @import("std"); -const build_options = @import("build_options"); - -const gdk = @import("gdk"); -const glib = @import("glib"); -const gtk = @import("gtk"); - -const input = @import("../../input.zig"); -const winproto = @import("winproto.zig"); - -/// Returns a GTK accelerator string from a trigger. -pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { - var buf_stream = std.io.fixedBufferStream(buf); - const writer = buf_stream.writer(); - - // Modifiers - if (trigger.mods.shift) try writer.writeAll(""); - if (trigger.mods.ctrl) try writer.writeAll(""); - if (trigger.mods.alt) try writer.writeAll(""); - if (trigger.mods.super) try writer.writeAll(""); - - // Write our key - if (!try writeTriggerKey(writer, trigger)) return null; - - // We need to make the string null terminated. - try writer.writeByte(0); - const slice = buf_stream.getWritten(); - return slice[0 .. slice.len - 1 :0]; -} - -/// Returns a XDG-compliant shortcuts string from a trigger. -/// Spec: https://specifications.freedesktop.org/shortcuts-spec/latest/ -pub fn xdgShortcutFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { - var buf_stream = std.io.fixedBufferStream(buf); - const writer = buf_stream.writer(); - - // Modifiers - if (trigger.mods.shift) try writer.writeAll("SHIFT+"); - if (trigger.mods.ctrl) try writer.writeAll("CTRL+"); - if (trigger.mods.alt) try writer.writeAll("ALT+"); - if (trigger.mods.super) try writer.writeAll("LOGO+"); - - // Write our key - // NOTE: While the spec specifies that only libxkbcommon keysyms are - // expected, using GTK's keysyms should still work as they are identical - // to *X11's* keysyms (which I assume is a subset of libxkbcommon's). - // I haven't been able to any evidence to back up that assumption but - // this works for now - if (!try writeTriggerKey(writer, trigger)) return null; - - // We need to make the string null terminated. - try writer.writeByte(0); - const slice = buf_stream.getWritten(); - return slice[0 .. slice.len - 1 :0]; -} - -fn writeTriggerKey(writer: anytype, trigger: input.Binding.Trigger) !bool { - switch (trigger.key) { - .physical => |k| { - const keyval = keyvalFromKey(k) orelse return false; - try writer.writeAll(std.mem.span(gdk.keyvalName(keyval) orelse return false)); - }, - - .unicode => |cp| { - if (gdk.keyvalName(cp)) |name| { - try writer.writeAll(std.mem.span(name)); - } else { - try writer.print("{u}", .{cp}); - } - }, - } - - return true; -} - -pub fn translateMods(state: gdk.ModifierType) input.Mods { - return .{ - .shift = state.shift_mask, - .ctrl = state.control_mask, - .alt = state.alt_mask, - .super = state.super_mask, - // Lock is dependent on the X settings but we just assume caps lock. - .caps_lock = state.lock_mask, - }; -} - -// Get the unshifted unicode value of the keyval. This is used -// by the Kitty keyboard protocol. -pub fn keyvalUnicodeUnshifted( - widget: *gtk.Widget, - event: *gdk.KeyEvent, - keycode: u32, -) u21 { - const display = widget.getDisplay(); - - // We need to get the currently active keyboard layout so we know - // what group to look at. - const layout = event.getLayout(); - - // Get all the possible keyboard mappings for this keycode. A keycode is the - // physical key pressed. - var keys: [*]gdk.KeymapKey = undefined; - var keyvals: [*]c_uint = undefined; - var n_entries: c_int = 0; - if (display.mapKeycode(keycode, &keys, &keyvals, &n_entries) == 0) return 0; - - defer glib.free(keys); - defer glib.free(keyvals); - - // debugging: - // std.log.debug("layout={}", .{layout}); - // for (0..@intCast(n_entries)) |i| { - // std.log.debug("keymap key={} codepoint={x}", .{ - // keys[i], - // gdk.keyvalToUnicode(keyvals[i]), - // }); - // } - - for (0..@intCast(n_entries)) |i| { - if (keys[i].f_group == layout and - keys[i].f_level == 0) - { - return std.math.cast( - u21, - gdk.keyvalToUnicode(keyvals[i]), - ) orelse 0; - } - } - - return 0; -} - -/// Returns the mods to use a key event from a GTK event. -/// This requires a lot of context because the GdkEvent -/// doesn't contain enough on its own. -pub fn eventMods( - event: *gdk.Event, - physical_key: input.Key, - gtk_mods: gdk.ModifierType, - action: input.Action, - app_winproto: *winproto.App, -) input.Mods { - const device = event.getDevice(); - - var mods = app_winproto.eventMods(device, gtk_mods); - mods.num_lock = if (device) |d| d.getNumLockState() != 0 else false; - - // We use the physical key to determine sided modifiers. As - // far as I can tell there's no other way to reliably determine - // this. - // - // We also set the main modifier to true if either side is true, - // since on both X11/Wayland, GTK doesn't set the main modifier - // if only the modifier key is pressed, but our core logic - // relies on it. - switch (physical_key) { - .shift_left => { - mods.shift = action != .release; - mods.sides.shift = .left; - }, - - .shift_right => { - mods.shift = action != .release; - mods.sides.shift = .right; - }, - - .control_left => { - mods.ctrl = action != .release; - mods.sides.ctrl = .left; - }, - - .control_right => { - mods.ctrl = action != .release; - mods.sides.ctrl = .right; - }, - - .alt_left => { - mods.alt = action != .release; - mods.sides.alt = .left; - }, - - .alt_right => { - mods.alt = action != .release; - mods.sides.alt = .right; - }, - - .meta_left => { - mods.super = action != .release; - mods.sides.super = .left; - }, - - .meta_right => { - mods.super = action != .release; - mods.sides.super = .right; - }, - - else => {}, - } - - return mods; -} - -/// Returns an input key from a keyval or null if we don't have a mapping. -pub fn keyFromKeyval(keyval: c_uint) ?input.Key { - for (keymap) |entry| { - if (entry[0] == keyval) return entry[1]; - } - - return null; -} - -/// Returns a keyval from an input key or null if we don't have a mapping. -pub fn keyvalFromKey(key: input.Key) ?c_uint { - switch (key) { - inline else => |key_comptime| { - return comptime value: { - @setEvalBranchQuota(50_000); - for (keymap) |entry| { - if (entry[1] == key_comptime) break :value entry[0]; - } - - break :value null; - }; - }, - } -} - -test "accelFromTrigger" { - const testing = std.testing; - var buf: [256]u8 = undefined; - - try testing.expectEqualStrings("q", (try accelFromTrigger(&buf, .{ - .mods = .{ .super = true }, - .key = .{ .unicode = 'q' }, - })).?); - - try testing.expectEqualStrings("backslash", (try accelFromTrigger(&buf, .{ - .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, - .key = .{ .unicode = 92 }, - })).?); -} - -test "xdgShortcutFromTrigger" { - const testing = std.testing; - var buf: [256]u8 = undefined; - - try testing.expectEqualStrings("LOGO+q", (try xdgShortcutFromTrigger(&buf, .{ - .mods = .{ .super = true }, - .key = .{ .unicode = 'q' }, - })).?); - - try testing.expectEqualStrings("SHIFT+CTRL+ALT+LOGO+backslash", (try xdgShortcutFromTrigger(&buf, .{ - .mods = .{ .ctrl = true, .alt = true, .super = true, .shift = true }, - .key = .{ .unicode = 92 }, - })).?); -} - -/// A raw entry in the keymap. Our keymap contains mappings between -/// GDK keys and our own key enum. -const RawEntry = struct { c_uint, input.Key }; - -const keymap: []const RawEntry = &.{ - .{ gdk.KEY_a, .key_a }, - .{ gdk.KEY_b, .key_b }, - .{ gdk.KEY_c, .key_c }, - .{ gdk.KEY_d, .key_d }, - .{ gdk.KEY_e, .key_e }, - .{ gdk.KEY_f, .key_f }, - .{ gdk.KEY_g, .key_g }, - .{ gdk.KEY_h, .key_h }, - .{ gdk.KEY_i, .key_i }, - .{ gdk.KEY_j, .key_j }, - .{ gdk.KEY_k, .key_k }, - .{ gdk.KEY_l, .key_l }, - .{ gdk.KEY_m, .key_m }, - .{ gdk.KEY_n, .key_n }, - .{ gdk.KEY_o, .key_o }, - .{ gdk.KEY_p, .key_p }, - .{ gdk.KEY_q, .key_q }, - .{ gdk.KEY_r, .key_r }, - .{ gdk.KEY_s, .key_s }, - .{ gdk.KEY_t, .key_t }, - .{ gdk.KEY_u, .key_u }, - .{ gdk.KEY_v, .key_v }, - .{ gdk.KEY_w, .key_w }, - .{ gdk.KEY_x, .key_x }, - .{ gdk.KEY_y, .key_y }, - .{ gdk.KEY_z, .key_z }, - - .{ gdk.KEY_0, .digit_0 }, - .{ gdk.KEY_1, .digit_1 }, - .{ gdk.KEY_2, .digit_2 }, - .{ gdk.KEY_3, .digit_3 }, - .{ gdk.KEY_4, .digit_4 }, - .{ gdk.KEY_5, .digit_5 }, - .{ gdk.KEY_6, .digit_6 }, - .{ gdk.KEY_7, .digit_7 }, - .{ gdk.KEY_8, .digit_8 }, - .{ gdk.KEY_9, .digit_9 }, - - .{ gdk.KEY_semicolon, .semicolon }, - .{ gdk.KEY_space, .space }, - .{ gdk.KEY_apostrophe, .quote }, - .{ gdk.KEY_comma, .comma }, - .{ gdk.KEY_grave, .backquote }, - .{ gdk.KEY_period, .period }, - .{ gdk.KEY_slash, .slash }, - .{ gdk.KEY_minus, .minus }, - .{ gdk.KEY_equal, .equal }, - .{ gdk.KEY_bracketleft, .bracket_left }, - .{ gdk.KEY_bracketright, .bracket_right }, - .{ gdk.KEY_backslash, .backslash }, - - .{ gdk.KEY_Up, .arrow_up }, - .{ gdk.KEY_Down, .arrow_down }, - .{ gdk.KEY_Right, .arrow_right }, - .{ gdk.KEY_Left, .arrow_left }, - .{ gdk.KEY_Home, .home }, - .{ gdk.KEY_End, .end }, - .{ gdk.KEY_Insert, .insert }, - .{ gdk.KEY_Delete, .delete }, - .{ gdk.KEY_Caps_Lock, .caps_lock }, - .{ gdk.KEY_Scroll_Lock, .scroll_lock }, - .{ gdk.KEY_Num_Lock, .num_lock }, - .{ gdk.KEY_Page_Up, .page_up }, - .{ gdk.KEY_Page_Down, .page_down }, - .{ gdk.KEY_Escape, .escape }, - .{ gdk.KEY_Return, .enter }, - .{ gdk.KEY_Tab, .tab }, - .{ gdk.KEY_BackSpace, .backspace }, - .{ gdk.KEY_Print, .print_screen }, - .{ gdk.KEY_Pause, .pause }, - - .{ gdk.KEY_F1, .f1 }, - .{ gdk.KEY_F2, .f2 }, - .{ gdk.KEY_F3, .f3 }, - .{ gdk.KEY_F4, .f4 }, - .{ gdk.KEY_F5, .f5 }, - .{ gdk.KEY_F6, .f6 }, - .{ gdk.KEY_F7, .f7 }, - .{ gdk.KEY_F8, .f8 }, - .{ gdk.KEY_F9, .f9 }, - .{ gdk.KEY_F10, .f10 }, - .{ gdk.KEY_F11, .f11 }, - .{ gdk.KEY_F12, .f12 }, - .{ gdk.KEY_F13, .f13 }, - .{ gdk.KEY_F14, .f14 }, - .{ gdk.KEY_F15, .f15 }, - .{ gdk.KEY_F16, .f16 }, - .{ gdk.KEY_F17, .f17 }, - .{ gdk.KEY_F18, .f18 }, - .{ gdk.KEY_F19, .f19 }, - .{ gdk.KEY_F20, .f20 }, - .{ gdk.KEY_F21, .f21 }, - .{ gdk.KEY_F22, .f22 }, - .{ gdk.KEY_F23, .f23 }, - .{ gdk.KEY_F24, .f24 }, - .{ gdk.KEY_F25, .f25 }, - - .{ gdk.KEY_KP_0, .numpad_0 }, - .{ gdk.KEY_KP_1, .numpad_1 }, - .{ gdk.KEY_KP_2, .numpad_2 }, - .{ gdk.KEY_KP_3, .numpad_3 }, - .{ gdk.KEY_KP_4, .numpad_4 }, - .{ gdk.KEY_KP_5, .numpad_5 }, - .{ gdk.KEY_KP_6, .numpad_6 }, - .{ gdk.KEY_KP_7, .numpad_7 }, - .{ gdk.KEY_KP_8, .numpad_8 }, - .{ gdk.KEY_KP_9, .numpad_9 }, - .{ gdk.KEY_KP_Decimal, .numpad_decimal }, - .{ gdk.KEY_KP_Divide, .numpad_divide }, - .{ gdk.KEY_KP_Multiply, .numpad_multiply }, - .{ gdk.KEY_KP_Subtract, .numpad_subtract }, - .{ gdk.KEY_KP_Add, .numpad_add }, - .{ gdk.KEY_KP_Enter, .numpad_enter }, - .{ gdk.KEY_KP_Equal, .numpad_equal }, - - .{ gdk.KEY_KP_Separator, .numpad_separator }, - .{ gdk.KEY_KP_Left, .numpad_left }, - .{ gdk.KEY_KP_Right, .numpad_right }, - .{ gdk.KEY_KP_Up, .numpad_up }, - .{ gdk.KEY_KP_Down, .numpad_down }, - .{ gdk.KEY_KP_Page_Up, .numpad_page_up }, - .{ gdk.KEY_KP_Page_Down, .numpad_page_down }, - .{ gdk.KEY_KP_Home, .numpad_home }, - .{ gdk.KEY_KP_End, .numpad_end }, - .{ gdk.KEY_KP_Insert, .numpad_insert }, - .{ gdk.KEY_KP_Delete, .numpad_delete }, - .{ gdk.KEY_KP_Begin, .numpad_begin }, - - .{ gdk.KEY_Copy, .copy }, - .{ gdk.KEY_Cut, .cut }, - .{ gdk.KEY_Paste, .paste }, - - .{ gdk.KEY_Shift_L, .shift_left }, - .{ gdk.KEY_Control_L, .control_left }, - .{ gdk.KEY_Alt_L, .alt_left }, - .{ gdk.KEY_Super_L, .meta_left }, - .{ gdk.KEY_Shift_R, .shift_right }, - .{ gdk.KEY_Control_R, .control_right }, - .{ gdk.KEY_Alt_R, .alt_right }, - .{ gdk.KEY_Super_R, .meta_right }, - - // TODO: media keys -}; diff --git a/src/apprt/gtk/menu.zig b/src/apprt/gtk/menu.zig deleted file mode 100644 index 50d0d1227..000000000 --- a/src/apprt/gtk/menu.zig +++ /dev/null @@ -1,139 +0,0 @@ -const std = @import("std"); - -const gtk = @import("gtk"); -const gdk = @import("gdk"); -const gio = @import("gio"); -const gobject = @import("gobject"); - -const apprt = @import("../../apprt.zig"); -const App = @import("App.zig"); -const Window = @import("Window.zig"); -const Surface = @import("Surface.zig"); -const Builder = @import("Builder.zig"); - -/// Abstract GTK menus to take advantage of machinery for buildtime/comptime -/// error checking. -pub fn Menu( - /// GTK apprt type that the menu is "for". Window and Surface are supported - /// right now. - comptime T: type, - /// Name of the menu. Along with the apprt type, this is used to look up the - /// builder ui definitions of the menu. - comptime menu_name: []const u8, - /// Should the popup have a pointer pointing to the location that it's - /// attached to. - comptime arrow: bool, -) type { - return struct { - const Self = @This(); - - /// parent apprt object - parent: *T, - - /// our widget - menu_widget: *gtk.PopoverMenu, - - /// initialize the menu - pub fn init(self: *Self, parent: *T) void { - const object_type = switch (T) { - Window => "window", - Surface => "surface", - else => unreachable, - }; - - var builder = Builder.init("menu-" ++ object_type ++ "-" ++ menu_name, 1, 0); - defer builder.deinit(); - - const menu_model = builder.getObject(gio.MenuModel, "menu").?; - - const menu_widget = gtk.PopoverMenu.newFromModelFull(menu_model, .{ .nested = true }); - - // If this menu has an arrow, don't modify the horizontal alignment - // or you get visual anomalies. See PR #6087. Otherwise set the - // horizontal alignment to `start` so that the top left corner of - // the menu aligns with the point that the menu is popped up at. - if (!arrow) menu_widget.as(gtk.Widget).setHalign(.start); - - menu_widget.as(gtk.Popover).setHasArrow(@intFromBool(arrow)); - - _ = gtk.Popover.signals.closed.connect( - menu_widget, - *Self, - gtkRefocusTerm, - self, - .{}, - ); - - self.* = .{ - .parent = parent, - .menu_widget = menu_widget, - }; - } - - pub fn setParent(self: *const Self, widget: *gtk.Widget) void { - self.menu_widget.as(gtk.Widget).setParent(widget); - } - - pub fn asWidget(self: *const Self) *gtk.Widget { - return self.menu_widget.as(gtk.Widget); - } - - pub fn isVisible(self: *const Self) bool { - return self.menu_widget.as(gtk.Widget).getVisible() != 0; - } - - /// Refresh the menu. Right now that means enabling/disabling the "Copy" - /// menu item based on whether there is an active selection or not, but - /// that may change in the future. - pub fn refresh(self: *const Self) void { - const window: *gtk.Window, const has_selection: bool = switch (T) { - Window => window: { - const has_selection = if (self.parent.actionSurface()) |core_surface| - core_surface.hasSelection() - else - false; - - break :window .{ self.parent.window.as(gtk.Window), has_selection }; - }, - Surface => surface: { - const window = self.parent.container.window() orelse return; - const has_selection = self.parent.core_surface.hasSelection(); - break :surface .{ window.window.as(gtk.Window), has_selection }; - }, - else => unreachable, - }; - - const action_map: *gio.ActionMap = gobject.ext.cast(gio.ActionMap, window) orelse return; - const action: *gio.SimpleAction = gobject.ext.cast( - gio.SimpleAction, - action_map.lookupAction("copy") orelse return, - ) orelse return; - action.setEnabled(@intFromBool(has_selection)); - } - - /// Pop up the menu at the given coordinates - pub fn popupAt(self: *const Self, x: c_int, y: c_int) void { - const rect: gdk.Rectangle = .{ - .f_x = x, - .f_y = y, - .f_width = 1, - .f_height = 1, - }; - const popover = self.menu_widget.as(gtk.Popover); - popover.setPointingTo(&rect); - self.refresh(); - popover.popup(); - } - - /// Refocus tab that lost focus because of the popover menu - fn gtkRefocusTerm(_: *gtk.PopoverMenu, self: *Self) callconv(.c) void { - const window: *Window = switch (T) { - Window => self.parent, - Surface => self.parent.container.window() orelse return, - else => unreachable, - }; - - window.focusCurrentTab(); - } - }; -} diff --git a/src/apprt/gtk/style-dark.css b/src/apprt/gtk/style-dark.css deleted file mode 100644 index 1ea2aeb4b..000000000 --- a/src/apprt/gtk/style-dark.css +++ /dev/null @@ -1,8 +0,0 @@ -.transparent { - background-color: transparent; -} - -.terminal-window .notebook paned > separator { - background-color: rgba(36, 36, 36, 1); - background-clip: content-box; -} diff --git a/src/apprt/gtk/style-hc-dark.css b/src/apprt/gtk/style-hc-dark.css deleted file mode 100644 index a9aa2dcc0..000000000 --- a/src/apprt/gtk/style-hc-dark.css +++ /dev/null @@ -1,3 +0,0 @@ -.transparent { - background-color: transparent; -} diff --git a/src/apprt/gtk/style-hc.css b/src/apprt/gtk/style-hc.css deleted file mode 100644 index a9aa2dcc0..000000000 --- a/src/apprt/gtk/style-hc.css +++ /dev/null @@ -1,3 +0,0 @@ -.transparent { - background-color: transparent; -} diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css deleted file mode 100644 index 777ab3810..000000000 --- a/src/apprt/gtk/style.css +++ /dev/null @@ -1,116 +0,0 @@ -label.url-overlay { - padding: 4px 8px 4px 8px; - outline-style: solid; - outline-color: #555555; - outline-width: 1px; -} - -label.url-overlay:hover { - opacity: 0; -} - -label.url-overlay.left { - border-radius: 0px 6px 0px 0px; -} - -label.url-overlay.right { - border-radius: 6px 0px 0px 0px; -} - -label.url-overlay.hidden { - opacity: 0; -} - -label.size-overlay { - padding: 4px 8px 4px 8px; - border-radius: 6px 6px 6px 6px; - outline-style: solid; - outline-width: 1px; - outline-color: #555555; -} - -label.size-overlay.hidden { - opacity: 0; -} - -window.ssd.no-border-radius { - /* Without clearing the border radius, at least on Mutter with - * gtk-titlebar=true and gtk-adwaita=false, there is some window artifacting - * that this will mitigate. - */ - border-radius: 0 0; -} - -.transparent { - background-color: transparent; -} - -.terminal-window .notebook paned > separator { - background-color: rgba(250, 250, 250, 1); - background-clip: content-box; - - /* This works around the oversized drag area for the right side of GtkPaned. - * - * Upstream Gtk issue: - * https://gitlab.gnome.org/GNOME/gtk/-/issues/4484#note_2362002 - * - * Ghostty issue: - * https://github.com/ghostty-org/ghostty/issues/3020 - * - * Without this, it's not possible to select the first character on the - * right-hand side of a split. - */ - margin: 0; - padding: 0; -} - -.clipboard-overlay { - border-radius: 10px; -} - -.clipboard-content-view { - filter: blur(0px); - transition: filter 0.3s ease; - border-radius: 10px; -} - -.clipboard-content-view.blurred { - filter: blur(5px); -} - -.command-palette-search { - font-size: 1.25rem; - padding: 4px; - -gtk-icon-size: 20px; -} - -.command-palette-search > image:first-child { - margin-left: 8px; - margin-right: 4px; -} - -.command-palette-search > image:last-child { - margin-left: 4px; - margin-right: 8px; -} - -banner.child_exited_normally revealer widget { - background-color: rgba(38, 162, 105, 0.5); - /* after GTK 4.16 is a requirement, switch to the following: - /* background-color: color-mix(in srgb, var(--success-bg-color), transparent 50%); */ -} - -banner.child_exited_abnormally revealer widget { - background-color: rgba(192, 28, 40, 0.5); - /* after GTK 4.16 is a requirement, switch to the following: - /* background-color: color-mix(in srgb, var(--error-bg-color), transparent 50%); */ -} - -/* -* Change the color of an error progressbar -*/ -progressbar.error trough progress { - background-color: rgb(192, 28, 40); - /* after GTK 4.16 is a requirement, switch to the following: */ - /* background-color: var(--error-bg-color); */ -} diff --git a/src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp b/src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp deleted file mode 100644 index 90de02845..000000000 --- a/src/apprt/gtk/ui/1.0/menu-headerbar-split_menu.blp +++ /dev/null @@ -1,25 +0,0 @@ -using Gtk 4.0; - -menu menu { - section { - item { - label: _("Split Up"); - action: "win.split-up"; - } - - item { - label: _("Split Down"); - action: "win.split-down"; - } - - item { - label: _("Split Left"); - action: "win.split-left"; - } - - item { - label: _("Split Right"); - action: "win.split-right"; - } - } -} diff --git a/src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp b/src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp deleted file mode 100644 index ab48552db..000000000 --- a/src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp +++ /dev/null @@ -1,102 +0,0 @@ -using Gtk 4.0; - -menu menu { - section { - item { - label: _("Copy"); - action: "win.copy"; - } - - item { - label: _("Paste"); - action: "win.paste"; - } - } - - section { - item { - label: _("Clear"); - action: "win.clear"; - } - - item { - label: _("Reset"); - action: "win.reset"; - } - } - - section { - submenu { - label: _("Split"); - - item { - label: _("Change Title…"); - action: "win.prompt-title"; - } - - item { - label: _("Split Up"); - action: "win.split-up"; - } - - item { - label: _("Split Down"); - action: "win.split-down"; - } - - item { - label: _("Split Left"); - action: "win.split-left"; - } - - item { - label: _("Split Right"); - action: "win.split-right"; - } - } - - submenu { - label: _("Tab"); - - item { - label: _("New Tab"); - action: "win.new-tab"; - } - - item { - label: _("Close Tab"); - action: "win.close-tab"; - } - } - - submenu { - label: _("Window"); - - item { - label: _("New Window"); - action: "win.new-window"; - } - - item { - label: _("Close Window"); - action: "win.close"; - } - } - } - - section { - submenu { - label: _("Config"); - - item { - label: _("Open Configuration"); - action: "app.open-config"; - } - - item { - label: _("Reload Configuration"); - action: "app.reload-config"; - } - } - } -} diff --git a/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp b/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp deleted file mode 100644 index 3273aa81c..000000000 --- a/src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp +++ /dev/null @@ -1,116 +0,0 @@ -using Gtk 4.0; - -menu menu { - section { - item { - label: _("Copy"); - action: "win.copy"; - } - - item { - label: _("Paste"); - action: "win.paste"; - } - } - - section { - item { - label: _("New Window"); - action: "win.new-window"; - } - - item { - label: _("Close Window"); - action: "win.close"; - } - } - - section { - item { - label: _("New Tab"); - action: "win.new-tab"; - } - - item { - label: _("Close Tab"); - action: "win.close-tab"; - } - } - - section { - submenu { - label: _("Split"); - - item { - label: _("Change Title…"); - action: "win.prompt-title"; - } - - item { - label: _("Split Up"); - action: "win.split-up"; - } - - item { - label: _("Split Down"); - action: "win.split-down"; - } - - item { - label: _("Split Left"); - action: "win.split-left"; - } - - item { - label: _("Split Right"); - action: "win.split-right"; - } - } - } - - section { - item { - label: _("Clear"); - action: "win.clear"; - } - - item { - label: _("Reset"); - action: "win.reset"; - } - } - - section { - item { - label: _("Command Palette"); - action: "win.toggle-command-palette"; - } - - item { - label: _("Terminal Inspector"); - action: "win.toggle-inspector"; - } - - item { - label: _("Open Configuration"); - action: "app.open-config"; - } - - item { - label: _("Reload Configuration"); - action: "app.reload-config"; - } - } - - section { - item { - label: _("About Ghostty"); - action: "win.about"; - } - - item { - label: _("Quit"); - action: "app.quit"; - } - } -} diff --git a/src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp b/src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp deleted file mode 100644 index b250073d2..000000000 --- a/src/apprt/gtk/ui/1.2/ccw-osc-52-read.blp +++ /dev/null @@ -1,71 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.MessageDialog clipboard_confirmation_window { - heading: _("Authorize Clipboard Access"); - body: _("An application is attempting to read from the clipboard. The current clipboard contents are shown below."); - - responses [ - cancel: _("Deny") suggested, - ok: _("Allow") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: Overlay { - styles [ - "osd", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } - } - }; -} diff --git a/src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp b/src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp deleted file mode 100644 index d880df5f2..000000000 --- a/src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp +++ /dev/null @@ -1,71 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.MessageDialog clipboard_confirmation_window { - heading: _("Authorize Clipboard Access"); - body: _("An application is attempting to write to the clipboard. The current clipboard contents are shown below."); - - responses [ - cancel: _("Deny") suggested, - ok: _("Allow") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: Overlay { - styles [ - "osd", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } - } - }; -} diff --git a/src/apprt/gtk/ui/1.2/ccw-paste.blp b/src/apprt/gtk/ui/1.2/ccw-paste.blp deleted file mode 100644 index f26921803..000000000 --- a/src/apprt/gtk/ui/1.2/ccw-paste.blp +++ /dev/null @@ -1,71 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.MessageDialog clipboard_confirmation_window { - heading: _("Warning: Potentially Unsafe Paste"); - body: _("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed."); - - responses [ - cancel: _("Cancel") suggested, - ok: _("Paste") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: Overlay { - styles [ - "osd", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } - } - }; -} diff --git a/src/apprt/gtk/ui/1.2/config-errors-dialog.blp b/src/apprt/gtk/ui/1.2/config-errors-dialog.blp deleted file mode 100644 index b844d6347..000000000 --- a/src/apprt/gtk/ui/1.2/config-errors-dialog.blp +++ /dev/null @@ -1,28 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -Adw.MessageDialog config_errors_dialog { - heading: _("Configuration Errors"); - body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."); - - responses [ - ignore: _("Ignore"), - reload: _("Reload Configuration") suggested, - ] - - extra-child: ScrolledWindow { - min-content-width: 500; - min-content-height: 100; - - TextView { - editable: false; - cursor-visible: false; - top-margin: 8; - bottom-margin: 8; - left-margin: 8; - right-margin: 8; - - buffer: TextBuffer error_message {}; - } - }; -} diff --git a/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp b/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp deleted file mode 100644 index ad0b5c01f..000000000 --- a/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp +++ /dev/null @@ -1,85 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.AlertDialog clipboard_confirmation_window { - heading: _("Authorize Clipboard Access"); - body: _("An application is attempting to read from the clipboard. The current clipboard contents are shown below."); - - responses [ - cancel: _("Deny") suggested, - ok: _("Allow") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: ListBox { - selection-mode: none; - - styles [ - "boxed-list-separate", - ] - - Overlay { - styles [ - "osd", - "clipboard-overlay", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 200; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } - } - } - - Adw.SwitchRow remember_choice { - title: _("Remember choice for this split"); - subtitle: _("Reload configuration to show this prompt again"); - } - }; -} diff --git a/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp b/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp deleted file mode 100644 index b71131940..000000000 --- a/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp +++ /dev/null @@ -1,81 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.AlertDialog clipboard_confirmation_window { - heading: _("Authorize Clipboard Access"); - body: _("An application is attempting to write to the clipboard. The current clipboard contents are shown below."); - - responses [ - cancel: _("Deny") suggested, - ok: _("Allow") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: ListBox { - selection-mode: none; - - styles [ - "boxed-list-separate", - ] - - Overlay { - styles [ - "osd", - "clipboard-overlay", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 200; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - } - } - - Adw.SwitchRow remember_choice { - title: _("Remember choice for this split"); - subtitle: _("Reload configuration to show this prompt again"); - } - }; -} diff --git a/src/apprt/gtk/ui/1.5/ccw-paste.blp b/src/apprt/gtk/ui/1.5/ccw-paste.blp deleted file mode 100644 index a5f909526..000000000 --- a/src/apprt/gtk/ui/1.5/ccw-paste.blp +++ /dev/null @@ -1,71 +0,0 @@ -using Gtk 4.0; -using Adw 1; -translation-domain "com.mitchellh.ghostty"; - -Adw.AlertDialog clipboard_confirmation_window { - heading: _("Warning: Potentially Unsafe Paste"); - body: _("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed."); - - responses [ - cancel: _("Cancel") suggested, - ok: _("Paste") destructive, - ] - - default-response: "cancel"; - close-response: "cancel"; - - extra-child: Overlay { - styles [ - "osd", - ] - - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } - } - }; -} diff --git a/src/apprt/gtk/ui/1.5/command-palette.blp b/src/apprt/gtk/ui/1.5/command-palette.blp deleted file mode 100644 index a84482091..000000000 --- a/src/apprt/gtk/ui/1.5/command-palette.blp +++ /dev/null @@ -1,106 +0,0 @@ -using Gtk 4.0; -using Gio 2.0; -using Adw 1; - -Adw.Dialog command-palette { - content-width: 700; - - Adw.ToolbarView { - top-bar-style: flat; - - [top] - Adw.HeaderBar { - [title] - SearchEntry search { - hexpand: true; - placeholder-text: _("Execute a command…"); - - styles [ - "command-palette-search", - ] - } - } - - ScrolledWindow { - min-content-height: 300; - - ListView view { - show-separators: true; - single-click-activate: true; - - model: SingleSelection model { - model: FilterListModel { - incremental: true; - - filter: AnyFilter { - StringFilter { - expression: expr item as <$GhosttyCommand>.title; - search: bind search.text; - } - - StringFilter { - expression: expr item as <$GhosttyCommand>.action-key; - search: bind search.text; - } - }; - - model: Gio.ListStore source { - item-type: typeof<$GhosttyCommand>; - }; - }; - }; - - styles [ - "rich-list", - ] - - factory: BuilderListItemFactory { - template ListItem { - child: Box { - orientation: horizontal; - spacing: 10; - tooltip-text: bind template.item as <$GhosttyCommand>.description; - - Box { - orientation: vertical; - hexpand: true; - - Label { - ellipsize: end; - halign: start; - wrap: false; - single-line-mode: true; - - styles [ - "title", - ] - - label: bind template.item as <$GhosttyCommand>.title; - } - - Label { - ellipsize: end; - halign: start; - wrap: false; - single-line-mode: true; - - styles [ - "subtitle", - "monospace", - ] - - label: bind template.item as <$GhosttyCommand>.action-key; - } - } - - ShortcutLabel { - accelerator: bind template.item as <$GhosttyCommand>.action; - valign: center; - } - }; - } - }; - } - } - } -} diff --git a/src/apprt/gtk/ui/1.5/config-errors-dialog.blp b/src/apprt/gtk/ui/1.5/config-errors-dialog.blp deleted file mode 100644 index 793e9295a..000000000 --- a/src/apprt/gtk/ui/1.5/config-errors-dialog.blp +++ /dev/null @@ -1,28 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -Adw.AlertDialog config_errors_dialog { - heading: _("Configuration Errors"); - body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."); - - responses [ - ignore: _("Ignore"), - reload: _("Reload Configuration") suggested, - ] - - extra-child: ScrolledWindow { - min-content-width: 500; - min-content-height: 100; - - TextView { - editable: false; - cursor-visible: false; - top-margin: 8; - bottom-margin: 8; - left-margin: 8; - right-margin: 8; - - buffer: TextBuffer error_message {}; - } - }; -} diff --git a/src/apprt/gtk/ui/1.5/prompt-title-dialog.blp b/src/apprt/gtk/ui/1.5/prompt-title-dialog.blp deleted file mode 100644 index d23594ba4..000000000 --- a/src/apprt/gtk/ui/1.5/prompt-title-dialog.blp +++ /dev/null @@ -1,16 +0,0 @@ -using Gtk 4.0; -using Adw 1; - -Adw.AlertDialog prompt_title_dialog { - heading: _("Change Terminal Title"); - body: _("Leave blank to restore the default title."); - - responses [ - cancel: _("Cancel") suggested, - ok: _("OK") destructive, - ] - - focus-widget: title_entry; - - extra-child: Entry title_entry {}; -} diff --git a/src/apprt/gtk/ui/README.md b/src/apprt/gtk/ui/README.md deleted file mode 100644 index b9dc732b6..000000000 --- a/src/apprt/gtk/ui/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# GTK UI files - -This directory is for storing GTK blueprints. GTK blueprints are compiled into -GTK resource builder `.ui` files by `blueprint-compiler` at build time and then -converted into an embeddable resource by `glib-compile-resources`. - -Blueprint files should be stored in directories that represent the minimum -Adwaita version needed to use that resource. Blueprint files should also be -formatted using `blueprint-compiler format` as well to ensure consistency -(formatting will be checked in CI). - -`blueprint-compiler` version 0.16.0 or newer is required to compile Blueprint -files. If your system does not have `blueprint-compiler` or does not have a -new enough version you can use the generated source tarballs, which contain -precompiled versions of the blueprints. diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig deleted file mode 100644 index 2dbe5a7a0..000000000 --- a/src/apprt/gtk/winproto.zig +++ /dev/null @@ -1,155 +0,0 @@ -const std = @import("std"); -const build_options = @import("build_options"); -const Allocator = std.mem.Allocator; - -const gdk = @import("gdk"); - -const Config = @import("../../config.zig").Config; -const input = @import("../../input.zig"); -const key = @import("key.zig"); -const ApprtWindow = @import("Window.zig"); - -pub const noop = @import("winproto/noop.zig"); -pub const x11 = @import("winproto/x11.zig"); -pub const wayland = @import("winproto/wayland.zig"); - -pub const Protocol = enum { - none, - wayland, - x11, -}; - -/// App-state for the underlying windowing protocol. There should be one -/// instance of this struct per application. -pub const App = union(Protocol) { - none: noop.App, - wayland: if (build_options.wayland) wayland.App else noop.App, - x11: if (build_options.x11) x11.App else noop.App, - - pub fn init( - alloc: Allocator, - gdk_display: *gdk.Display, - app_id: [:0]const u8, - config: *const Config, - ) !App { - inline for (@typeInfo(App).@"union".fields) |field| { - if (try field.type.init( - alloc, - gdk_display, - app_id, - config, - )) |v| { - return @unionInit(App, field.name, v); - } - } - - return .{ .none = .{} }; - } - - pub fn deinit(self: *App, alloc: Allocator) void { - switch (self.*) { - inline else => |*v| v.deinit(alloc), - } - } - - pub fn eventMods( - self: *App, - device: ?*gdk.Device, - gtk_mods: gdk.ModifierType, - ) input.Mods { - return switch (self.*) { - inline else => |*v| v.eventMods(device, gtk_mods), - } orelse key.translateMods(gtk_mods); - } - - pub fn supportsQuickTerminal(self: App) bool { - return switch (self) { - inline else => |v| v.supportsQuickTerminal(), - }; - } - - /// Set up necessary support for the quick terminal that must occur - /// *before* the window-level winproto object is created. - /// - /// Only has an effect on the Wayland backend, where the gtk4-layer-shell - /// library is initialized. - pub fn initQuickTerminal(self: *App, apprt_window: *ApprtWindow) !void { - switch (self.*) { - inline else => |*v| try v.initQuickTerminal(apprt_window), - } - } -}; - -/// Per-Window state for the underlying windowing protocol. -/// -/// In Wayland, the terminology used is "Surface" and for it, this is -/// really "Surface"-specific state. But Ghostty uses the term "Surface" -/// heavily to mean something completely different, so we use "Window" here -/// to better match what it generally maps to in the Ghostty codebase. -pub const Window = union(Protocol) { - none: noop.Window, - wayland: if (build_options.wayland) wayland.Window else noop.Window, - x11: if (build_options.x11) x11.Window else noop.Window, - - pub fn init( - alloc: Allocator, - app: *App, - apprt_window: *ApprtWindow, - ) !Window { - return switch (app.*) { - inline else => |*v, tag| { - inline for (@typeInfo(Window).@"union".fields) |field| { - if (comptime std.mem.eql( - u8, - field.name, - @tagName(tag), - )) return @unionInit( - Window, - field.name, - try field.type.init( - alloc, - v, - apprt_window, - ), - ); - } - }, - }; - } - - pub fn deinit(self: *Window, alloc: Allocator) void { - switch (self.*) { - inline else => |*v| v.deinit(alloc), - } - } - - pub fn resizeEvent(self: *Window) !void { - switch (self.*) { - inline else => |*v| try v.resizeEvent(), - } - } - - pub fn syncAppearance(self: *Window) !void { - switch (self.*) { - inline else => |*v| try v.syncAppearance(), - } - } - - pub fn clientSideDecorationEnabled(self: Window) bool { - return switch (self) { - inline else => |v| v.clientSideDecorationEnabled(), - }; - } - - pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { - switch (self.*) { - inline else => |*v| try v.addSubprocessEnv(env), - } - } - - pub fn setUrgent(self: *Window, urgent: bool) !void { - switch (self.*) { - inline else => |*v| try v.setUrgent(urgent), - } - } -}; diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig deleted file mode 100644 index fb732b756..000000000 --- a/src/apprt/gtk/winproto/noop.zig +++ /dev/null @@ -1,75 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; - -const gdk = @import("gdk"); - -const Config = @import("../../../config.zig").Config; -const input = @import("../../../input.zig"); -const ApprtWindow = @import("../Window.zig"); - -const log = std.log.scoped(.winproto_noop); - -pub const App = struct { - pub fn init( - _: Allocator, - _: *gdk.Display, - _: [:0]const u8, - _: *const Config, - ) !?App { - return null; - } - - pub fn deinit(self: *App, alloc: Allocator) void { - _ = self; - _ = alloc; - } - - pub fn eventMods( - _: *App, - _: ?*gdk.Device, - _: gdk.ModifierType, - ) ?input.Mods { - return null; - } - - pub fn supportsQuickTerminal(_: App) bool { - return false; - } - pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {} -}; - -pub const Window = struct { - pub fn init( - _: Allocator, - _: *App, - _: *ApprtWindow, - ) !Window { - return .{}; - } - - pub fn deinit(self: Window, alloc: Allocator) void { - _ = self; - _ = alloc; - } - - pub fn updateConfigEvent( - _: *Window, - _: *const ApprtWindow.DerivedConfig, - ) !void {} - - pub fn resizeEvent(_: *Window) !void {} - - pub fn syncAppearance(_: *Window) !void {} - - /// This returns true if CSD is enabled for this window. This - /// should be the actual present state of the window, not the - /// desired state. - pub fn clientSideDecorationEnabled(self: Window) bool { - _ = self; - return true; - } - - pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {} - - pub fn setUrgent(_: *Window, _: bool) !void {} -}; diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig deleted file mode 100644 index 0973499cc..000000000 --- a/src/apprt/gtk/winproto/wayland.zig +++ /dev/null @@ -1,511 +0,0 @@ -//! Wayland protocol implementation for the Ghostty GTK apprt. -const std = @import("std"); -const Allocator = std.mem.Allocator; -const build_options = @import("build_options"); - -const gdk = @import("gdk"); -const gdk_wayland = @import("gdk_wayland"); -const gobject = @import("gobject"); -const gtk = @import("gtk"); -const layer_shell = @import("gtk4-layer-shell"); -const wayland = @import("wayland"); - -const Config = @import("../../../config.zig").Config; -const input = @import("../../../input.zig"); -const ApprtWindow = @import("../Window.zig"); - -const wl = wayland.client.wl; -const org = wayland.client.org; -const xdg = wayland.client.xdg; - -const log = std.log.scoped(.winproto_wayland); - -/// Wayland state that contains application-wide Wayland objects (e.g. wl_display). -pub const App = struct { - display: *wl.Display, - context: *Context, - - const Context = struct { - kde_blur_manager: ?*org.KdeKwinBlurManager = null, - - // FIXME: replace with `zxdg_decoration_v1` once GTK merges - // https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 - kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null, - - kde_slide_manager: ?*org.KdeKwinSlideManager = null, - - default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, - - xdg_activation: ?*xdg.ActivationV1 = null, - - /// Whether the xdg_wm_dialog_v1 protocol is present. - /// - /// If it is present, gtk4-layer-shell < 1.0.4 may crash when the user - /// creates a quick terminal, and we need to ensure this fails - /// gracefully if this situation occurs. - /// - /// FIXME: This is a temporary workaround - we should remove this when - /// all of our supported distros drop support for affected old - /// gtk4-layer-shell versions. - /// - /// See https://github.com/wmww/gtk4-layer-shell/issues/50 - xdg_wm_dialog_present: bool = false, - }; - - pub fn init( - alloc: Allocator, - gdk_display: *gdk.Display, - app_id: [:0]const u8, - config: *const Config, - ) !?App { - _ = config; - _ = app_id; - - const gdk_wayland_display = gobject.ext.cast( - gdk_wayland.WaylandDisplay, - gdk_display, - ) orelse return null; - - const display: *wl.Display = @ptrCast(@alignCast( - gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay, - )); - - // Create our context for our callbacks so we have a stable pointer. - // Note: at the time of writing this comment, we don't really need - // a stable pointer, but it's too scary that we'd need one in the future - // and not have it and corrupt memory or something so let's just do it. - const context = try alloc.create(Context); - errdefer alloc.destroy(context); - context.* = .{}; - - // Get our display registry so we can get all the available interfaces - // and bind to what we need. - const registry = try display.getRegistry(); - registry.setListener(*Context, registryListener, context); - if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - - // Do another round-trip to get the default decoration mode - if (context.kde_decoration_manager) |deco_manager| { - deco_manager.setListener(*Context, decoManagerListener, context); - if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - } - - return .{ - .display = display, - .context = context, - }; - } - - pub fn deinit(self: *App, alloc: Allocator) void { - alloc.destroy(self.context); - } - - pub fn eventMods( - _: *App, - _: ?*gdk.Device, - _: gdk.ModifierType, - ) ?input.Mods { - return null; - } - - pub fn supportsQuickTerminal(self: App) bool { - if (!layer_shell.isSupported()) { - log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{}); - return false; - } - - if (self.context.xdg_wm_dialog_present and layer_shell.getLibraryVersion().order(.{ - .major = 1, - .minor = 0, - .patch = 4, - }) == .lt) { - log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{}); - return false; - } - - return true; - } - - pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void { - const window = apprt_window.window.as(gtk.Window); - - layer_shell.initForWindow(window); - layer_shell.setLayer(window, .top); - layer_shell.setNamespace(window, "ghostty-quick-terminal"); - } - - fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type { - // Globals should be optional pointers - const T = switch (@typeInfo(field.type)) { - .optional => |o| switch (@typeInfo(o.child)) { - .pointer => |v| v.child, - else => return null, - }, - else => return null, - }; - - // Only process Wayland interfaces - if (!@hasDecl(T, "interface")) return null; - return T; - } - - fn registryListener( - registry: *wl.Registry, - event: wl.Registry.Event, - context: *Context, - ) void { - const ctx_fields = @typeInfo(Context).@"struct".fields; - - switch (event) { - .global => |v| { - log.debug("found global {s}", .{v.interface}); - - // We don't actually do anything with this other than checking - // for its existence, so we process this separately. - if (std.mem.orderZ( - u8, - v.interface, - "xdg_wm_dialog_v1", - ) == .eq) { - context.xdg_wm_dialog_present = true; - return; - } - - inline for (ctx_fields) |field| { - const T = getInterfaceType(field) orelse continue; - - if (std.mem.orderZ( - u8, - v.interface, - T.interface.name, - ) == .eq) { - log.debug("matched {}", .{T}); - - @field(context, field.name) = registry.bind( - v.name, - T, - T.generated_version, - ) catch |err| { - log.warn( - "error binding interface {s} error={}", - .{ v.interface, err }, - ); - return; - }; - } - } - }, - - // This should be a rare occurrence, but in case a global - // is suddenly no longer available, we destroy and unset it - // as the protocol mandates. - .global_remove => |v| remove: { - inline for (ctx_fields) |field| { - if (getInterfaceType(field) == null) continue; - const global = @field(context, field.name) orelse break :remove; - if (global.getId() == v.name) { - global.destroy(); - @field(context, field.name) = null; - } - } - }, - } - } - - fn decoManagerListener( - _: *org.KdeKwinServerDecorationManager, - event: org.KdeKwinServerDecorationManager.Event, - context: *Context, - ) void { - switch (event) { - .default_mode => |mode| { - context.default_deco_mode = @enumFromInt(mode.mode); - }, - } - } -}; - -/// Per-window (wl_surface) state for the Wayland protocol. -pub const Window = struct { - apprt_window: *ApprtWindow, - - /// The Wayland surface for this window. - surface: *wl.Surface, - - /// The context from the app where we can load our Wayland interfaces. - app_context: *App.Context, - - /// A token that, when present, indicates that the window is blurred. - blur_token: ?*org.KdeKwinBlur = null, - - /// Object that controls the decoration mode (client/server/auto) - /// of the window. - decoration: ?*org.KdeKwinServerDecoration = null, - - /// Object that controls the slide-in/slide-out animations of the - /// quick terminal. Always null for windows other than the quick terminal. - slide: ?*org.KdeKwinSlide = null, - - /// Object that, when present, denotes that the window is currently - /// requesting attention from the user. - activation_token: ?*xdg.ActivationTokenV1 = null, - - pub fn init( - alloc: Allocator, - app: *App, - apprt_window: *ApprtWindow, - ) !Window { - _ = alloc; - - const gtk_native = apprt_window.window.as(gtk.Native); - const gdk_surface = gtk_native.getSurface() orelse return error.NotWaylandSurface; - - // This should never fail, because if we're being called at this point - // then we've already asserted that our app state is Wayland. - const gdk_wl_surface = gobject.ext.cast( - gdk_wayland.WaylandSurface, - gdk_surface, - ) orelse return error.NoWaylandSurface; - - const wl_surface: *wl.Surface = @ptrCast(@alignCast( - gdk_wl_surface.getWlSurface() orelse return error.NoWaylandSurface, - )); - - // Get our decoration object so we can control the - // CSD vs SSD status of this surface. - const deco: ?*org.KdeKwinServerDecoration = deco: { - const mgr = app.context.kde_decoration_manager orelse - break :deco null; - - const deco: *org.KdeKwinServerDecoration = mgr.create( - wl_surface, - ) catch |err| { - log.warn("could not create decoration object={}", .{err}); - break :deco null; - }; - - break :deco deco; - }; - - if (apprt_window.isQuickTerminal()) { - _ = gdk.Surface.signals.enter_monitor.connect( - gdk_surface, - *ApprtWindow, - enteredMonitor, - apprt_window, - .{}, - ); - } - - return .{ - .apprt_window = apprt_window, - .surface = wl_surface, - .app_context = app.context, - .decoration = deco, - }; - } - - pub fn deinit(self: Window, alloc: Allocator) void { - _ = alloc; - if (self.blur_token) |blur| blur.release(); - if (self.decoration) |deco| deco.release(); - if (self.slide) |slide| slide.release(); - } - - pub fn resizeEvent(_: *Window) !void {} - - pub fn syncAppearance(self: *Window) !void { - self.syncBlur() catch |err| { - log.err("failed to sync blur={}", .{err}); - }; - self.syncDecoration() catch |err| { - log.err("failed to sync blur={}", .{err}); - }; - - if (self.apprt_window.isQuickTerminal()) { - self.syncQuickTerminal() catch |err| { - log.warn("failed to sync quick terminal appearance={}", .{err}); - }; - } - } - - pub fn clientSideDecorationEnabled(self: Window) bool { - return switch (self.getDecorationMode()) { - .Client => true, - // If we support SSDs, then we should *not* enable CSDs if we prefer SSDs. - // However, if we do not support SSDs (e.g. GNOME) then we should enable - // CSDs even if the user prefers SSDs. - .Server => if (self.app_context.kde_decoration_manager) |_| false else true, - .None => false, - else => unreachable, - }; - } - - pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { - _ = self; - _ = env; - } - - pub fn setUrgent(self: *Window, urgent: bool) !void { - const activation = self.app_context.xdg_activation orelse return; - - // If there already is a token, destroy and unset it - if (self.activation_token) |token| token.destroy(); - - self.activation_token = if (urgent) token: { - const token = try activation.getActivationToken(); - token.setSurface(self.surface); - token.setListener(*Window, onActivationTokenEvent, self); - token.commit(); - break :token token; - } else null; - } - - /// Update the blur state of the window. - fn syncBlur(self: *Window) !void { - const manager = self.app_context.kde_blur_manager orelse return; - const blur = self.apprt_window.config.background_blur; - - if (self.blur_token) |tok| { - // Only release token when transitioning from blurred -> not blurred - if (!blur.enabled()) { - manager.unset(self.surface); - tok.release(); - self.blur_token = null; - } - } else { - // Only acquire token when transitioning from not blurred -> blurred - if (blur.enabled()) { - const tok = try manager.create(self.surface); - tok.commit(); - self.blur_token = tok; - } - } - } - - fn syncDecoration(self: *Window) !void { - const deco = self.decoration orelse return; - - // The protocol requests uint instead of enum so we have - // to convert it. - deco.requestMode(@intCast(@intFromEnum(self.getDecorationMode()))); - } - - fn getDecorationMode(self: Window) org.KdeKwinServerDecorationManager.Mode { - return switch (self.apprt_window.config.window_decoration) { - .auto => self.app_context.default_deco_mode orelse .Client, - .client => .Client, - .server => .Server, - .none => .None, - }; - } - - fn syncQuickTerminal(self: *Window) !void { - const window = self.apprt_window.window.as(gtk.Window); - const config = &self.apprt_window.config; - - layer_shell.setKeyboardMode( - window, - switch (config.quick_terminal_keyboard_interactivity) { - .none => .none, - .@"on-demand" => on_demand: { - if (layer_shell.getProtocolVersion() < 4) { - log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{}); - break :on_demand .exclusive; - } - break :on_demand .on_demand; - }, - .exclusive => .exclusive, - }, - ); - - const anchored_edge: ?layer_shell.ShellEdge = switch (config.quick_terminal_position) { - .left => .left, - .right => .right, - .top => .top, - .bottom => .bottom, - .center => null, - }; - - for (std.meta.tags(layer_shell.ShellEdge)) |edge| { - if (anchored_edge) |anchored| { - if (edge == anchored) { - layer_shell.setMargin(window, edge, 0); - layer_shell.setAnchor(window, edge, true); - continue; - } - } - - // Arbitrary margin - could be made customizable? - layer_shell.setMargin(window, edge, 20); - layer_shell.setAnchor(window, edge, false); - } - - if (self.slide) |slide| slide.release(); - - self.slide = if (anchored_edge) |anchored| slide: { - const mgr = self.app_context.kde_slide_manager orelse break :slide null; - - const slide = mgr.create(self.surface) catch |err| { - log.warn("could not create slide object={}", .{err}); - break :slide null; - }; - - const slide_location: org.KdeKwinSlide.Location = switch (anchored) { - .top => .top, - .bottom => .bottom, - .left => .left, - .right => .right, - }; - - slide.setLocation(@intCast(@intFromEnum(slide_location))); - slide.commit(); - break :slide slide; - } else null; - } - - /// Update the size of the quick terminal based on monitor dimensions. - fn enteredMonitor( - _: *gdk.Surface, - monitor: *gdk.Monitor, - apprt_window: *ApprtWindow, - ) callconv(.c) void { - const window = apprt_window.window.as(gtk.Window); - const config = &apprt_window.config; - - var monitor_size: gdk.Rectangle = undefined; - monitor.getGeometry(&monitor_size); - - const dims = config.quick_terminal_size.calculate( - config.quick_terminal_position, - .{ - .width = @intCast(monitor_size.f_width), - .height = @intCast(monitor_size.f_height), - }, - ); - - window.setDefaultSize(@intCast(dims.width), @intCast(dims.height)); - } - - fn onActivationTokenEvent( - token: *xdg.ActivationTokenV1, - event: xdg.ActivationTokenV1.Event, - self: *Window, - ) void { - const activation = self.app_context.xdg_activation orelse return; - const current_token = self.activation_token orelse return; - - if (token.getId() != current_token.getId()) { - log.warn("received event for unknown activation token; ignoring", .{}); - return; - } - - switch (event) { - .done => |done| { - activation.activate(done.token, self.surface); - token.destroy(); - self.activation_token = null; - }, - } - } -}; diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig deleted file mode 100644 index 624de03f8..000000000 --- a/src/apprt/gtk/winproto/x11.zig +++ /dev/null @@ -1,507 +0,0 @@ -//! X11 window protocol implementation for the Ghostty GTK apprt. -const std = @import("std"); -const builtin = @import("builtin"); -const build_options = @import("build_options"); -const Allocator = std.mem.Allocator; - -const adw = @import("adw"); -const gdk = @import("gdk"); -const gdk_x11 = @import("gdk_x11"); -const glib = @import("glib"); -const gobject = @import("gobject"); -const gtk = @import("gtk"); -const xlib = @import("xlib"); - -pub const c = @cImport({ - @cInclude("X11/Xlib.h"); - @cInclude("X11/Xatom.h"); - @cInclude("X11/XKBlib.h"); -}); - -const input = @import("../../../input.zig"); -const Config = @import("../../../config.zig").Config; -const ApprtWindow = @import("../Window.zig"); - -const log = std.log.scoped(.gtk_x11); - -pub const App = struct { - display: *xlib.Display, - base_event_code: c_int, - atoms: Atoms, - - pub fn init( - _: Allocator, - gdk_display: *gdk.Display, - app_id: [:0]const u8, - config: *const Config, - ) !?App { - // If the display isn't X11, then we don't need to do anything. - const gdk_x11_display = gobject.ext.cast( - gdk_x11.X11Display, - gdk_display, - ) orelse return null; - - const xlib_display = gdk_x11_display.getXdisplay(); - - const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn| - pn - else if (builtin.mode == .Debug) - "ghostty-debug" - else - "ghostty"; - - // Set the X11 window class property (WM_CLASS) if are are on an X11 - // display. - // - // Note that we also set the program name here using g_set_prgname. - // This is how the instance name field for WM_CLASS is derived when - // calling gdk_x11_display_set_program_class; there does not seem to be - // a way to set it directly. It does not look like this is being set by - // our other app initialization routines currently, but since we're - // currently deriving its value from x11-instance-name effectively, I - // feel like gating it behind an X11 check is better intent. - // - // This makes the property show up like so when using xprop: - // - // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" - // - // Append "-debug" on both when using the debug build. - glib.setPrgname(x11_program_name); - gdk_x11.X11Display.setProgramClass(gdk_display, app_id); - - // XKB - log.debug("Xkb.init: initializing Xkb", .{}); - log.debug("Xkb.init: running XkbQueryExtension", .{}); - var opcode: c_int = 0; - var base_event_code: c_int = 0; - var base_error_code: c_int = 0; - var major = c.XkbMajorVersion; - var minor = c.XkbMinorVersion; - if (c.XkbQueryExtension( - @ptrCast(@alignCast(xlib_display)), - &opcode, - &base_event_code, - &base_error_code, - &major, - &minor, - ) == 0) { - log.err("Fatal: error initializing Xkb extension: error executing XkbQueryExtension", .{}); - return error.XkbInitializationError; - } - - log.debug("Xkb.init: running XkbSelectEventDetails", .{}); - if (c.XkbSelectEventDetails( - @ptrCast(@alignCast(xlib_display)), - c.XkbUseCoreKbd, - c.XkbStateNotify, - c.XkbModifierStateMask, - c.XkbModifierStateMask, - ) == 0) { - log.err("Fatal: error initializing Xkb extension: error executing XkbSelectEventDetails", .{}); - return error.XkbInitializationError; - } - - return .{ - .display = xlib_display, - .base_event_code = base_event_code, - .atoms = .init(gdk_x11_display), - }; - } - - pub fn deinit(self: *App, alloc: Allocator) void { - _ = self; - _ = alloc; - } - - /// Checks for an immediate pending XKB state update event, and returns the - /// keyboard state based on if it finds any. This is necessary as the - /// standard GTK X11 API (and X11 in general) does not include the current - /// key pressed in any modifier state snapshot for that event (e.g. if the - /// pressed key is a modifier, that is not necessarily reflected in the - /// modifiers). - /// - /// Returns null if there is no event. In this case, the caller should fall - /// back to the standard GDK modifier state (this likely means the key - /// event did not result in a modifier change). - pub fn eventMods( - self: App, - device: ?*gdk.Device, - gtk_mods: gdk.ModifierType, - ) ?input.Mods { - _ = device; - _ = gtk_mods; - - // Shoutout to Mozilla for figuring out a clean way to do this, this is - // paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp. - if (c.XEventsQueued( - @ptrCast(@alignCast(self.display)), - c.QueuedAfterReading, - ) == 0) return null; - - var nextEvent: c.XEvent = undefined; - _ = c.XPeekEvent(@ptrCast(@alignCast(self.display)), &nextEvent); - if (nextEvent.type != self.base_event_code) return null; - - const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent); - if (xkb_event.any.xkb_type != c.XkbStateNotify) return null; - - const xkb_state_notify_event: *c.XkbStateNotifyEvent = @ptrCast(xkb_event); - // Check the state according to XKB masks. - const lookup_mods = xkb_state_notify_event.lookup_mods; - var mods: input.Mods = .{}; - - log.debug("X11: found extra XkbStateNotify event w/lookup_mods: {b}", .{lookup_mods}); - if (lookup_mods & c.ShiftMask != 0) mods.shift = true; - if (lookup_mods & c.ControlMask != 0) mods.ctrl = true; - if (lookup_mods & c.Mod1Mask != 0) mods.alt = true; - if (lookup_mods & c.Mod4Mask != 0) mods.super = true; - if (lookup_mods & c.LockMask != 0) mods.caps_lock = true; - - return mods; - } - - pub fn supportsQuickTerminal(_: App) bool { - log.warn("quick terminal is not yet supported on X11", .{}); - return false; - } - - pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {} -}; - -pub const Window = struct { - app: *App, - config: *const ApprtWindow.DerivedConfig, - gtk_window: *adw.ApplicationWindow, - x11_surface: *gdk_x11.X11Surface, - - blur_region: Region = .{}, - - pub fn init( - alloc: Allocator, - app: *App, - apprt_window: *ApprtWindow, - ) !Window { - _ = alloc; - - const surface = apprt_window.window.as( - gtk.Native, - ).getSurface() orelse return error.NotX11Surface; - - const x11_surface = gobject.ext.cast( - gdk_x11.X11Surface, - surface, - ) orelse return error.NotX11Surface; - - return .{ - .app = app, - .config = &apprt_window.config, - .gtk_window = apprt_window.window, - .x11_surface = x11_surface, - }; - } - - pub fn deinit(self: Window, alloc: Allocator) void { - _ = self; - _ = alloc; - } - - pub fn resizeEvent(self: *Window) !void { - // The blur region must update with window resizes - try self.syncBlur(); - } - - pub fn syncAppearance(self: *Window) !void { - // The user could have toggled between CSDs and SSDs, - // therefore we need to recalculate the blur region offset. - self.blur_region = blur: { - // NOTE(pluiedev): CSDs are a f--king mistake. - // Please, GNOME, stop this nonsense of making a window ~30% bigger - // internally than how they really are just for your shadows and - // rounded corners and all that fluff. Please. I beg of you. - var x: f64 = 0; - var y: f64 = 0; - - self.gtk_window.as(gtk.Native).getSurfaceTransform(&x, &y); - - // Transform surface coordinates to device coordinates. - const scale: f64 = @floatFromInt(self.gtk_window.as(gtk.Widget).getScaleFactor()); - x *= scale; - y *= scale; - - break :blur .{ - .x = @intFromFloat(x), - .y = @intFromFloat(y), - }; - }; - self.syncBlur() catch |err| { - log.err("failed to synchronize blur={}", .{err}); - }; - self.syncDecorations() catch |err| { - log.err("failed to synchronize decorations={}", .{err}); - }; - } - - pub fn clientSideDecorationEnabled(self: Window) bool { - return switch (self.config.window_decoration) { - .auto, .client => true, - .server, .none => false, - }; - } - - fn syncBlur(self: *Window) !void { - // FIXME: This doesn't currently factor in rounded corners on Adwaita, - // which means that the blur region will grow slightly outside of the - // window borders. Unfortunately, actually calculating the rounded - // region can be quite complex without having access to existing APIs - // (cf. https://github.com/cutefishos/fishui/blob/41d4ba194063a3c7fff4675619b57e6ac0504f06/src/platforms/linux/blurhelper/windowblur.cpp#L134) - // and I think it's not really noticeable enough to justify the effort. - // (Wayland also has this visual artifact anyway...) - - const gtk_widget = self.gtk_window.as(gtk.Widget); - - // Transform surface coordinates to device coordinates. - const scale = self.gtk_window.as(gtk.Widget).getScaleFactor(); - self.blur_region.width = gtk_widget.getWidth() * scale; - self.blur_region.height = gtk_widget.getHeight() * scale; - - const blur = self.config.background_blur; - log.debug("set blur={}, window xid={}, region={}", .{ - blur, - self.x11_surface.getXid(), - self.blur_region, - }); - - if (blur.enabled()) { - try self.changeProperty( - Region, - self.app.atoms.kde_blur, - c.XA_CARDINAL, - ._32, - .{ .mode = .replace }, - &self.blur_region, - ); - } else { - try self.deleteProperty(self.app.atoms.kde_blur); - } - } - - fn syncDecorations(self: *Window) !void { - var hints: MotifWMHints = .{}; - - self.getWindowProperty( - MotifWMHints, - self.app.atoms.motif_wm_hints, - self.app.atoms.motif_wm_hints, - ._32, - .{}, - &hints, - ) catch |err| switch (err) { - // motif_wm_hints is already initialized, so this is fine - error.PropertyNotFound => {}, - - error.RequestFailed, - error.PropertyTypeMismatch, - error.PropertyFormatMismatch, - => return err, - }; - - hints.flags.decorations = true; - hints.decorations.all = switch (self.config.window_decoration) { - .server => true, - .auto, .client, .none => false, - }; - - try self.changeProperty( - MotifWMHints, - self.app.atoms.motif_wm_hints, - self.app.atoms.motif_wm_hints, - ._32, - .{ .mode = .replace }, - &hints, - ); - } - - pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { - var buf: [64]u8 = undefined; - const window_id = try std.fmt.bufPrint( - &buf, - "{}", - .{self.x11_surface.getXid()}, - ); - - try env.put("WINDOWID", window_id); - } - - pub fn setUrgent(self: *Window, urgent: bool) !void { - self.x11_surface.setUrgencyHint(@intFromBool(urgent)); - } - - fn getWindowProperty( - self: *Window, - comptime T: type, - name: c.Atom, - typ: c.Atom, - comptime format: PropertyFormat, - options: struct { - offset: c_long = 0, - length: c_long = std.math.maxInt(c_long), - delete: bool = false, - }, - result: *T, - ) GetWindowPropertyError!void { - // FIXME: Maybe we should switch to libxcb one day. - // Sounds like a much better idea than whatever this is - var actual_type_return: c.Atom = undefined; - var actual_format_return: c_int = undefined; - var nitems_return: c_ulong = undefined; - var bytes_after_return: c_ulong = undefined; - var prop_return: ?format.bufferType() = null; - - const code = c.XGetWindowProperty( - @ptrCast(@alignCast(self.app.display)), - self.x11_surface.getXid(), - name, - options.offset, - options.length, - @intFromBool(options.delete), - typ, - &actual_type_return, - &actual_format_return, - &nitems_return, - &bytes_after_return, - @ptrCast(&prop_return), - ); - if (code != c.Success) return error.RequestFailed; - - if (actual_type_return == c.None) return error.PropertyNotFound; - if (typ != actual_type_return) return error.PropertyTypeMismatch; - if (@intFromEnum(format) != actual_format_return) return error.PropertyFormatMismatch; - - const data_ptr: *T = @ptrCast(prop_return); - result.* = data_ptr.*; - _ = c.XFree(prop_return); - } - - fn changeProperty( - self: *Window, - comptime T: type, - name: c.Atom, - typ: c.Atom, - comptime format: PropertyFormat, - options: struct { - mode: PropertyChangeMode, - }, - value: *T, - ) X11Error!void { - const data: format.bufferType() = @ptrCast(value); - - const status = c.XChangeProperty( - @ptrCast(@alignCast(self.app.display)), - self.x11_surface.getXid(), - name, - typ, - @intFromEnum(format), - @intFromEnum(options.mode), - data, - @divExact(@sizeOf(T), @sizeOf(format.elemType())), - ); - - // For some godforsaken reason Xlib alternates between - // error values (0 = success) and booleans (1 = success), and they look exactly - // the same in the signature (just `int`, since Xlib is written in C89)... - if (status == 0) return error.RequestFailed; - } - - fn deleteProperty(self: *Window, name: c.Atom) X11Error!void { - const status = c.XDeleteProperty( - @ptrCast(@alignCast(self.app.display)), - self.x11_surface.getXid(), - name, - ); - if (status == 0) return error.RequestFailed; - } -}; - -const X11Error = error{ - RequestFailed, -}; - -const GetWindowPropertyError = X11Error || error{ - PropertyNotFound, - PropertyTypeMismatch, - PropertyFormatMismatch, -}; - -const Atoms = struct { - kde_blur: c.Atom, - motif_wm_hints: c.Atom, - - fn init(display: *gdk_x11.X11Display) Atoms { - return .{ - .kde_blur = gdk_x11.x11GetXatomByNameForDisplay( - display, - "_KDE_NET_WM_BLUR_BEHIND_REGION", - ), - .motif_wm_hints = gdk_x11.x11GetXatomByNameForDisplay( - display, - "_MOTIF_WM_HINTS", - ), - }; - } -}; - -const PropertyChangeMode = enum(c_int) { - replace = c.PropModeReplace, - prepend = c.PropModePrepend, - append = c.PropModeAppend, -}; - -const PropertyFormat = enum(c_int) { - _8 = 8, - _16 = 16, - _32 = 32, - - fn elemType(comptime self: PropertyFormat) type { - return switch (self) { - ._8 => c_char, - ._16 => c_int, - ._32 => c_long, - }; - } - - fn bufferType(comptime self: PropertyFormat) type { - // The buffer type has to be a multi-pointer to bytes - // *aligned to the element type* (very important, - // otherwise you'll read garbage!) - // - // I know this is really ugly. X11 is ugly. I consider it apropos. - return [*]align(@alignOf(self.elemType())) u8; - } -}; - -const Region = extern struct { - x: c_long = 0, - y: c_long = 0, - width: c_long = 0, - height: c_long = 0, -}; - -// See Xm/MwmUtil.h, packaged with the Motif Window Manager -const MotifWMHints = extern struct { - flags: packed struct(c_ulong) { - _pad: u1 = 0, - decorations: bool = false, - - // We don't really care about the other flags - _rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 2) = 0, - } = .{}, - functions: c_ulong = 0, - decorations: packed struct(c_ulong) { - all: bool = false, - - // We don't really care about the other flags - _rest: std.meta.Int(.unsigned, @bitSizeOf(c_ulong) - 1) = 0, - } = .{}, - input_mode: c_long = 0, - status: c_ulong = 0, -}; diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index b9e93e9ba..363b1f63a 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -39,13 +39,13 @@ pub const Clipboard = enum(Backing) { // Our backing isn't is as small as we can in Zig, but a full // C int if we're binding to C APIs. const Backing = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => c_int, + .@"gtk-ng" => c_int, else => u2, }; /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + .@"gtk-ng" => @import("gobject").ext.defineEnum( Clipboard, .{ .name = "GhosttyApprtClipboard" }, ), @@ -74,7 +74,7 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .@"gtk-ng" => @import("gobject").ext.defineBoxed( ClipboardRequest, .{ .name = "GhosttyClipboardRequest" }, ), diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index f76e3d05a..e571fc9f8 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -111,7 +111,6 @@ pub const Message = union(enum) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng", => @import("gobject").ext.defineBoxed( ChildExited, diff --git a/src/build/GhosttyDist.zig b/src/build/GhosttyDist.zig index 6123582b7..d889f2350 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -20,11 +20,6 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { // Get the resources we're going to inject into the source tarball. const alloc = b.allocator; var resources: std.ArrayListUnmanaged(Resource) = .empty; - { - const gtk = SharedDeps.gtkDistResources(b); - try resources.append(alloc, gtk.resources_c); - try resources.append(alloc, gtk.resources_h); - } { const gtk = SharedDeps.gtkNgDistResources(b); try resources.append(alloc, gtk.resources_c); diff --git a/src/build/GhosttyI18n.zig b/src/build/GhosttyI18n.zig index 9dcc67a31..72a553603 100644 --- a/src/build/GhosttyI18n.zig +++ b/src/build/GhosttyI18n.zig @@ -3,7 +3,7 @@ const GhosttyI18n = @This(); const std = @import("std"); const builtin = @import("builtin"); const Config = @import("Config.zig"); -const gresource = @import("../apprt/gtk/gresource.zig"); +const gresource = @import("../apprt/gtk-ng/build/gresource.zig"); const internal_os = @import("../os/main.zig"); const domain = "com.mitchellh.ghostty"; @@ -78,9 +78,9 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { // Not cacheable due to the gresource files xgettext.has_side_effects = true; - inline for (gresource.blueprint_files) |blp| { + inline for (gresource.blueprints) |blp| { const path = std.fmt.comptimePrint( - "src/apprt/gtk/ui/{[major]}.{[minor]}/{[name]s}.blp", + "src/apprt/gtk-ng/ui/{[major]}.{[minor]}/{[name]s}.blp", blp, ); // The arguments to xgettext must be the relative path in the build root @@ -105,7 +105,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { } var gtk_dir = try b.build_root.handle.openDir( - "src/apprt/gtk", + "src/apprt/gtk-ng", .{ .iterate = true }, ); defer gtk_dir.close(); @@ -138,7 +138,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { ); for (gtk_files.items) |item| { - const path = b.pathJoin(&.{ "src/apprt/gtk", item }); + const path = b.pathJoin(&.{ "src/apprt/gtk-ng", item }); // The arguments to xgettext must be the relative path in the build root // or the resulting files will contain the absolute path. This will // cause a lot of churn because not everyone has the Ghostty code diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index c03746a48..86390a496 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -550,7 +550,6 @@ pub fn add( switch (self.config.app_runtime) { .none => {}, - .gtk => try self.addGTK(step), .@"gtk-ng" => try self.addGtkNg(step), } } @@ -789,234 +788,6 @@ pub fn gtkNgDistResources( }; } -/// Setup the dependencies for the GTK apprt build. The GTK apprt -/// is particularly involved compared to others so we pull this out -/// into a dedicated function. -fn addGTK( - self: *const SharedDeps, - step: *std.Build.Step.Compile, -) !void { - const b = step.step.owner; - const target = step.root_module.resolved_target.?; - const optimize = step.root_module.optimize.?; - - const gobject_ = b.lazyDependency("gobject", .{ - .target = target, - .optimize = optimize, - }); - if (gobject_) |gobject| { - const gobject_imports = .{ - .{ "adw", "adw1" }, - .{ "gdk", "gdk4" }, - .{ "gio", "gio2" }, - .{ "glib", "glib2" }, - .{ "gobject", "gobject2" }, - .{ "gtk", "gtk4" }, - .{ "xlib", "xlib2" }, - }; - inline for (gobject_imports) |import| { - const name, const module = import; - step.root_module.addImport(name, gobject.module(module)); - } - } - - step.linkSystemLibrary2("gtk4", dynamic_link_opts); - step.linkSystemLibrary2("libadwaita-1", dynamic_link_opts); - - if (self.config.x11) { - step.linkSystemLibrary2("X11", dynamic_link_opts); - if (gobject_) |gobject| { - step.root_module.addImport( - "gdk_x11", - gobject.module("gdkx114"), - ); - } - } - - if (self.config.wayland) wayland: { - // These need to be all be called to note that we need them. - const wayland_dep_ = b.lazyDependency("wayland", .{}); - const wayland_protocols_dep_ = b.lazyDependency( - "wayland_protocols", - .{}, - ); - const plasma_wayland_protocols_dep_ = b.lazyDependency( - "plasma_wayland_protocols", - .{}, - ); - - // Unwrap or return, there are no more dependencies below. - const wayland_dep = wayland_dep_ orelse break :wayland; - const wayland_protocols_dep = wayland_protocols_dep_ orelse break :wayland; - const plasma_wayland_protocols_dep = plasma_wayland_protocols_dep_ orelse break :wayland; - - // Note that zig_wayland cannot be lazy because lazy dependencies - // can't be imported since they don't exist and imports are - // resolved at compile time of the build. - const zig_wayland_dep = b.dependency("zig_wayland", .{}); - const Scanner = @import("zig_wayland").Scanner; - const scanner = Scanner.create(zig_wayland_dep.builder, .{ - .wayland_xml = wayland_dep.path("protocol/wayland.xml"), - .wayland_protocols = wayland_protocols_dep.path(""), - }); - - scanner.addCustomProtocol( - plasma_wayland_protocols_dep.path("src/protocols/blur.xml"), - ); - // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 - scanner.addCustomProtocol( - plasma_wayland_protocols_dep.path("src/protocols/server-decoration.xml"), - ); - scanner.addCustomProtocol( - plasma_wayland_protocols_dep.path("src/protocols/slide.xml"), - ); - scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml"); - - scanner.generate("wl_compositor", 1); - scanner.generate("org_kde_kwin_blur_manager", 1); - scanner.generate("org_kde_kwin_server_decoration_manager", 1); - scanner.generate("org_kde_kwin_slide_manager", 1); - scanner.generate("xdg_activation_v1", 1); - - step.root_module.addImport("wayland", b.createModule(.{ - .root_source_file = scanner.result, - })); - if (gobject_) |gobject| step.root_module.addImport( - "gdk_wayland", - gobject.module("gdkwayland4"), - ); - - if (b.lazyDependency("gtk4_layer_shell", .{ - .target = target, - .optimize = optimize, - })) |gtk4_layer_shell| { - const layer_shell_module = gtk4_layer_shell.module("gtk4-layer-shell"); - if (gobject_) |gobject| layer_shell_module.addImport( - "gtk", - gobject.module("gtk4"), - ); - step.root_module.addImport( - "gtk4-layer-shell", - layer_shell_module, - ); - - // IMPORTANT: gtk4-layer-shell must be linked BEFORE - // wayland-client, as it relies on shimming libwayland's APIs. - if (b.systemIntegrationOption("gtk4-layer-shell", .{})) { - step.linkSystemLibrary2("gtk4-layer-shell-0", dynamic_link_opts); - } else { - // gtk4-layer-shell *must* be dynamically linked, - // so we don't add it as a static library - const shared_lib = gtk4_layer_shell.artifact("gtk4-layer-shell"); - b.installArtifact(shared_lib); - step.linkLibrary(shared_lib); - } - } - - step.linkSystemLibrary2("wayland-client", dynamic_link_opts); - } - - { - // Get our gresource c/h files and add them to our build. - const dist = gtkDistResources(b); - step.addCSourceFile(.{ .file = dist.resources_c.path(b), .flags = &.{} }); - step.addIncludePath(dist.resources_h.path(b).dirname()); - } -} - -/// Creates the resources that can be prebuilt for our dist build. -pub fn gtkDistResources( - b: *std.Build, -) struct { - resources_c: DistResource, - resources_h: DistResource, -} { - const gresource = @import("../apprt/gtk/gresource.zig"); - - const gresource_xml = gresource_xml: { - const xml_exe = b.addExecutable(.{ - .name = "generate_gresource_xml", - .root_source_file = b.path("src/apprt/gtk/gresource.zig"), - .target = b.graph.host, - }); - const xml_run = b.addRunArtifact(xml_exe); - - const blueprint_exe = b.addExecutable(.{ - .name = "gtk_blueprint_compiler", - .root_source_file = b.path("src/apprt/gtk/blueprint_compiler.zig"), - .target = b.graph.host, - }); - blueprint_exe.linkLibC(); - blueprint_exe.linkSystemLibrary2("gtk4", dynamic_link_opts); - blueprint_exe.linkSystemLibrary2("libadwaita-1", dynamic_link_opts); - - for (gresource.blueprint_files) |blueprint_file| { - const blueprint_run = b.addRunArtifact(blueprint_exe); - blueprint_run.addArgs(&.{ - b.fmt("{d}", .{blueprint_file.major}), - b.fmt("{d}", .{blueprint_file.minor}), - }); - const ui_file = blueprint_run.addOutputFileArg(b.fmt( - "{d}.{d}/{s}.ui", - .{ - blueprint_file.major, - blueprint_file.minor, - blueprint_file.name, - }, - )); - blueprint_run.addFileArg(b.path(b.fmt( - "src/apprt/gtk/ui/{d}.{d}/{s}.blp", - .{ - blueprint_file.major, - blueprint_file.minor, - blueprint_file.name, - }, - ))); - - xml_run.addFileArg(ui_file); - } - - break :gresource_xml xml_run.captureStdOut(); - }; - - const generate_c = b.addSystemCommand(&.{ - "glib-compile-resources", - "--c-name", - "ghostty", - "--generate-source", - "--target", - }); - const resources_c = generate_c.addOutputFileArg("ghostty_resources.c"); - generate_c.addFileArg(gresource_xml); - for (gresource.dependencies) |file| { - generate_c.addFileInput(b.path(file)); - } - - const generate_h = b.addSystemCommand(&.{ - "glib-compile-resources", - "--c-name", - "ghostty", - "--generate-header", - "--target", - }); - const resources_h = generate_h.addOutputFileArg("ghostty_resources.h"); - generate_h.addFileArg(gresource_xml); - for (gresource.dependencies) |file| { - generate_h.addFileInput(b.path(file)); - } - - return .{ - .resources_c = .{ - .dist = "src/apprt/gtk/ghostty_resources.c", - .generated = resources_c, - }, - .resources_h = .{ - .dist = "src/apprt/gtk/ghostty_resources.h", - .generated = resources_h, - }, - }; -} - // For dynamic linking, we prefer dynamic linking and to search by // mode first. Mode first will search all paths for a dynamic library // before falling back to static. diff --git a/src/cli/version.zig b/src/cli/version.zig index 22608fa88..2dd208180 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -7,8 +7,8 @@ const internal_os = @import("../os/main.zig"); const xev = @import("../global.zig").xev; const renderer = @import("../renderer.zig"); -const gtk_version = @import("../apprt/gtk/gtk_version.zig"); -const adw_version = @import("../apprt/gtk/adw_version.zig"); +const gtk_version = @import("../apprt/gtk-ng/gtk_version.zig"); +const adw_version = @import("../apprt/gtk-ng/adw_version.zig"); pub const Options = struct {}; @@ -38,7 +38,7 @@ pub fn run(alloc: Allocator) !u8 { try stdout.print(" - font engine : {}\n", .{build_config.font_backend}); try stdout.print(" - renderer : {}\n", .{renderer.Renderer}); try stdout.print(" - libxev : {s}\n", .{@tagName(xev.backend)}); - if (comptime build_config.app_runtime == .gtk) { + if (comptime build_config.app_runtime == .@"gtk-ng") { if (comptime builtin.os.tag == .linux) { const kernel_info = internal_os.getKernelInfo(alloc); defer if (kernel_info) |k| alloc.free(k); diff --git a/src/config/Config.zig b/src/config/Config.zig index 1ec0bafce..cea2c1a28 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -7149,7 +7149,7 @@ pub const GtkTitlebarStyle = enum(c_int) { tabs, pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + .@"gtk-ng" => @import("gobject").ext.defineEnum( GtkTitlebarStyle, .{ .name = "GhosttyGtkTitlebarStyle" }, ), @@ -7717,7 +7717,7 @@ pub const WindowDecoration = enum(c_int) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + .@"gtk-ng" => @import("gobject").ext.defineEnum( WindowDecoration, .{ .name = "GhosttyConfigWindowDecoration" }, ), diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 57da22109..5cb959af4 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -1266,7 +1266,7 @@ pub fn SplitTree(comptime V: type) type { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .@"gtk-ng" => @import("gobject").ext.defineBoxed( Self, .{ // To get the type name we get the non-qualified type name diff --git a/src/font/face.zig b/src/font/face.zig index 2902f97ae..054f542fc 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -59,7 +59,7 @@ pub const DesiredSize = struct { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .@"gtk-ng" => @import("gobject").ext.defineBoxed( DesiredSize, .{ .name = "GhosttyFontDesiredSize" }, ), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d475db539..77d93e4aa 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -744,7 +744,7 @@ pub const Action = union(enum) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .@"gtk-ng" => @import("gobject").ext.defineBoxed( Action, .{ .name = "GhosttyBindingAction" }, ), diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 908e1c828..8a1c465e9 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -165,7 +165,6 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { else => @compileError("unsupported app runtime for OpenGL"), // GTK uses global OpenGL context so we load from null. - apprt.gtk, apprt.gtk_ng, => try prepareContext(null), @@ -201,7 +200,7 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk, apprt.gtk_ng => { + apprt.gtk_ng => { // GTK doesn't support threaded OpenGL operations as far as I can // tell, so we use the renderer thread to setup all the state // but then do the actual draws and texture syncs and all that @@ -223,7 +222,7 @@ pub fn threadExit(self: *const OpenGL) void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk, apprt.gtk_ng => { + apprt.gtk_ng => { // We don't need to do any unloading for GTK because we may // be sharing the global bindings with other windows. }, @@ -238,7 +237,7 @@ pub fn displayRealized(self: *const OpenGL) void { _ = self; switch (apprt.runtime) { - apprt.gtk, apprt.gtk_ng => prepareContext(null) catch |err| { + apprt.gtk_ng => prepareContext(null) catch |err| { log.warn( "Error preparing GL context in displayRealized, err={}", .{err}, diff --git a/src/terminal/mouse_shape.zig b/src/terminal/mouse_shape.zig index e71d4fb3b..16434f3f6 100644 --- a/src/terminal/mouse_shape.zig +++ b/src/terminal/mouse_shape.zig @@ -49,7 +49,7 @@ pub const MouseShape = enum(c_int) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .gtk, .@"gtk-ng" => @import("gobject").ext.defineEnum( + .@"gtk-ng" => @import("gobject").ext.defineEnum( MouseShape, .{ .name = "GhosttyMouseShape" }, ), From bb78adbd9300cd7eb4f65a8c43aebead1a75c364 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 4 Sep 2025 23:35:52 -0500 Subject: [PATCH 31/42] devshell: add poop --- nix/devShell.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nix/devShell.nix b/nix/devShell.nix index a54e199c2..783d6018d 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -61,6 +61,7 @@ pandoc, pinact, hyperfine, + poop, typos, shellcheck, uv, @@ -187,6 +188,9 @@ in # developer shell glycin-loaders librsvg + + # for benchmarking + poop ]; # This should be set onto the rpath of the ghostty binary if you want From 93debc439ccc87e8dee82d73c49a693a368b0685 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 5 Sep 2025 10:07:36 +0200 Subject: [PATCH 32/42] gtk: the Future is Now --- .github/workflows/test.yml | 14 +++++++------- AGENTS.md | 2 +- CODEOWNERS | 2 +- src/apprt.zig | 8 ++++---- src/apprt/action.zig | 2 +- src/apprt/{gtk-ng.zig => gtk.zig} | 10 +++++----- src/apprt/{gtk-ng => gtk}/App.zig | 0 src/apprt/{gtk-ng => gtk}/Surface.zig | 0 src/apprt/{gtk-ng => gtk}/adw_version.zig | 0 src/apprt/{gtk-ng => gtk}/build/blueprint.zig | 0 src/apprt/{gtk-ng => gtk}/build/gresource.zig | 4 ++-- src/apprt/{gtk-ng => gtk}/cgroup.zig | 0 src/apprt/{gtk-ng => gtk}/class.zig | 0 src/apprt/{gtk-ng => gtk}/class/application.zig | 0 .../class/clipboard_confirmation_dialog.zig | 0 .../class/close_confirmation_dialog.zig | 0 .../{gtk-ng => gtk}/class/command_palette.zig | 0 src/apprt/{gtk-ng => gtk}/class/config.zig | 0 .../{gtk-ng => gtk}/class/config_errors_dialog.zig | 0 src/apprt/{gtk-ng => gtk}/class/debug_warning.zig | 0 src/apprt/{gtk-ng => gtk}/class/dialog.zig | 0 .../{gtk-ng => gtk}/class/global_shortcuts.zig | 0 src/apprt/{gtk-ng => gtk}/class/imgui_widget.zig | 0 .../{gtk-ng => gtk}/class/inspector_widget.zig | 0 .../{gtk-ng => gtk}/class/inspector_window.zig | 0 src/apprt/{gtk-ng => gtk}/class/resize_overlay.zig | 0 src/apprt/{gtk-ng => gtk}/class/split_tree.zig | 0 src/apprt/{gtk-ng => gtk}/class/surface.zig | 0 .../{gtk-ng => gtk}/class/surface_child_exited.zig | 0 .../{gtk-ng => gtk}/class/surface_title_dialog.zig | 0 src/apprt/{gtk-ng => gtk}/class/tab.zig | 0 src/apprt/{gtk-ng => gtk}/class/window.zig | 0 src/apprt/{gtk-ng => gtk}/css/style-dark.css | 0 src/apprt/{gtk-ng => gtk}/css/style-hc-dark.css | 0 src/apprt/{gtk-ng => gtk}/css/style-hc.css | 0 src/apprt/{gtk-ng => gtk}/css/style.css | 0 src/apprt/{gtk-ng => gtk}/ext.zig | 0 src/apprt/{gtk-ng => gtk}/ext/actions.zig | 0 src/apprt/{gtk-ng => gtk}/gtk_version.zig | 0 src/apprt/{gtk-ng => gtk}/ipc/DBus.zig | 0 src/apprt/{gtk-ng => gtk}/ipc/new_window.zig | 0 src/apprt/{gtk-ng => gtk}/key.zig | 0 .../ui/1.0/clipboard-confirmation-dialog.blp | 0 .../ui/1.2/close-confirmation-dialog.blp | 0 .../ui/1.2/config-errors-dialog.blp | 0 src/apprt/{gtk-ng => gtk}/ui/1.2/debug-warning.blp | 0 .../{gtk-ng => gtk}/ui/1.2/resize-overlay.blp | 0 src/apprt/{gtk-ng => gtk}/ui/1.2/surface.blp | 0 src/apprt/{gtk-ng => gtk}/ui/1.3/debug-warning.blp | 0 .../ui/1.3/surface-child-exited.blp | 0 .../ui/1.4/clipboard-confirmation-dialog.blp | 0 .../{gtk-ng => gtk}/ui/1.5/command-palette.blp | 0 src/apprt/{gtk-ng => gtk}/ui/1.5/imgui-widget.blp | 0 .../{gtk-ng => gtk}/ui/1.5/inspector-widget.blp | 0 .../{gtk-ng => gtk}/ui/1.5/inspector-window.blp | 0 .../{gtk-ng => gtk}/ui/1.5/split-tree-split.blp | 0 src/apprt/{gtk-ng => gtk}/ui/1.5/split-tree.blp | 0 .../ui/1.5/surface-title-dialog.blp | 0 src/apprt/{gtk-ng => gtk}/ui/1.5/tab.blp | 0 src/apprt/{gtk-ng => gtk}/ui/1.5/window.blp | 0 src/apprt/{gtk-ng => gtk}/weak_ref.zig | 0 src/apprt/{gtk-ng => gtk}/winproto.zig | 0 src/apprt/{gtk-ng => gtk}/winproto/noop.zig | 0 src/apprt/{gtk-ng => gtk}/winproto/wayland.zig | 0 src/apprt/{gtk-ng => gtk}/winproto/x11.zig | 0 src/apprt/structs.zig | 6 +++--- src/apprt/surface.zig | 2 +- src/build/GhosttyI18n.zig | 8 ++++---- src/build/SharedDeps.zig | 12 ++++++------ src/cli/version.zig | 6 +++--- src/config/Config.zig | 4 ++-- src/datastruct/split_tree.zig | 2 +- src/font/face.zig | 2 +- src/input/Binding.zig | 2 +- src/renderer/OpenGL.zig | 8 ++++---- src/terminal/mouse_shape.zig | 2 +- valgrind.supp | 8 ++++---- 77 files changed, 52 insertions(+), 52 deletions(-) rename src/apprt/{gtk-ng.zig => gtk.zig} (51%) rename src/apprt/{gtk-ng => gtk}/App.zig (100%) rename src/apprt/{gtk-ng => gtk}/Surface.zig (100%) rename src/apprt/{gtk-ng => gtk}/adw_version.zig (100%) rename src/apprt/{gtk-ng => gtk}/build/blueprint.zig (100%) rename src/apprt/{gtk-ng => gtk}/build/gresource.zig (98%) rename src/apprt/{gtk-ng => gtk}/cgroup.zig (100%) rename src/apprt/{gtk-ng => gtk}/class.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/application.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/clipboard_confirmation_dialog.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/close_confirmation_dialog.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/command_palette.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/config.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/config_errors_dialog.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/debug_warning.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/dialog.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/global_shortcuts.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/imgui_widget.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/inspector_widget.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/inspector_window.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/resize_overlay.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/split_tree.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/surface.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/surface_child_exited.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/surface_title_dialog.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/tab.zig (100%) rename src/apprt/{gtk-ng => gtk}/class/window.zig (100%) rename src/apprt/{gtk-ng => gtk}/css/style-dark.css (100%) rename src/apprt/{gtk-ng => gtk}/css/style-hc-dark.css (100%) rename src/apprt/{gtk-ng => gtk}/css/style-hc.css (100%) rename src/apprt/{gtk-ng => gtk}/css/style.css (100%) rename src/apprt/{gtk-ng => gtk}/ext.zig (100%) rename src/apprt/{gtk-ng => gtk}/ext/actions.zig (100%) rename src/apprt/{gtk-ng => gtk}/gtk_version.zig (100%) rename src/apprt/{gtk-ng => gtk}/ipc/DBus.zig (100%) rename src/apprt/{gtk-ng => gtk}/ipc/new_window.zig (100%) rename src/apprt/{gtk-ng => gtk}/key.zig (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.0/clipboard-confirmation-dialog.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.2/close-confirmation-dialog.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.2/config-errors-dialog.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.2/debug-warning.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.2/resize-overlay.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.2/surface.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.3/debug-warning.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.3/surface-child-exited.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.4/clipboard-confirmation-dialog.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.5/command-palette.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.5/imgui-widget.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.5/inspector-widget.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.5/inspector-window.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.5/split-tree-split.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.5/split-tree.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.5/surface-title-dialog.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.5/tab.blp (100%) rename src/apprt/{gtk-ng => gtk}/ui/1.5/window.blp (100%) rename src/apprt/{gtk-ng => gtk}/weak_ref.zig (100%) rename src/apprt/{gtk-ng => gtk}/winproto.zig (100%) rename src/apprt/{gtk-ng => gtk}/winproto/noop.zig (100%) rename src/apprt/{gtk-ng => gtk}/winproto/wayland.zig (100%) rename src/apprt/{gtk-ng => gtk}/winproto/x11.zig (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ec50e494..a2b2a84aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: - build-macos-matrix - build-windows - test - - test-gtk-ng + - test-gtk - test-sentry-linux - test-macos - pinact @@ -491,14 +491,14 @@ jobs: - name: test run: nix develop -c zig build -Dapp-runtime=none test - - name: Test GTK-NG Build - run: nix develop -c zig build -Dapp-runtime=gtk-ng -Demit-docs -Demit-webdata + - name: Test GTK Build + run: nix develop -c zig build -Dapp-runtime=gtk -Demit-docs -Demit-webdata # This relies on the cache being populated by the commands above. - name: Test System Build run: nix develop -c zig build --system ${ZIG_GLOBAL_CACHE_DIR}/p - test-gtk-ng: + test-gtk: strategy: fail-fast: false matrix: @@ -534,7 +534,7 @@ jobs: run: | nix develop -c \ zig build \ - -Dapp-runtime=gtk-ng \ + -Dapp-runtime=gtk \ -Dgtk-x11=${{ matrix.x11 }} \ -Dgtk-wayland=${{ matrix.wayland }} \ test @@ -543,7 +543,7 @@ jobs: run: | nix develop -c \ zig build \ - -Dapp-runtime=gtk-ng \ + -Dapp-runtime=gtk \ -Dgtk-x11=${{ matrix.x11 }} \ -Dgtk-wayland=${{ matrix.wayland }} @@ -1011,7 +1011,7 @@ jobs: cd $GITHUB_WORKSPACE zig build test - - name: Build GTK-NG app runtime + - name: Build GTK app runtime shell: freebsd {0} run: | cd $GITHUB_WORKSPACE diff --git a/AGENTS.md b/AGENTS.md index 00faaf81c..2e90fd94e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ A file for [guiding coding agents](https://agents.md/). - Shared Zig core: `src/` - C API: `include/ghostty.h` - macOS app: `macos/` -- GTK (Linux and FreeBSD) app: `src/apprt/gtk-ng` +- GTK (Linux and FreeBSD) app: `src/apprt/gtk` ## macOS App diff --git a/CODEOWNERS b/CODEOWNERS index 0f7e18ed8..2a93ce671 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -118,7 +118,7 @@ /pkg/harfbuzz/ @ghostty-org/font # GTK -/src/apprt/gtk-ng/ @ghostty-org/gtk +/src/apprt/gtk/ @ghostty-org/gtk /src/os/cgroup.zig @ghostty-org/gtk /src/os/flatpak.zig @ghostty-org/gtk /dist/linux/ @ghostty-org/gtk diff --git a/src/apprt.zig b/src/apprt.zig index cbde56312..ccb1251a2 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -16,7 +16,7 @@ const structs = @import("apprt/structs.zig"); pub const action = @import("apprt/action.zig"); pub const ipc = @import("apprt/ipc.zig"); -pub const gtk_ng = @import("apprt/gtk-ng.zig"); +pub const gtk = @import("apprt/gtk.zig"); pub const none = @import("apprt/none.zig"); pub const browser = @import("apprt/browser.zig"); pub const embedded = @import("apprt/embedded.zig"); @@ -42,7 +42,7 @@ pub const SurfaceSize = structs.SurfaceSize; pub const runtime = switch (build_config.artifact) { .exe => switch (build_config.app_runtime) { .none => none, - .@"gtk-ng" => gtk_ng, + .gtk => gtk, }, .lib => embedded, .wasm_module => browser, @@ -60,13 +60,13 @@ pub const Runtime = enum { /// GTK4. Rich windowed application. This uses a full GObject-based /// approach to building the application. - @"gtk-ng", + gtk, pub fn default(target: std.Target) Runtime { return switch (target.os.tag) { // The Linux and FreeBSD default is GTK because it is a full // featured application. - .linux, .freebsd => .@"gtk-ng", + .linux, .freebsd => .gtk, // Otherwise, we do NONE so we don't create an exe and we create // libghostty. On macOS, Xcode is used to build the app that links // to libghostty. diff --git a/src/apprt/action.zig b/src/apprt/action.zig index fdd328a24..fbcc92805 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -542,7 +542,7 @@ pub const InitialSize = extern struct { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .gtk => @import("gobject").ext.defineBoxed( InitialSize, .{ .name = "GhosttyApprtInitialSize" }, ), diff --git a/src/apprt/gtk-ng.zig b/src/apprt/gtk.zig similarity index 51% rename from src/apprt/gtk-ng.zig rename to src/apprt/gtk.zig index fe1bac023..212892094 100644 --- a/src/apprt/gtk-ng.zig +++ b/src/apprt/gtk.zig @@ -1,15 +1,15 @@ const internal_os = @import("../os/main.zig"); // The required comptime API for any apprt. -pub const App = @import("gtk-ng/App.zig"); -pub const Surface = @import("gtk-ng/Surface.zig"); +pub const App = @import("gtk/App.zig"); +pub const Surface = @import("gtk/Surface.zig"); pub const resourcesDir = internal_os.resourcesDir; // The exported API, custom for the apprt. -pub const class = @import("gtk-ng/class.zig"); -pub const WeakRef = @import("gtk-ng/weak_ref.zig").WeakRef; +pub const class = @import("gtk/class.zig"); +pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef; test { @import("std").testing.refAllDecls(@This()); - _ = @import("gtk-ng/ext.zig"); + _ = @import("gtk/ext.zig"); } diff --git a/src/apprt/gtk-ng/App.zig b/src/apprt/gtk/App.zig similarity index 100% rename from src/apprt/gtk-ng/App.zig rename to src/apprt/gtk/App.zig diff --git a/src/apprt/gtk-ng/Surface.zig b/src/apprt/gtk/Surface.zig similarity index 100% rename from src/apprt/gtk-ng/Surface.zig rename to src/apprt/gtk/Surface.zig diff --git a/src/apprt/gtk-ng/adw_version.zig b/src/apprt/gtk/adw_version.zig similarity index 100% rename from src/apprt/gtk-ng/adw_version.zig rename to src/apprt/gtk/adw_version.zig diff --git a/src/apprt/gtk-ng/build/blueprint.zig b/src/apprt/gtk/build/blueprint.zig similarity index 100% rename from src/apprt/gtk-ng/build/blueprint.zig rename to src/apprt/gtk/build/blueprint.zig diff --git a/src/apprt/gtk-ng/build/gresource.zig b/src/apprt/gtk/build/gresource.zig similarity index 98% rename from src/apprt/gtk-ng/build/gresource.zig rename to src/apprt/gtk/build/gresource.zig index 3cd385483..1f253fd5e 100644 --- a/src/apprt/gtk-ng/build/gresource.zig +++ b/src/apprt/gtk/build/gresource.zig @@ -14,10 +14,10 @@ pub const app_id = "com.mitchellh.ghostty"; /// The path to the Blueprint files. The folder structure is expected to be /// `{version}/{name}.blp` where `version` is the major and minor /// minimum adwaita version. -pub const ui_path = "src/apprt/gtk-ng/ui"; +pub const ui_path = "src/apprt/gtk/ui"; /// The path to the CSS files. -pub const css_path = "src/apprt/gtk-ng/css"; +pub const css_path = "src/apprt/gtk/css"; /// The possible icon sizes we'll embed into the gresource file. /// If any size doesn't exist then it will be an error. We could diff --git a/src/apprt/gtk-ng/cgroup.zig b/src/apprt/gtk/cgroup.zig similarity index 100% rename from src/apprt/gtk-ng/cgroup.zig rename to src/apprt/gtk/cgroup.zig diff --git a/src/apprt/gtk-ng/class.zig b/src/apprt/gtk/class.zig similarity index 100% rename from src/apprt/gtk-ng/class.zig rename to src/apprt/gtk/class.zig diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk/class/application.zig similarity index 100% rename from src/apprt/gtk-ng/class/application.zig rename to src/apprt/gtk/class/application.zig diff --git a/src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig similarity index 100% rename from src/apprt/gtk-ng/class/clipboard_confirmation_dialog.zig rename to src/apprt/gtk/class/clipboard_confirmation_dialog.zig diff --git a/src/apprt/gtk-ng/class/close_confirmation_dialog.zig b/src/apprt/gtk/class/close_confirmation_dialog.zig similarity index 100% rename from src/apprt/gtk-ng/class/close_confirmation_dialog.zig rename to src/apprt/gtk/class/close_confirmation_dialog.zig diff --git a/src/apprt/gtk-ng/class/command_palette.zig b/src/apprt/gtk/class/command_palette.zig similarity index 100% rename from src/apprt/gtk-ng/class/command_palette.zig rename to src/apprt/gtk/class/command_palette.zig diff --git a/src/apprt/gtk-ng/class/config.zig b/src/apprt/gtk/class/config.zig similarity index 100% rename from src/apprt/gtk-ng/class/config.zig rename to src/apprt/gtk/class/config.zig diff --git a/src/apprt/gtk-ng/class/config_errors_dialog.zig b/src/apprt/gtk/class/config_errors_dialog.zig similarity index 100% rename from src/apprt/gtk-ng/class/config_errors_dialog.zig rename to src/apprt/gtk/class/config_errors_dialog.zig diff --git a/src/apprt/gtk-ng/class/debug_warning.zig b/src/apprt/gtk/class/debug_warning.zig similarity index 100% rename from src/apprt/gtk-ng/class/debug_warning.zig rename to src/apprt/gtk/class/debug_warning.zig diff --git a/src/apprt/gtk-ng/class/dialog.zig b/src/apprt/gtk/class/dialog.zig similarity index 100% rename from src/apprt/gtk-ng/class/dialog.zig rename to src/apprt/gtk/class/dialog.zig diff --git a/src/apprt/gtk-ng/class/global_shortcuts.zig b/src/apprt/gtk/class/global_shortcuts.zig similarity index 100% rename from src/apprt/gtk-ng/class/global_shortcuts.zig rename to src/apprt/gtk/class/global_shortcuts.zig diff --git a/src/apprt/gtk-ng/class/imgui_widget.zig b/src/apprt/gtk/class/imgui_widget.zig similarity index 100% rename from src/apprt/gtk-ng/class/imgui_widget.zig rename to src/apprt/gtk/class/imgui_widget.zig diff --git a/src/apprt/gtk-ng/class/inspector_widget.zig b/src/apprt/gtk/class/inspector_widget.zig similarity index 100% rename from src/apprt/gtk-ng/class/inspector_widget.zig rename to src/apprt/gtk/class/inspector_widget.zig diff --git a/src/apprt/gtk-ng/class/inspector_window.zig b/src/apprt/gtk/class/inspector_window.zig similarity index 100% rename from src/apprt/gtk-ng/class/inspector_window.zig rename to src/apprt/gtk/class/inspector_window.zig diff --git a/src/apprt/gtk-ng/class/resize_overlay.zig b/src/apprt/gtk/class/resize_overlay.zig similarity index 100% rename from src/apprt/gtk-ng/class/resize_overlay.zig rename to src/apprt/gtk/class/resize_overlay.zig diff --git a/src/apprt/gtk-ng/class/split_tree.zig b/src/apprt/gtk/class/split_tree.zig similarity index 100% rename from src/apprt/gtk-ng/class/split_tree.zig rename to src/apprt/gtk/class/split_tree.zig diff --git a/src/apprt/gtk-ng/class/surface.zig b/src/apprt/gtk/class/surface.zig similarity index 100% rename from src/apprt/gtk-ng/class/surface.zig rename to src/apprt/gtk/class/surface.zig diff --git a/src/apprt/gtk-ng/class/surface_child_exited.zig b/src/apprt/gtk/class/surface_child_exited.zig similarity index 100% rename from src/apprt/gtk-ng/class/surface_child_exited.zig rename to src/apprt/gtk/class/surface_child_exited.zig diff --git a/src/apprt/gtk-ng/class/surface_title_dialog.zig b/src/apprt/gtk/class/surface_title_dialog.zig similarity index 100% rename from src/apprt/gtk-ng/class/surface_title_dialog.zig rename to src/apprt/gtk/class/surface_title_dialog.zig diff --git a/src/apprt/gtk-ng/class/tab.zig b/src/apprt/gtk/class/tab.zig similarity index 100% rename from src/apprt/gtk-ng/class/tab.zig rename to src/apprt/gtk/class/tab.zig diff --git a/src/apprt/gtk-ng/class/window.zig b/src/apprt/gtk/class/window.zig similarity index 100% rename from src/apprt/gtk-ng/class/window.zig rename to src/apprt/gtk/class/window.zig diff --git a/src/apprt/gtk-ng/css/style-dark.css b/src/apprt/gtk/css/style-dark.css similarity index 100% rename from src/apprt/gtk-ng/css/style-dark.css rename to src/apprt/gtk/css/style-dark.css diff --git a/src/apprt/gtk-ng/css/style-hc-dark.css b/src/apprt/gtk/css/style-hc-dark.css similarity index 100% rename from src/apprt/gtk-ng/css/style-hc-dark.css rename to src/apprt/gtk/css/style-hc-dark.css diff --git a/src/apprt/gtk-ng/css/style-hc.css b/src/apprt/gtk/css/style-hc.css similarity index 100% rename from src/apprt/gtk-ng/css/style-hc.css rename to src/apprt/gtk/css/style-hc.css diff --git a/src/apprt/gtk-ng/css/style.css b/src/apprt/gtk/css/style.css similarity index 100% rename from src/apprt/gtk-ng/css/style.css rename to src/apprt/gtk/css/style.css diff --git a/src/apprt/gtk-ng/ext.zig b/src/apprt/gtk/ext.zig similarity index 100% rename from src/apprt/gtk-ng/ext.zig rename to src/apprt/gtk/ext.zig diff --git a/src/apprt/gtk-ng/ext/actions.zig b/src/apprt/gtk/ext/actions.zig similarity index 100% rename from src/apprt/gtk-ng/ext/actions.zig rename to src/apprt/gtk/ext/actions.zig diff --git a/src/apprt/gtk-ng/gtk_version.zig b/src/apprt/gtk/gtk_version.zig similarity index 100% rename from src/apprt/gtk-ng/gtk_version.zig rename to src/apprt/gtk/gtk_version.zig diff --git a/src/apprt/gtk-ng/ipc/DBus.zig b/src/apprt/gtk/ipc/DBus.zig similarity index 100% rename from src/apprt/gtk-ng/ipc/DBus.zig rename to src/apprt/gtk/ipc/DBus.zig diff --git a/src/apprt/gtk-ng/ipc/new_window.zig b/src/apprt/gtk/ipc/new_window.zig similarity index 100% rename from src/apprt/gtk-ng/ipc/new_window.zig rename to src/apprt/gtk/ipc/new_window.zig diff --git a/src/apprt/gtk-ng/key.zig b/src/apprt/gtk/key.zig similarity index 100% rename from src/apprt/gtk-ng/key.zig rename to src/apprt/gtk/key.zig diff --git a/src/apprt/gtk-ng/ui/1.0/clipboard-confirmation-dialog.blp b/src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.0/clipboard-confirmation-dialog.blp rename to src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp diff --git a/src/apprt/gtk-ng/ui/1.2/close-confirmation-dialog.blp b/src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.2/close-confirmation-dialog.blp rename to src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp diff --git a/src/apprt/gtk-ng/ui/1.2/config-errors-dialog.blp b/src/apprt/gtk/ui/1.2/config-errors-dialog.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.2/config-errors-dialog.blp rename to src/apprt/gtk/ui/1.2/config-errors-dialog.blp diff --git a/src/apprt/gtk-ng/ui/1.2/debug-warning.blp b/src/apprt/gtk/ui/1.2/debug-warning.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.2/debug-warning.blp rename to src/apprt/gtk/ui/1.2/debug-warning.blp diff --git a/src/apprt/gtk-ng/ui/1.2/resize-overlay.blp b/src/apprt/gtk/ui/1.2/resize-overlay.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.2/resize-overlay.blp rename to src/apprt/gtk/ui/1.2/resize-overlay.blp diff --git a/src/apprt/gtk-ng/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.2/surface.blp rename to src/apprt/gtk/ui/1.2/surface.blp diff --git a/src/apprt/gtk-ng/ui/1.3/debug-warning.blp b/src/apprt/gtk/ui/1.3/debug-warning.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.3/debug-warning.blp rename to src/apprt/gtk/ui/1.3/debug-warning.blp diff --git a/src/apprt/gtk-ng/ui/1.3/surface-child-exited.blp b/src/apprt/gtk/ui/1.3/surface-child-exited.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.3/surface-child-exited.blp rename to src/apprt/gtk/ui/1.3/surface-child-exited.blp diff --git a/src/apprt/gtk-ng/ui/1.4/clipboard-confirmation-dialog.blp b/src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.4/clipboard-confirmation-dialog.blp rename to src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp diff --git a/src/apprt/gtk-ng/ui/1.5/command-palette.blp b/src/apprt/gtk/ui/1.5/command-palette.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/command-palette.blp rename to src/apprt/gtk/ui/1.5/command-palette.blp diff --git a/src/apprt/gtk-ng/ui/1.5/imgui-widget.blp b/src/apprt/gtk/ui/1.5/imgui-widget.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/imgui-widget.blp rename to src/apprt/gtk/ui/1.5/imgui-widget.blp diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-widget.blp b/src/apprt/gtk/ui/1.5/inspector-widget.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/inspector-widget.blp rename to src/apprt/gtk/ui/1.5/inspector-widget.blp diff --git a/src/apprt/gtk-ng/ui/1.5/inspector-window.blp b/src/apprt/gtk/ui/1.5/inspector-window.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/inspector-window.blp rename to src/apprt/gtk/ui/1.5/inspector-window.blp diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree-split.blp b/src/apprt/gtk/ui/1.5/split-tree-split.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/split-tree-split.blp rename to src/apprt/gtk/ui/1.5/split-tree-split.blp diff --git a/src/apprt/gtk-ng/ui/1.5/split-tree.blp b/src/apprt/gtk/ui/1.5/split-tree.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/split-tree.blp rename to src/apprt/gtk/ui/1.5/split-tree.blp diff --git a/src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp b/src/apprt/gtk/ui/1.5/surface-title-dialog.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/surface-title-dialog.blp rename to src/apprt/gtk/ui/1.5/surface-title-dialog.blp diff --git a/src/apprt/gtk-ng/ui/1.5/tab.blp b/src/apprt/gtk/ui/1.5/tab.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/tab.blp rename to src/apprt/gtk/ui/1.5/tab.blp diff --git a/src/apprt/gtk-ng/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp similarity index 100% rename from src/apprt/gtk-ng/ui/1.5/window.blp rename to src/apprt/gtk/ui/1.5/window.blp diff --git a/src/apprt/gtk-ng/weak_ref.zig b/src/apprt/gtk/weak_ref.zig similarity index 100% rename from src/apprt/gtk-ng/weak_ref.zig rename to src/apprt/gtk/weak_ref.zig diff --git a/src/apprt/gtk-ng/winproto.zig b/src/apprt/gtk/winproto.zig similarity index 100% rename from src/apprt/gtk-ng/winproto.zig rename to src/apprt/gtk/winproto.zig diff --git a/src/apprt/gtk-ng/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig similarity index 100% rename from src/apprt/gtk-ng/winproto/noop.zig rename to src/apprt/gtk/winproto/noop.zig diff --git a/src/apprt/gtk-ng/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig similarity index 100% rename from src/apprt/gtk-ng/winproto/wayland.zig rename to src/apprt/gtk/winproto/wayland.zig diff --git a/src/apprt/gtk-ng/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig similarity index 100% rename from src/apprt/gtk-ng/winproto/x11.zig rename to src/apprt/gtk/winproto/x11.zig diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 363b1f63a..89b8c2235 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -39,13 +39,13 @@ pub const Clipboard = enum(Backing) { // Our backing isn't is as small as we can in Zig, but a full // C int if we're binding to C APIs. const Backing = switch (build_config.app_runtime) { - .@"gtk-ng" => c_int, + .gtk => c_int, else => u2, }; /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .@"gtk-ng" => @import("gobject").ext.defineEnum( + .gtk => @import("gobject").ext.defineEnum( Clipboard, .{ .name = "GhosttyApprtClipboard" }, ), @@ -74,7 +74,7 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .gtk => @import("gobject").ext.defineBoxed( ClipboardRequest, .{ .name = "GhosttyClipboardRequest" }, ), diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index e571fc9f8..a4070c668 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -111,7 +111,7 @@ pub const Message = union(enum) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .@"gtk-ng", + .gtk, => @import("gobject").ext.defineBoxed( ChildExited, .{ .name = "GhosttyApprtChildExited" }, diff --git a/src/build/GhosttyI18n.zig b/src/build/GhosttyI18n.zig index 72a553603..324cc94c2 100644 --- a/src/build/GhosttyI18n.zig +++ b/src/build/GhosttyI18n.zig @@ -3,7 +3,7 @@ const GhosttyI18n = @This(); const std = @import("std"); const builtin = @import("builtin"); const Config = @import("Config.zig"); -const gresource = @import("../apprt/gtk-ng/build/gresource.zig"); +const gresource = @import("../apprt/gtk/build/gresource.zig"); const internal_os = @import("../os/main.zig"); const domain = "com.mitchellh.ghostty"; @@ -80,7 +80,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { inline for (gresource.blueprints) |blp| { const path = std.fmt.comptimePrint( - "src/apprt/gtk-ng/ui/{[major]}.{[minor]}/{[name]s}.blp", + "src/apprt/gtk/ui/{[major]}.{[minor]}/{[name]s}.blp", blp, ); // The arguments to xgettext must be the relative path in the build root @@ -105,7 +105,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { } var gtk_dir = try b.build_root.handle.openDir( - "src/apprt/gtk-ng", + "src/apprt/gtk", .{ .iterate = true }, ); defer gtk_dir.close(); @@ -138,7 +138,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { ); for (gtk_files.items) |item| { - const path = b.pathJoin(&.{ "src/apprt/gtk-ng", item }); + const path = b.pathJoin(&.{ "src/apprt/gtk", item }); // The arguments to xgettext must be the relative path in the build root // or the resulting files will contain the absolute path. This will // cause a lot of churn because not everyone has the Ghostty code diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 86390a496..9bec7243b 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -550,7 +550,7 @@ pub fn add( switch (self.config.app_runtime) { .none => {}, - .@"gtk-ng" => try self.addGtkNg(step), + .gtk => try self.addGtkNg(step), } } @@ -701,11 +701,11 @@ pub fn gtkNgDistResources( resources_c: DistResource, resources_h: DistResource, } { - const gresource = @import("../apprt/gtk-ng/build/gresource.zig"); + const gresource = @import("../apprt/gtk/build/gresource.zig"); const gresource_xml = gresource_xml: { const xml_exe = b.addExecutable(.{ .name = "generate_gresource_xml", - .root_source_file = b.path("src/apprt/gtk-ng/build/gresource.zig"), + .root_source_file = b.path("src/apprt/gtk/build/gresource.zig"), .target = b.graph.host, }); const xml_run = b.addRunArtifact(xml_exe); @@ -713,7 +713,7 @@ pub fn gtkNgDistResources( // Run our blueprint compiler across all of our blueprint files. const blueprint_exe = b.addExecutable(.{ .name = "gtk_blueprint_compiler", - .root_source_file = b.path("src/apprt/gtk-ng/build/blueprint.zig"), + .root_source_file = b.path("src/apprt/gtk/build/blueprint.zig"), .target = b.graph.host, }); blueprint_exe.linkLibC(); @@ -778,11 +778,11 @@ pub fn gtkNgDistResources( return .{ .resources_c = .{ - .dist = "src/apprt/gtk-ng/ghostty_resources.c", + .dist = "src/apprt/gtk/ghostty_resources.c", .generated = resources_c, }, .resources_h = .{ - .dist = "src/apprt/gtk-ng/ghostty_resources.h", + .dist = "src/apprt/gtk/ghostty_resources.h", .generated = resources_h, }, }; diff --git a/src/cli/version.zig b/src/cli/version.zig index 2dd208180..22608fa88 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -7,8 +7,8 @@ const internal_os = @import("../os/main.zig"); const xev = @import("../global.zig").xev; const renderer = @import("../renderer.zig"); -const gtk_version = @import("../apprt/gtk-ng/gtk_version.zig"); -const adw_version = @import("../apprt/gtk-ng/adw_version.zig"); +const gtk_version = @import("../apprt/gtk/gtk_version.zig"); +const adw_version = @import("../apprt/gtk/adw_version.zig"); pub const Options = struct {}; @@ -38,7 +38,7 @@ pub fn run(alloc: Allocator) !u8 { try stdout.print(" - font engine : {}\n", .{build_config.font_backend}); try stdout.print(" - renderer : {}\n", .{renderer.Renderer}); try stdout.print(" - libxev : {s}\n", .{@tagName(xev.backend)}); - if (comptime build_config.app_runtime == .@"gtk-ng") { + if (comptime build_config.app_runtime == .gtk) { if (comptime builtin.os.tag == .linux) { const kernel_info = internal_os.getKernelInfo(alloc); defer if (kernel_info) |k| alloc.free(k); diff --git a/src/config/Config.zig b/src/config/Config.zig index cea2c1a28..f9d8fcf7e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -7149,7 +7149,7 @@ pub const GtkTitlebarStyle = enum(c_int) { tabs, pub const getGObjectType = switch (build_config.app_runtime) { - .@"gtk-ng" => @import("gobject").ext.defineEnum( + .gtk => @import("gobject").ext.defineEnum( GtkTitlebarStyle, .{ .name = "GhosttyGtkTitlebarStyle" }, ), @@ -7717,7 +7717,7 @@ pub const WindowDecoration = enum(c_int) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .@"gtk-ng" => @import("gobject").ext.defineEnum( + .gtk => @import("gobject").ext.defineEnum( WindowDecoration, .{ .name = "GhosttyConfigWindowDecoration" }, ), diff --git a/src/datastruct/split_tree.zig b/src/datastruct/split_tree.zig index 5cb959af4..28b45ceed 100644 --- a/src/datastruct/split_tree.zig +++ b/src/datastruct/split_tree.zig @@ -1266,7 +1266,7 @@ pub fn SplitTree(comptime V: type) type { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .gtk => @import("gobject").ext.defineBoxed( Self, .{ // To get the type name we get the non-qualified type name diff --git a/src/font/face.zig b/src/font/face.zig index 054f542fc..9da3c30f6 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -59,7 +59,7 @@ pub const DesiredSize = struct { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .gtk => @import("gobject").ext.defineBoxed( DesiredSize, .{ .name = "GhosttyFontDesiredSize" }, ), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 77d93e4aa..02feeaa99 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -744,7 +744,7 @@ pub const Action = union(enum) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .@"gtk-ng" => @import("gobject").ext.defineBoxed( + .gtk => @import("gobject").ext.defineBoxed( Action, .{ .name = "GhosttyBindingAction" }, ), diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 8a1c465e9..e572806d1 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -165,7 +165,7 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { else => @compileError("unsupported app runtime for OpenGL"), // GTK uses global OpenGL context so we load from null. - apprt.gtk_ng, + apprt.gtk, => try prepareContext(null), apprt.embedded => { @@ -200,7 +200,7 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk_ng => { + apprt.gtk => { // GTK doesn't support threaded OpenGL operations as far as I can // tell, so we use the renderer thread to setup all the state // but then do the actual draws and texture syncs and all that @@ -222,7 +222,7 @@ pub fn threadExit(self: *const OpenGL) void { switch (apprt.runtime) { else => @compileError("unsupported app runtime for OpenGL"), - apprt.gtk_ng => { + apprt.gtk => { // We don't need to do any unloading for GTK because we may // be sharing the global bindings with other windows. }, @@ -237,7 +237,7 @@ pub fn displayRealized(self: *const OpenGL) void { _ = self; switch (apprt.runtime) { - apprt.gtk_ng => prepareContext(null) catch |err| { + apprt.gtk => prepareContext(null) catch |err| { log.warn( "Error preparing GL context in displayRealized, err={}", .{err}, diff --git a/src/terminal/mouse_shape.zig b/src/terminal/mouse_shape.zig index 16434f3f6..23ab215d6 100644 --- a/src/terminal/mouse_shape.zig +++ b/src/terminal/mouse_shape.zig @@ -49,7 +49,7 @@ pub const MouseShape = enum(c_int) { /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { - .@"gtk-ng" => @import("gobject").ext.defineEnum( + .gtk => @import("gobject").ext.defineEnum( MouseShape, .{ .name = "GhosttyMouseShape" }, ), diff --git a/valgrind.supp b/valgrind.supp index bfc78bcff..eeb395d03 100644 --- a/valgrind.supp +++ b/valgrind.supp @@ -143,8 +143,8 @@ fun:g_main_context_dispatch_unlocked fun:g_main_context_iterate_unlocked.isra.0 fun:g_main_context_iteration - fun:apprt.gtk-ng.class.application.Application.run - fun:apprt.gtk-ng.App.run + fun:apprt.gtk.class.application.Application.run + fun:apprt.gtk.App.run fun:main_ghostty.main fun:callMain fun:callMainWithArgs @@ -177,8 +177,8 @@ fun:g_main_context_dispatch_unlocked fun:g_main_context_iterate_unlocked.isra.0 fun:g_main_context_iteration - fun:apprt.gtk-ng.class.application.Application.run - fun:apprt.gtk-ng.App.run + fun:apprt.gtk.class.application.Application.run + fun:apprt.gtk.App.run fun:main_ghostty.main fun:callMain fun:callMainWithArgs From a7da96faeea305d5ff1758a98557a42afe0fed32 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 4 Sep 2025 23:04:08 -0500 Subject: [PATCH 33/42] add two LUT-based implementations of isSymbol --- src/benchmark/IsSymbol.zig | 172 +++++++++++++++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/benchmark/main.zig | 1 + src/build/Config.zig | 7 ++ src/build/SharedDeps.zig | 1 + src/build/UnicodeTables.zig | 73 +++++++++++--- src/renderer/cell.zig | 12 +-- src/unicode/lut.zig | 26 +++++ src/unicode/lut2.zig | 183 ++++++++++++++++++++++++++++++++++++ src/unicode/main.zig | 2 + src/unicode/props.zig | 2 +- src/unicode/symbols1.zig | 93 ++++++++++++++++++ src/unicode/symbols2.zig | 85 +++++++++++++++++ 13 files changed, 634 insertions(+), 25 deletions(-) create mode 100644 src/benchmark/IsSymbol.zig create mode 100644 src/unicode/lut2.zig create mode 100644 src/unicode/symbols1.zig create mode 100644 src/unicode/symbols2.zig diff --git a/src/benchmark/IsSymbol.zig b/src/benchmark/IsSymbol.zig new file mode 100644 index 000000000..46ebb8c66 --- /dev/null +++ b/src/benchmark/IsSymbol.zig @@ -0,0 +1,172 @@ +//! This benchmark tests the throughput of grapheme break calculation. +//! This is a common operation in terminal character printing for terminals +//! that support grapheme clustering. +const IsSymbol = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const UTF8Decoder = @import("../terminal/UTF8Decoder.zig"); +const symbols1 = @import("../unicode/symbols1.zig"); +const symbols2 = @import("../unicode/symbols2.zig"); + +const log = std.log.scoped(.@"is-symbol-bench"); + +opts: Options, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +pub const Options = struct { + /// Which test to run. + mode: Mode = .ziglyph, + + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + data: ?[]const u8 = null, +}; + +pub const Mode = enum { + /// "Naive" ziglyph implementation. + ziglyph, + + /// Ghostty's table-based approach. + table1, + table2, +}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*IsSymbol { + const ptr = try alloc.create(IsSymbol); + errdefer alloc.destroy(ptr); + ptr.* = .{ .opts = opts }; + return ptr; +} + +pub fn destroy(self: *IsSymbol, alloc: Allocator) void { + alloc.destroy(self); +} + +pub fn benchmark(self: *IsSymbol) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .ziglyph => stepZiglyph, + .table1 => stepTable1, + .table2 => stepTable1, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *IsSymbol = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn teardown(ptr: *anyopaque) void { + const self: *IsSymbol = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn stepZiglyph(ptr: *anyopaque) Benchmark.Error!void { + const self: *IsSymbol = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + var d: UTF8Decoder = .{}; + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + + for (buf[0..n]) |c| { + const cp_, const consumed = d.next(c); + assert(consumed); + if (cp_) |cp| { + std.mem.doNotOptimizeAway(symbols1.isSymbol(cp)); + } + } + } +} + +fn stepTable1(ptr: *anyopaque) Benchmark.Error!void { + const self: *IsSymbol = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + var d: UTF8Decoder = .{}; + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + + for (buf[0..n]) |c| { + const cp_, const consumed = d.next(c); + assert(consumed); + if (cp_) |cp| { + std.mem.doNotOptimizeAway(symbols1.table.get(cp)); + } + } + } +} + +fn stepTable2(ptr: *anyopaque) Benchmark.Error!void { + const self: *IsSymbol = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + var d: UTF8Decoder = .{}; + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + + for (buf[0..n]) |c| { + const cp_, const consumed = d.next(c); + assert(consumed); + if (cp_) |cp| { + std.mem.doNotOptimizeAway(symbols2.table.get(cp)); + } + } + } +} + +test IsSymbol { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *IsSymbol = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 97bb9c683..3b1c905eb 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -10,6 +10,7 @@ pub const Action = enum { @"grapheme-break", @"terminal-parser", @"terminal-stream", + @"is-symbol", /// Returns the struct associated with the action. The struct /// should have a few decls: @@ -25,6 +26,7 @@ pub const Action = enum { .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), .@"terminal-parser" => @import("TerminalParser.zig"), + .@"is-symbol" => @import("IsSymbol.zig"), }; } }; diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 49bb17289..3a59125fc 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -5,6 +5,7 @@ pub const TerminalStream = @import("TerminalStream.zig"); pub const CodepointWidth = @import("CodepointWidth.zig"); pub const GraphemeBreak = @import("GraphemeBreak.zig"); pub const TerminalParser = @import("TerminalParser.zig"); +pub const IsSymbol = @import("IsSymbol.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/build/Config.zig b/src/build/Config.zig index fd892f16c..b11e8850d 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -61,6 +61,7 @@ emit_termcap: bool = false, emit_test_exe: bool = false, emit_xcframework: bool = false, emit_webdata: bool = false, +emit_unicode_table_gen: bool = false, /// Environmental properties env: std.process.EnvMap, @@ -299,6 +300,12 @@ pub fn init(b: *std.Build) !Config { "Build and install test executables with 'build'", ) orelse false; + config.emit_unicode_table_gen = b.option( + bool, + "emit-unicode-table-gen", + "Build and install executables that generate unicode tables with 'build'", + ) orelse false; + config.emit_bench = b.option( bool, "emit-bench", diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 86390a496..af826d964 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -31,6 +31,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps { .metallib = undefined, }; try result.initTarget(b, cfg.target); + if (cfg.emit_unicode_table_gen) result.unicode_tables.install(b); return result; } diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig index 5bba2341b..dd9a6bdf2 100644 --- a/src/build/UnicodeTables.zig +++ b/src/build/UnicodeTables.zig @@ -4,14 +4,18 @@ const std = @import("std"); const Config = @import("Config.zig"); /// The exe. -exe: *std.Build.Step.Compile, +props_exe: *std.Build.Step.Compile, +symbols1_exe: *std.Build.Step.Compile, +symbols2_exe: *std.Build.Step.Compile, /// The output path for the unicode tables -output: std.Build.LazyPath, +props_output: std.Build.LazyPath, +symbols1_output: std.Build.LazyPath, +symbols2_output: std.Build.LazyPath, pub fn init(b: *std.Build) !UnicodeTables { - const exe = b.addExecutable(.{ - .name = "unigen", + const props_exe = b.addExecutable(.{ + .name = "props-unigen", .root_module = b.createModule(.{ .root_source_file = b.path("src/unicode/props.zig"), .target = b.graph.host, @@ -21,31 +25,72 @@ pub fn init(b: *std.Build) !UnicodeTables { }), }); + const symbols1_exe = b.addExecutable(.{ + .name = "symbols1-unigen", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/unicode/symbols1.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), + }); + + const symbols2_exe = b.addExecutable(.{ + .name = "symbols2-unigen", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/unicode/symbols2.zig"), + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), + }); + if (b.lazyDependency("ziglyph", .{ .target = b.graph.host, })) |ziglyph_dep| { - exe.root_module.addImport( - "ziglyph", - ziglyph_dep.module("ziglyph"), - ); + inline for (&.{ props_exe, symbols1_exe, symbols2_exe }) |exe| { + exe.root_module.addImport( + "ziglyph", + ziglyph_dep.module("ziglyph"), + ); + } } - const run = b.addRunArtifact(exe); + const props_run = b.addRunArtifact(props_exe); + const symbols1_run = b.addRunArtifact(symbols1_exe); + const symbols2_run = b.addRunArtifact(symbols2_exe); + return .{ - .exe = exe, - .output = run.captureStdOut(), + .props_exe = props_exe, + .symbols1_exe = symbols1_exe, + .symbols2_exe = symbols2_exe, + .props_output = props_run.captureStdOut(), + .symbols1_output = symbols1_run.captureStdOut(), + .symbols2_output = symbols2_run.captureStdOut(), }; } /// Add the "unicode_tables" import. pub fn addImport(self: *const UnicodeTables, step: *std.Build.Step.Compile) void { - self.output.addStepDependencies(&step.step); + self.props_output.addStepDependencies(&step.step); step.root_module.addAnonymousImport("unicode_tables", .{ - .root_source_file = self.output, + .root_source_file = self.props_output, + }); + self.symbols1_output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("symbols1_tables", .{ + .root_source_file = self.symbols1_output, + }); + self.symbols2_output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("symbols2_tables", .{ + .root_source_file = self.symbols2_output, }); } /// Install the exe pub fn install(self: *const UnicodeTables, b: *std.Build) void { - b.installArtifact(self.exe); + b.installArtifact(self.props_exe); + b.installArtifact(self.symbols1_exe); + b.installArtifact(self.symbols2_exe); } diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index ec13b8953..a75fddf52 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -1,12 +1,12 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; -const ziglyph = @import("ziglyph"); const font = @import("../font/main.zig"); const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); const shaderpkg = renderer.Renderer.API.shaders; const ArrayListCollection = @import("../datastruct/array_list_collection.zig").ArrayListCollection; +const symbols = @import("../unicode/symbols1.zig").table; /// The possible cell content keys that exist. pub const Key = enum { @@ -249,15 +249,7 @@ pub fn isCovering(cp: u21) bool { /// In the future it may be prudent to expand this to encompass more /// symbol-like characters, and/or exclude some PUA sections. pub fn isSymbol(cp: u21) bool { - // TODO: This should probably become a codegen'd LUT - return ziglyph.general_category.isPrivateUse(cp) or - ziglyph.blocks.isDingbats(cp) or - ziglyph.blocks.isEmoticons(cp) or - ziglyph.blocks.isMiscellaneousSymbols(cp) or - ziglyph.blocks.isEnclosedAlphanumerics(cp) or - ziglyph.blocks.isEnclosedAlphanumericSupplement(cp) or - ziglyph.blocks.isMiscellaneousSymbolsAndPictographs(cp) or - ziglyph.blocks.isTransportAndMapSymbols(cp); + return symbols.get(cp); } /// Returns the appropriate `constraint_width` for diff --git a/src/unicode/lut.zig b/src/unicode/lut.zig index 95c6a3688..e709bf1fe 100644 --- a/src/unicode/lut.zig +++ b/src/unicode/lut.zig @@ -142,6 +142,32 @@ pub fn Tables(comptime Elem: type) type { return self.stage3[self.stage2[self.stage1[high] + low]]; } + pub inline fn getInline(self: *const Self, cp: u21) Elem { + const high = cp >> 8; + const low = cp & 0xFF; + return self.stage3[self.stage2[self.stage1[high] + low]]; + } + + pub fn getBool(self: *const Self, cp: u21) bool { + assert(Elem == bool); + assert(self.stage3.len == 2); + assert(self.stage3[0] == false); + assert(self.stage3[1] == true); + const high = cp >> 8; + const low = cp & 0xFF; + return self.stage2[self.stage1[high] + low] != 0; + } + + pub inline fn getBoolInline(self: *const Self, cp: u21) bool { + assert(Elem == bool); + assert(self.stage3.len == 2); + assert(self.stage3[0] == false); + assert(self.stage3[1] == true); + const high = cp >> 8; + const low = cp & 0xFF; + return self.stage2[self.stage1[high] + low] != 0; + } + /// Writes the lookup table as Zig to the given writer. The /// written file exports three constants: stage1, stage2, and /// stage3. These can be used to rebuild the lookup table in Zig. diff --git a/src/unicode/lut2.zig b/src/unicode/lut2.zig new file mode 100644 index 000000000..ef5c886a2 --- /dev/null +++ b/src/unicode/lut2.zig @@ -0,0 +1,183 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +// This whole file is based on the algorithm described here: +// https://here-be-braces.com/fast-lookup-of-unicode-properties/ + +const set_size = @typeInfo(usize).int.bits; +// const Set = std.bit_set.ArrayBitSet(usize, set_size); +const Set = std.bit_set.IntegerBitSet(set_size); +const cp_shift = std.math.log2_int(u21, set_size); +const cp_mask = set_size - 1; + +/// Creates a type that is able to generate a 2-level lookup table +/// from a Unicode codepoint to a mapping of type bool. The lookup table +/// generally is expected to be codegen'd and then reloaded, although it +/// can in theory be generated at runtime. +/// +/// Context must have one function: +/// - `get(Context, u21) bool`: returns the mapping for a given codepoint +/// +pub fn Generator( + comptime Context: type, +) type { + return struct { + const Self = @This(); + + /// Mapping of a block to its index in the stage2 array. + const SetMap = std.HashMap( + Set, + u16, + struct { + pub fn hash(ctx: @This(), k: Set) u64 { + _ = ctx; + var hasher = std.hash.Wyhash.init(0); + std.hash.autoHashStrat(&hasher, k, .DeepRecursive); + return hasher.final(); + } + + pub fn eql(ctx: @This(), a: Set, b: Set) bool { + _ = ctx; + return a.eql(b); + } + }, + std.hash_map.default_max_load_percentage, + ); + + ctx: Context = undefined, + + /// Generate the lookup tables. The arrays in the return value + /// are owned by the caller and must be freed. + pub fn generate(self: *const Self, alloc: Allocator) !Tables { + var min: u21 = std.math.maxInt(u21); + var max: u21 = std.math.minInt(u21); + + // Maps block => stage2 index + var set_map = SetMap.init(alloc); + defer set_map.deinit(); + + // Our stages + var stage1 = std.ArrayList(u16).init(alloc); + defer stage1.deinit(); + var stage2 = std.ArrayList(Set).init(alloc); + defer stage2.deinit(); + + var set: Set = .initEmpty(); + + // ensure that the 1st entry is always all false + try stage2.append(set); + try set_map.putNoClobber(set, 0); + + for (0..std.math.maxInt(u21) + 1) |cp_| { + const cp: u21 = @intCast(cp_); + const high = cp >> cp_shift; + const low = cp & cp_mask; + + if (self.ctx.get(cp)) { + if (cp < min) min = cp; + if (cp > max) max = cp; + set.set(low); + } + + // If we still have space and we're not done with codepoints, + // we keep building up the block. Conversely: we finalize this + // block if we've filled it or are out of codepoints. + if (low + 1 < set_size and cp != std.math.maxInt(u21)) continue; + + // Look for the stage2 index for this block. If it doesn't exist + // we add it to stage2 and update the mapping. + const gop = try set_map.getOrPut(set); + if (!gop.found_existing) { + gop.value_ptr.* = std.math.cast( + u16, + stage2.items.len, + ) orelse return error.Stage2TooLarge; + try stage2.append(set); + } + + // Map stage1 => stage2 and reset our block + try stage1.append(gop.value_ptr.*); + set = .initEmpty(); + assert(stage1.items.len - 1 == high); + } + + // All of our lengths must fit in a u16 for this to work + assert(stage1.items.len <= std.math.maxInt(u16)); + assert(stage2.items.len <= std.math.maxInt(u16)); + + const stage1_owned = try stage1.toOwnedSlice(); + errdefer alloc.free(stage1_owned); + const stage2_owned = try stage2.toOwnedSlice(); + errdefer alloc.free(stage2_owned); + + return .{ + .min = min, + .max = max, + .stage1 = stage1_owned, + .stage2 = stage2_owned, + }; + } + }; +} + +/// Creates a type that given a 3-level lookup table, can be used to +/// look up a mapping for a given codepoint, encode it out to Zig, etc. +pub const Tables = struct { + const Self = @This(); + + min: u21, + max: u21, + stage1: []const u16, + stage2: []const Set, + + /// Given a codepoint, returns the mapping for that codepoint. + pub fn get(self: *const Self, cp: u21) bool { + if (cp < self.min) return false; + if (cp > self.max) return false; + const high = cp >> cp_shift; + const stage2 = self.stage1[high]; + // take advantage of the fact that the first entry is always all false + if (stage2 == 0) return false; + const low = cp & cp_mask; + return self.stage2[stage2].isSet(low); + } + + /// Writes the lookup table as Zig to the given writer. The + /// written file exports three constants: stage1, stage2, and + /// stage3. These can be used to rebuild the lookup table in Zig. + pub fn writeZig(self: *const Self, writer: anytype) !void { + try writer.print( + \\//! This file is auto-generated. Do not edit. + \\const std = @import("std"); + \\ + \\pub const min: u21 = {}; + \\pub const max: u21 = {}; + \\ + \\pub const stage1: [{}]u16 = .{{ + , .{ self.min, self.max, self.stage1.len }); + for (self.stage1) |entry| try writer.print("{},", .{entry}); + + try writer.print( + \\ + \\}}; + \\ + \\pub const Set = std.bit_set.IntegerBitSet({d}); + \\pub const stage2: [{d}]Set = .{{ + \\ + , .{ set_size, self.stage2.len }); + // for (self.stage2) |entry| { + // try writer.print(" .{{\n", .{}); + // try writer.print(" .masks = [{d}]{s}{{\n", .{ entry.masks.len, @typeName(Set.MaskInt) }); + // for (entry.masks) |mask| { + // try writer.print(" {d},\n", .{mask}); + // } + // try writer.print(" }},\n", .{}); + // try writer.print(" }},\n", .{}); + // } + for (self.stage2) |entry| { + try writer.print(" .{{ .mask = {d} }},\n", .{entry.mask}); + } + try writer.writeAll("};\n"); + } +}; diff --git a/src/unicode/main.zig b/src/unicode/main.zig index f5b911948..91dfd482c 100644 --- a/src/unicode/main.zig +++ b/src/unicode/main.zig @@ -9,5 +9,7 @@ pub const graphemeBreak = grapheme.graphemeBreak; pub const GraphemeBreakState = grapheme.BreakState; test { + _ = @import("symbols1.zig"); + _ = @import("symbols2.zig"); @import("std").testing.refAllDecls(@This()); } diff --git a/src/unicode/props.zig b/src/unicode/props.zig index 99c57aa0a..7edb3761c 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -166,7 +166,7 @@ pub fn main() !void { // This is not very fast in debug modes, so its commented by default. // IMPORTANT: UNCOMMENT THIS WHENEVER MAKING CODEPOINTWIDTH CHANGES. -// test "tables match ziglyph" { +// test "unicode props: tables match ziglyph" { // const testing = std.testing; // // const min = 0xFF + 1; // start outside ascii diff --git a/src/unicode/symbols1.zig b/src/unicode/symbols1.zig new file mode 100644 index 000000000..e5b8cc22a --- /dev/null +++ b/src/unicode/symbols1.zig @@ -0,0 +1,93 @@ +const props = @This(); +const std = @import("std"); +const assert = std.debug.assert; +const ziglyph = @import("ziglyph"); +const lut = @import("lut.zig"); + +/// The lookup tables for Ghostty. +pub const table = table: { + // This is only available after running main() below as part of the Ghostty + // build.zig, but due to Zig's lazy analysis we can still reference it here. + const generated = @import("symbols1_tables").Tables(bool); + const Tables = lut.Tables(bool); + break :table Tables{ + .stage1 = &generated.stage1, + .stage2 = &generated.stage2, + .stage3 = &generated.stage3, + }; +}; + +/// Returns true of the codepoint is a "symbol-like" character, which +/// for now we define as anything in a private use area and anything +/// in several unicode blocks: +/// - Dingbats +/// - Emoticons +/// - Miscellaneous Symbols +/// - Enclosed Alphanumerics +/// - Enclosed Alphanumeric Supplement +/// - Miscellaneous Symbols and Pictographs +/// - Transport and Map Symbols +/// +/// In the future it may be prudent to expand this to encompass more +/// symbol-like characters, and/or exclude some PUA sections. +pub fn isSymbol(cp: u21) bool { + return ziglyph.general_category.isPrivateUse(cp) or + ziglyph.blocks.isDingbats(cp) or + ziglyph.blocks.isEmoticons(cp) or + ziglyph.blocks.isMiscellaneousSymbols(cp) or + ziglyph.blocks.isEnclosedAlphanumerics(cp) or + ziglyph.blocks.isEnclosedAlphanumericSupplement(cp) or + ziglyph.blocks.isMiscellaneousSymbolsAndPictographs(cp) or + ziglyph.blocks.isTransportAndMapSymbols(cp); +} + +/// Runnable binary to generate the lookup tables and output to stdout. +pub fn main() !void { + var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_state.deinit(); + const alloc = arena_state.allocator(); + + const gen: lut.Generator( + bool, + struct { + pub fn get(ctx: @This(), cp: u21) !bool { + _ = ctx; + return isSymbol(cp); + } + + pub fn eql(ctx: @This(), a: bool, b: bool) bool { + _ = ctx; + return a == b; + } + }, + ) = .{}; + + const t = try gen.generate(alloc); + defer alloc.free(t.stage1); + defer alloc.free(t.stage2); + defer alloc.free(t.stage3); + try t.writeZig(std.io.getStdOut().writer()); + + // Uncomment when manually debugging to see our table sizes. + // std.log.warn("stage1={} stage2={} stage3={}", .{ + // t.stage1.len, + // t.stage2.len, + // t.stage3.len, + // }); +} + +// This is not very fast in debug modes, so its commented by default. +// IMPORTANT: UNCOMMENT THIS WHENEVER MAKING CHANGES. +test "unicode symbols1: tables match ziglyph" { + const testing = std.testing; + + for (0..std.math.maxInt(u21)) |cp| { + const t = table.get(@intCast(cp)); + const zg = isSymbol(@intCast(cp)); + + if (t != zg) { + std.log.warn("mismatch cp=U+{x} t={} zg={}", .{ cp, t, zg }); + try testing.expect(false); + } + } +} diff --git a/src/unicode/symbols2.zig b/src/unicode/symbols2.zig new file mode 100644 index 000000000..1d23c51be --- /dev/null +++ b/src/unicode/symbols2.zig @@ -0,0 +1,85 @@ +const props = @This(); +const std = @import("std"); +const assert = std.debug.assert; +const ziglyph = @import("ziglyph"); +const lut2 = @import("lut2.zig"); + +/// The lookup tables for Ghostty. +pub const table = table: { + // This is only available after running main() below as part of the Ghostty + // build.zig, but due to Zig's lazy analysis we can still reference it here. + const generated = @import("symbols2_tables"); + break :table lut2.Tables{ + .min = generated.min, + .max = generated.max, + .stage1 = &generated.stage1, + .stage2 = &generated.stage2, + }; +}; + +/// Returns true of the codepoint is a "symbol-like" character, which +/// for now we define as anything in a private use area and anything +/// in several unicode blocks: +/// - Dingbats +/// - Emoticons +/// - Miscellaneous Symbols +/// - Enclosed Alphanumerics +/// - Enclosed Alphanumeric Supplement +/// - Miscellaneous Symbols and Pictographs +/// - Transport and Map Symbols +/// +/// In the future it may be prudent to expand this to encompass more +/// symbol-like characters, and/or exclude some PUA sections. +pub fn isSymbol(cp: u21) bool { + return ziglyph.general_category.isPrivateUse(cp) or + ziglyph.blocks.isDingbats(cp) or + ziglyph.blocks.isEmoticons(cp) or + ziglyph.blocks.isMiscellaneousSymbols(cp) or + ziglyph.blocks.isEnclosedAlphanumerics(cp) or + ziglyph.blocks.isEnclosedAlphanumericSupplement(cp) or + ziglyph.blocks.isMiscellaneousSymbolsAndPictographs(cp) or + ziglyph.blocks.isTransportAndMapSymbols(cp); +} + +/// Runnable binary to generate the lookup tables and output to stdout. +pub fn main() !void { + var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_state.deinit(); + const alloc = arena_state.allocator(); + + const gen: lut2.Generator( + struct { + pub fn get(ctx: @This(), cp: u21) bool { + _ = ctx; + return isSymbol(cp); + } + }, + ) = .{}; + + const t = try gen.generate(alloc); + defer alloc.free(t.stage1); + defer alloc.free(t.stage2); + try t.writeZig(std.io.getStdOut().writer()); + + // Uncomment when manually debugging to see our table sizes. + // std.log.warn("stage1={} stage2={}", .{ + // t.stage1.len, + // t.stage2.len, + // }); +} + +// This is not very fast in debug modes, so its commented by default. +// IMPORTANT: UNCOMMENT THIS WHENEVER MAKING CHANGES. +test "unicode symbols2: tables match ziglyph" { + const testing = std.testing; + + for (0..std.math.maxInt(u21)) |cp| { + const t1 = table.get(@intCast(cp)); + const zg = isSymbol(@intCast(cp)); + + if (t1 != zg) { + std.log.warn("mismatch cp=U+{x} t={} zg={}", .{ cp, t1, zg }); + try testing.expect(false); + } + } +} From e024b77ad518ee2d2066f0969b4f7a7804e28d46 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 4 Sep 2025 23:15:29 -0500 Subject: [PATCH 34/42] drop the new LUT type as no performance advantage detected --- src/benchmark/IsSymbol.zig | 39 +---- src/build/UnicodeTables.zig | 47 ++---- src/renderer/cell.zig | 2 +- src/unicode/lut2.zig | 183 ---------------------- src/unicode/main.zig | 3 +- src/unicode/{symbols1.zig => symbols.zig} | 2 +- src/unicode/symbols2.zig | 85 ---------- 7 files changed, 22 insertions(+), 339 deletions(-) delete mode 100644 src/unicode/lut2.zig rename src/unicode/{symbols1.zig => symbols.zig} (97%) delete mode 100644 src/unicode/symbols2.zig diff --git a/src/benchmark/IsSymbol.zig b/src/benchmark/IsSymbol.zig index 46ebb8c66..940207619 100644 --- a/src/benchmark/IsSymbol.zig +++ b/src/benchmark/IsSymbol.zig @@ -10,8 +10,7 @@ const Allocator = std.mem.Allocator; const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); const UTF8Decoder = @import("../terminal/UTF8Decoder.zig"); -const symbols1 = @import("../unicode/symbols1.zig"); -const symbols2 = @import("../unicode/symbols2.zig"); +const symbols = @import("../unicode/symbols.zig"); const log = std.log.scoped(.@"is-symbol-bench"); @@ -37,8 +36,7 @@ pub const Mode = enum { ziglyph, /// Ghostty's table-based approach. - table1, - table2, + table, }; /// Create a new terminal stream handler for the given arguments. @@ -60,8 +58,7 @@ pub fn benchmark(self: *IsSymbol) Benchmark { return .init(self, .{ .stepFn = switch (self.opts.mode) { .ziglyph => stepZiglyph, - .table1 => stepTable1, - .table2 => stepTable1, + .table => stepTable, }, .setupFn = setup, .teardownFn = teardown, @@ -106,13 +103,13 @@ fn stepZiglyph(ptr: *anyopaque) Benchmark.Error!void { const cp_, const consumed = d.next(c); assert(consumed); if (cp_) |cp| { - std.mem.doNotOptimizeAway(symbols1.isSymbol(cp)); + std.mem.doNotOptimizeAway(symbols.isSymbol(cp)); } } } } -fn stepTable1(ptr: *anyopaque) Benchmark.Error!void { +fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *IsSymbol = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; @@ -130,31 +127,7 @@ fn stepTable1(ptr: *anyopaque) Benchmark.Error!void { const cp_, const consumed = d.next(c); assert(consumed); if (cp_) |cp| { - std.mem.doNotOptimizeAway(symbols1.table.get(cp)); - } - } - } -} - -fn stepTable2(ptr: *anyopaque) Benchmark.Error!void { - const self: *IsSymbol = @ptrCast(@alignCast(ptr)); - - const f = self.data_f orelse return; - var r = std.io.bufferedReader(f.reader()); - var d: UTF8Decoder = .{}; - var buf: [4096]u8 = undefined; - while (true) { - const n = r.read(&buf) catch |err| { - log.warn("error reading data file err={}", .{err}); - return error.BenchmarkFailed; - }; - if (n == 0) break; // EOF reached - - for (buf[0..n]) |c| { - const cp_, const consumed = d.next(c); - assert(consumed); - if (cp_) |cp| { - std.mem.doNotOptimizeAway(symbols2.table.get(cp)); + std.mem.doNotOptimizeAway(symbols.table.get(cp)); } } } diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig index dd9a6bdf2..6bb656a29 100644 --- a/src/build/UnicodeTables.zig +++ b/src/build/UnicodeTables.zig @@ -5,13 +5,11 @@ const Config = @import("Config.zig"); /// The exe. props_exe: *std.Build.Step.Compile, -symbols1_exe: *std.Build.Step.Compile, -symbols2_exe: *std.Build.Step.Compile, +symbols_exe: *std.Build.Step.Compile, /// The output path for the unicode tables props_output: std.Build.LazyPath, -symbols1_output: std.Build.LazyPath, -symbols2_output: std.Build.LazyPath, +symbols_output: std.Build.LazyPath, pub fn init(b: *std.Build) !UnicodeTables { const props_exe = b.addExecutable(.{ @@ -25,21 +23,10 @@ pub fn init(b: *std.Build) !UnicodeTables { }), }); - const symbols1_exe = b.addExecutable(.{ - .name = "symbols1-unigen", + const symbols_exe = b.addExecutable(.{ + .name = "symbols-unigen", .root_module = b.createModule(.{ - .root_source_file = b.path("src/unicode/symbols1.zig"), - .target = b.graph.host, - .strip = false, - .omit_frame_pointer = false, - .unwind_tables = .sync, - }), - }); - - const symbols2_exe = b.addExecutable(.{ - .name = "symbols2-unigen", - .root_module = b.createModule(.{ - .root_source_file = b.path("src/unicode/symbols2.zig"), + .root_source_file = b.path("src/unicode/symbols.zig"), .target = b.graph.host, .strip = false, .omit_frame_pointer = false, @@ -50,7 +37,7 @@ pub fn init(b: *std.Build) !UnicodeTables { if (b.lazyDependency("ziglyph", .{ .target = b.graph.host, })) |ziglyph_dep| { - inline for (&.{ props_exe, symbols1_exe, symbols2_exe }) |exe| { + inline for (&.{ props_exe, symbols_exe }) |exe| { exe.root_module.addImport( "ziglyph", ziglyph_dep.module("ziglyph"), @@ -59,16 +46,13 @@ pub fn init(b: *std.Build) !UnicodeTables { } const props_run = b.addRunArtifact(props_exe); - const symbols1_run = b.addRunArtifact(symbols1_exe); - const symbols2_run = b.addRunArtifact(symbols2_exe); + const symbols_run = b.addRunArtifact(symbols_exe); return .{ .props_exe = props_exe, - .symbols1_exe = symbols1_exe, - .symbols2_exe = symbols2_exe, + .symbols_exe = symbols_exe, .props_output = props_run.captureStdOut(), - .symbols1_output = symbols1_run.captureStdOut(), - .symbols2_output = symbols2_run.captureStdOut(), + .symbols_output = symbols_run.captureStdOut(), }; } @@ -78,19 +62,14 @@ pub fn addImport(self: *const UnicodeTables, step: *std.Build.Step.Compile) void step.root_module.addAnonymousImport("unicode_tables", .{ .root_source_file = self.props_output, }); - self.symbols1_output.addStepDependencies(&step.step); - step.root_module.addAnonymousImport("symbols1_tables", .{ - .root_source_file = self.symbols1_output, - }); - self.symbols2_output.addStepDependencies(&step.step); - step.root_module.addAnonymousImport("symbols2_tables", .{ - .root_source_file = self.symbols2_output, + self.symbols_output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("symbols_tables", .{ + .root_source_file = self.symbols_output, }); } /// Install the exe pub fn install(self: *const UnicodeTables, b: *std.Build) void { b.installArtifact(self.props_exe); - b.installArtifact(self.symbols1_exe); - b.installArtifact(self.symbols2_exe); + b.installArtifact(self.symbols_exe); } diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index a75fddf52..6ada849ed 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -6,7 +6,7 @@ const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); const shaderpkg = renderer.Renderer.API.shaders; const ArrayListCollection = @import("../datastruct/array_list_collection.zig").ArrayListCollection; -const symbols = @import("../unicode/symbols1.zig").table; +const symbols = @import("../unicode/symbols.zig").table; /// The possible cell content keys that exist. pub const Key = enum { diff --git a/src/unicode/lut2.zig b/src/unicode/lut2.zig deleted file mode 100644 index ef5c886a2..000000000 --- a/src/unicode/lut2.zig +++ /dev/null @@ -1,183 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -// This whole file is based on the algorithm described here: -// https://here-be-braces.com/fast-lookup-of-unicode-properties/ - -const set_size = @typeInfo(usize).int.bits; -// const Set = std.bit_set.ArrayBitSet(usize, set_size); -const Set = std.bit_set.IntegerBitSet(set_size); -const cp_shift = std.math.log2_int(u21, set_size); -const cp_mask = set_size - 1; - -/// Creates a type that is able to generate a 2-level lookup table -/// from a Unicode codepoint to a mapping of type bool. The lookup table -/// generally is expected to be codegen'd and then reloaded, although it -/// can in theory be generated at runtime. -/// -/// Context must have one function: -/// - `get(Context, u21) bool`: returns the mapping for a given codepoint -/// -pub fn Generator( - comptime Context: type, -) type { - return struct { - const Self = @This(); - - /// Mapping of a block to its index in the stage2 array. - const SetMap = std.HashMap( - Set, - u16, - struct { - pub fn hash(ctx: @This(), k: Set) u64 { - _ = ctx; - var hasher = std.hash.Wyhash.init(0); - std.hash.autoHashStrat(&hasher, k, .DeepRecursive); - return hasher.final(); - } - - pub fn eql(ctx: @This(), a: Set, b: Set) bool { - _ = ctx; - return a.eql(b); - } - }, - std.hash_map.default_max_load_percentage, - ); - - ctx: Context = undefined, - - /// Generate the lookup tables. The arrays in the return value - /// are owned by the caller and must be freed. - pub fn generate(self: *const Self, alloc: Allocator) !Tables { - var min: u21 = std.math.maxInt(u21); - var max: u21 = std.math.minInt(u21); - - // Maps block => stage2 index - var set_map = SetMap.init(alloc); - defer set_map.deinit(); - - // Our stages - var stage1 = std.ArrayList(u16).init(alloc); - defer stage1.deinit(); - var stage2 = std.ArrayList(Set).init(alloc); - defer stage2.deinit(); - - var set: Set = .initEmpty(); - - // ensure that the 1st entry is always all false - try stage2.append(set); - try set_map.putNoClobber(set, 0); - - for (0..std.math.maxInt(u21) + 1) |cp_| { - const cp: u21 = @intCast(cp_); - const high = cp >> cp_shift; - const low = cp & cp_mask; - - if (self.ctx.get(cp)) { - if (cp < min) min = cp; - if (cp > max) max = cp; - set.set(low); - } - - // If we still have space and we're not done with codepoints, - // we keep building up the block. Conversely: we finalize this - // block if we've filled it or are out of codepoints. - if (low + 1 < set_size and cp != std.math.maxInt(u21)) continue; - - // Look for the stage2 index for this block. If it doesn't exist - // we add it to stage2 and update the mapping. - const gop = try set_map.getOrPut(set); - if (!gop.found_existing) { - gop.value_ptr.* = std.math.cast( - u16, - stage2.items.len, - ) orelse return error.Stage2TooLarge; - try stage2.append(set); - } - - // Map stage1 => stage2 and reset our block - try stage1.append(gop.value_ptr.*); - set = .initEmpty(); - assert(stage1.items.len - 1 == high); - } - - // All of our lengths must fit in a u16 for this to work - assert(stage1.items.len <= std.math.maxInt(u16)); - assert(stage2.items.len <= std.math.maxInt(u16)); - - const stage1_owned = try stage1.toOwnedSlice(); - errdefer alloc.free(stage1_owned); - const stage2_owned = try stage2.toOwnedSlice(); - errdefer alloc.free(stage2_owned); - - return .{ - .min = min, - .max = max, - .stage1 = stage1_owned, - .stage2 = stage2_owned, - }; - } - }; -} - -/// Creates a type that given a 3-level lookup table, can be used to -/// look up a mapping for a given codepoint, encode it out to Zig, etc. -pub const Tables = struct { - const Self = @This(); - - min: u21, - max: u21, - stage1: []const u16, - stage2: []const Set, - - /// Given a codepoint, returns the mapping for that codepoint. - pub fn get(self: *const Self, cp: u21) bool { - if (cp < self.min) return false; - if (cp > self.max) return false; - const high = cp >> cp_shift; - const stage2 = self.stage1[high]; - // take advantage of the fact that the first entry is always all false - if (stage2 == 0) return false; - const low = cp & cp_mask; - return self.stage2[stage2].isSet(low); - } - - /// Writes the lookup table as Zig to the given writer. The - /// written file exports three constants: stage1, stage2, and - /// stage3. These can be used to rebuild the lookup table in Zig. - pub fn writeZig(self: *const Self, writer: anytype) !void { - try writer.print( - \\//! This file is auto-generated. Do not edit. - \\const std = @import("std"); - \\ - \\pub const min: u21 = {}; - \\pub const max: u21 = {}; - \\ - \\pub const stage1: [{}]u16 = .{{ - , .{ self.min, self.max, self.stage1.len }); - for (self.stage1) |entry| try writer.print("{},", .{entry}); - - try writer.print( - \\ - \\}}; - \\ - \\pub const Set = std.bit_set.IntegerBitSet({d}); - \\pub const stage2: [{d}]Set = .{{ - \\ - , .{ set_size, self.stage2.len }); - // for (self.stage2) |entry| { - // try writer.print(" .{{\n", .{}); - // try writer.print(" .masks = [{d}]{s}{{\n", .{ entry.masks.len, @typeName(Set.MaskInt) }); - // for (entry.masks) |mask| { - // try writer.print(" {d},\n", .{mask}); - // } - // try writer.print(" }},\n", .{}); - // try writer.print(" }},\n", .{}); - // } - for (self.stage2) |entry| { - try writer.print(" .{{ .mask = {d} }},\n", .{entry.mask}); - } - try writer.writeAll("};\n"); - } -}; diff --git a/src/unicode/main.zig b/src/unicode/main.zig index 91dfd482c..17c86deca 100644 --- a/src/unicode/main.zig +++ b/src/unicode/main.zig @@ -9,7 +9,6 @@ pub const graphemeBreak = grapheme.graphemeBreak; pub const GraphemeBreakState = grapheme.BreakState; test { - _ = @import("symbols1.zig"); - _ = @import("symbols2.zig"); + _ = @import("symbols.zig"); @import("std").testing.refAllDecls(@This()); } diff --git a/src/unicode/symbols1.zig b/src/unicode/symbols.zig similarity index 97% rename from src/unicode/symbols1.zig rename to src/unicode/symbols.zig index e5b8cc22a..3e038fe7d 100644 --- a/src/unicode/symbols1.zig +++ b/src/unicode/symbols.zig @@ -8,7 +8,7 @@ const lut = @import("lut.zig"); pub const table = table: { // This is only available after running main() below as part of the Ghostty // build.zig, but due to Zig's lazy analysis we can still reference it here. - const generated = @import("symbols1_tables").Tables(bool); + const generated = @import("symbols_tables").Tables(bool); const Tables = lut.Tables(bool); break :table Tables{ .stage1 = &generated.stage1, diff --git a/src/unicode/symbols2.zig b/src/unicode/symbols2.zig deleted file mode 100644 index 1d23c51be..000000000 --- a/src/unicode/symbols2.zig +++ /dev/null @@ -1,85 +0,0 @@ -const props = @This(); -const std = @import("std"); -const assert = std.debug.assert; -const ziglyph = @import("ziglyph"); -const lut2 = @import("lut2.zig"); - -/// The lookup tables for Ghostty. -pub const table = table: { - // This is only available after running main() below as part of the Ghostty - // build.zig, but due to Zig's lazy analysis we can still reference it here. - const generated = @import("symbols2_tables"); - break :table lut2.Tables{ - .min = generated.min, - .max = generated.max, - .stage1 = &generated.stage1, - .stage2 = &generated.stage2, - }; -}; - -/// Returns true of the codepoint is a "symbol-like" character, which -/// for now we define as anything in a private use area and anything -/// in several unicode blocks: -/// - Dingbats -/// - Emoticons -/// - Miscellaneous Symbols -/// - Enclosed Alphanumerics -/// - Enclosed Alphanumeric Supplement -/// - Miscellaneous Symbols and Pictographs -/// - Transport and Map Symbols -/// -/// In the future it may be prudent to expand this to encompass more -/// symbol-like characters, and/or exclude some PUA sections. -pub fn isSymbol(cp: u21) bool { - return ziglyph.general_category.isPrivateUse(cp) or - ziglyph.blocks.isDingbats(cp) or - ziglyph.blocks.isEmoticons(cp) or - ziglyph.blocks.isMiscellaneousSymbols(cp) or - ziglyph.blocks.isEnclosedAlphanumerics(cp) or - ziglyph.blocks.isEnclosedAlphanumericSupplement(cp) or - ziglyph.blocks.isMiscellaneousSymbolsAndPictographs(cp) or - ziglyph.blocks.isTransportAndMapSymbols(cp); -} - -/// Runnable binary to generate the lookup tables and output to stdout. -pub fn main() !void { - var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer arena_state.deinit(); - const alloc = arena_state.allocator(); - - const gen: lut2.Generator( - struct { - pub fn get(ctx: @This(), cp: u21) bool { - _ = ctx; - return isSymbol(cp); - } - }, - ) = .{}; - - const t = try gen.generate(alloc); - defer alloc.free(t.stage1); - defer alloc.free(t.stage2); - try t.writeZig(std.io.getStdOut().writer()); - - // Uncomment when manually debugging to see our table sizes. - // std.log.warn("stage1={} stage2={}", .{ - // t.stage1.len, - // t.stage2.len, - // }); -} - -// This is not very fast in debug modes, so its commented by default. -// IMPORTANT: UNCOMMENT THIS WHENEVER MAKING CHANGES. -test "unicode symbols2: tables match ziglyph" { - const testing = std.testing; - - for (0..std.math.maxInt(u21)) |cp| { - const t1 = table.get(@intCast(cp)); - const zg = isSymbol(@intCast(cp)); - - if (t1 != zg) { - std.log.warn("mismatch cp=U+{x} t={} zg={}", .{ cp, t1, zg }); - try testing.expect(false); - } - } -} From d10e4748605f30665aa531ef275fc00611f24105 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 3 Sep 2025 21:34:08 -0500 Subject: [PATCH 35/42] gtk-ng: deprecate detection of launch source Detecting the launch source frequently failed because various launchers fail to sanitize the environment variables that Ghostty used to detect the launch source. For example, if your desktop environment was launched by `systemd`, but your desktop environment did not sanitize the `INVOCATION_ID` or the `JOURNAL_STREAM` environment variables, Ghostty would assume that it had been launched by `systemd` and behave as such. This led to complaints about Ghostty not creating new windows when users expected that it would. To remedy this, Ghostty no longer does any detection of the launch source. If your launch source is something other than the CLI, it must be explicitly speciflied on the CLI. All of Ghostty's default desktop and service files do this. Users or packagers that create custom desktop or service files will need to take this into account. On GTK, the `desktop` setting for `gtk-single-instance` is replaced with `detect`. `detect` behaves as `gtk-single-instance=true` if one of the following conditions is true: 1. If no CLI arguments have been set. 2. If `--launched-from` has been set to `desktop`, `dbus`, or `systemd`. Otherwise `detect` behaves as `gtk-single-instance=false`. --- src/apprt/embedded.zig | 2 +- src/apprt/gtk/class/application.zig | 8 +- src/cli/args.zig | 1 + src/config/Config.zig | 123 ++++++++++++++++++++-------- 4 files changed, 94 insertions(+), 40 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 5f43e1659..390292601 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -909,7 +909,7 @@ pub const Surface = struct { // our translation settings for Ghostty. If we aren't from // the desktop then we didn't set our LANGUAGE var so we // don't need to remove it. - switch (self.app.config.@"launched-from".?) { + switch (self.app.config.@"launched-from") { .desktop => env.remove("LANGUAGE"), .dbus, .systemd, .cli => {}, } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 22fe3f618..e9ff3dd81 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -223,10 +223,8 @@ pub const Application = extern struct { const single_instance = switch (config.@"gtk-single-instance") { .true => true, .false => false, - .desktop => switch (config.@"launched-from".?) { - .desktop, .systemd, .dbus => true, - .cli => false, - }, + // This should have been resolved to true/false during config loading. + .detect => unreachable, }; // Setup the flags for our application. @@ -428,7 +426,7 @@ pub const Application = extern struct { // We need to scope any config access because once we run our // event loop, this can change out from underneath us. const config = priv.config.get(); - if (config.@"initial-window") switch (config.@"launched-from".?) { + if (config.@"initial-window") switch (config.@"launched-from") { .desktop, .cli => self.as(gio.Application).activate(), .dbus, .systemd => {}, }; diff --git a/src/cli/args.zig b/src/cli/args.zig index 0ff3dc047..4db0a29a2 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -260,6 +260,7 @@ fn formatInvalidValue( } fn formatValues(comptime T: type, key: []const u8, writer: anytype) std.mem.Allocator.Error!void { + @setEvalBranchQuota(2000); const typeinfo = @typeInfo(T); inline for (typeinfo.@"struct".fields) |f| { if (std.mem.eql(u8, key, f.name)) { diff --git a/src/config/Config.zig b/src/config/Config.zig index f9d8fcf7e..debf5e9d5 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2975,14 +2975,22 @@ else /// /// If `false`, each new ghostty process will launch a separate application. /// -/// The default value is `desktop` which will default to `true` if Ghostty -/// detects that it was launched from the `.desktop` file such as an app -/// launcher (like Gnome Shell) or by D-Bus activation. If Ghostty is launched -/// from the command line, it will default to `false`. +/// If `detect`, Ghostty will act as if it was `true` if one of the following +/// conditions is true: +/// +/// 1. If no CLI arguments have been set. +/// 2. If `--launched-from` has been set to `desktop`, `dbus`, or `systemd`. +/// +/// Otherwise, Ghostty will act as if it was `false`. +/// +/// The pre-1.2 option `desktop` has been deprecated. If encountered it will be +/// treated as `detect`. +/// +/// The default value is `detect`. /// /// Note that debug builds of Ghostty have a separate single-instance ID /// so you can test single instance without conflicting with release builds. -@"gtk-single-instance": GtkSingleInstance = .desktop, +@"gtk-single-instance": GtkSingleInstance = .default, /// When enabled, the full GTK titlebar is displayed instead of your window /// manager's simple titlebar. The behavior of this option will vary with your @@ -3113,15 +3121,13 @@ term: []const u8 = "xterm-ghostty", /// incorrect for your environment or for developers who want to test /// Ghostty's behavior in different, forced environments. /// -/// This is set using the standard `no-[value]`, `[value]` syntax separated -/// by commas. Example: "no-desktop,systemd". Specific details about the -/// available values are documented on LaunchProperties in the code. Since -/// this isn't intended to be modified by users, the documentation is -/// lighter than the other configurations and users are expected to -/// refer to the code for details. +/// Specific details about the available values are documented on LaunchSource +/// in the code. Since this isn't intended to be modified by users, the +/// documentation is lighter than the other configurations and users are +/// expected to refer to the code for details. /// /// Available since: 1.2.0 -@"launched-from": ?LaunchSource = null, +@"launched-from": LaunchSource = .default, /// Configures the low-level API to use for async IO, eventing, etc. /// @@ -3488,7 +3494,23 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { .windows => {}, // Fast-path if we are Linux and have no args. - .linux, .freebsd => if (std.os.argv.len <= 1) return, + .linux, .freebsd => { + if (std.os.argv.len <= 1) { + if (self.@"gtk-single-instance" == .detect) { + const arena_alloc = self._arena.?.allocator(); + // Add an artificial replay step so that replaying the + // inputs doesn't undo this change. + try self._replay_steps.append( + arena_alloc, + .{ + .arg = "--gtk-single-instance=true", + }, + ); + self.@"gtk-single-instance" = .true; + } + return; + } + }, // Everything else we have to at least try because it may // not use std.os.argv. @@ -3584,6 +3606,34 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // directory. var buf: [std.fs.max_path_bytes]u8 = undefined; try self.expandPaths(try std.fs.cwd().realpath(".", &buf)); + + if (self.@"gtk-single-instance" == .detect) { + const arena_alloc = self._arena.?.allocator(); + switch (self.@"launched-from") { + .cli => { + // Add an artificial replay step so that replaying the + // inputs doesn't undo this change. + try self._replay_steps.append( + arena_alloc, + .{ + .arg = "--gtk-single-instance=false", + }, + ); + self.@"gtk-single-instance" = .false; + }, + .desktop, .systemd, .dbus => { + // Add an artificial replay step so that replaying the + // inputs doesn't undo this change. + try self._replay_steps.append( + arena_alloc, + .{ + .arg = "--gtk-single-instance=true", + }, + ); + self.@"gtk-single-instance" = .true; + }, + } + } } /// Load and parse the config files that were added in the "config-file" key. @@ -3917,11 +3967,6 @@ pub fn finalize(self: *Config) !void { const alloc = self._arena.?.allocator(); - // Ensure our launch source is properly set. - if (self.@"launched-from" == null) { - self.@"launched-from" = .detect(); - } - // If we have a font-family set and don't set the others, default // the others to the font family. This way, if someone does // --font-family=foo, then we try to get the stylized versions of @@ -3946,7 +3991,7 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse switch (self.@"launched-from".?) { + const wd = self.@"working-directory" orelse switch (self.@"launched-from") { // If we have no working directory set, our default depends on // whether we were launched from the desktop or elsewhere. .desktop => "home", @@ -3973,7 +4018,7 @@ pub fn finalize(self: *Config) !void { // If we were launched from the desktop, our SHELL env var // will represent our SHELL at login time. We want to use the // latest shell from /etc/passwd or directory services. - switch (self.@"launched-from".?) { + switch (self.@"launched-from") { .desktop, .dbus, .systemd => break :shell_env, .cli => {}, } @@ -7125,9 +7170,23 @@ pub const MacShortcuts = enum { /// See gtk-single-instance pub const GtkSingleInstance = enum { - desktop, false, true, + detect, + + pub const default: GtkSingleInstance = .detect; + + pub fn parseCLI(input_: ?[]const u8) error{ ValueRequired, InvalidValue }!GtkSingleInstance { + const input = std.mem.trim( + u8, + input_ orelse return error.ValueRequired, + cli.args.whitespace, + ); + + if (std.mem.eql(u8, input, "desktop")) return .detect; + + return std.meta.stringToEnum(GtkSingleInstance, input) orelse error.InvalidValue; + } }; /// See gtk-tabs-location @@ -8020,31 +8079,27 @@ pub const Duration = struct { }; pub const LaunchSource = enum { - /// Ghostty was launched via the CLI. This is the default if - /// no other source is detected. + /// Ghostty was launched via the CLI. This is the default on non-macOS + /// platforms. cli, /// Ghostty was launched in a desktop environment (not via the CLI). /// This is used to determine some behaviors such as how to read /// settings, whether single instance defaults to true, etc. + /// + /// This is the default on macOS. desktop, /// Ghostty was started via dbus activation. dbus, - /// Ghostty was started via systemd activation. + /// Ghostty was started via systemd unit. systemd, - pub fn detect() LaunchSource { - return if (internal_os.launchedFromDesktop()) - .desktop - else if (internal_os.launchedByDbusActivation()) - .dbus - else if (internal_os.launchedBySystemd()) - .systemd - else - .cli; - } + pub const default: LaunchSource = switch (builtin.os.tag) { + .macos => .desktop, + else => .cli, + }; }; pub const WindowPadding = struct { From 587f47a5870fa9ddc4558452b76ffbdab0a16f38 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Sep 2025 20:57:32 -0700 Subject: [PATCH 36/42] apprt/gtk-ng: clean up our single instance, new window interactions This removes `launched-from` entirely and moves our `gtk-single-instance` detection logic to assume true unless we detect CLI instead of assume false unless we detect desktop/dbus/systemd. The "assume true" scenario for single instance is desirable because detecting a CLI instance is much more reliable. Removing `launched-from` fixes an issue where we had a difficult-to-understand relationship between `launched-from`, `gtk-single-instance`, and `initial-window`. Now, only `gtk-single-instance` has some hueristic logic. And `initial-window` ALWAYS sends a GTK activation signal regardless of single instance or not. As a result, we need to be explicit in our systemd, dbus, desktop files about what we want Ghostty to do, but everything works as you'd mostly expect. Now, if you put plain old `ghostty` in your terminal, you get a new Ghostty instance. If you put it anywhere else, you get a GTK single instance activation call (either creates a first instance or opens a new window in the existing instance). Works for launchers and so on. --- dist/linux/app.desktop.in | 4 +- dist/linux/dbus.service.flatpak.in | 2 +- dist/linux/dbus.service.in | 2 +- dist/linux/systemd.service.in | 2 +- src/apprt/embedded.zig | 5 +- src/apprt/gtk/class/application.zig | 12 +- src/config/Config.zig | 234 ++++++++++++++-------------- 7 files changed, 125 insertions(+), 136 deletions(-) diff --git a/dist/linux/app.desktop.in b/dist/linux/app.desktop.in index 32ba00cfd..e05c47b6e 100644 --- a/dist/linux/app.desktop.in +++ b/dist/linux/app.desktop.in @@ -4,7 +4,7 @@ Name=@NAME@ Type=Application Comment=A terminal emulator TryExec=@GHOSTTY@ -Exec=@GHOSTTY@ --launched-from=desktop +Exec=@GHOSTTY@ --gtk-single-instance=true Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; @@ -23,4 +23,4 @@ X-KDE-Shortcuts=Ctrl+Alt+T [Desktop Action new-window] Name=New Window -Exec=@GHOSTTY@ --launched-from=desktop +Exec=@GHOSTTY@ --gtk-single-instance=true diff --git a/dist/linux/dbus.service.flatpak.in b/dist/linux/dbus.service.flatpak.in index 213cda78f..873f8dcf1 100644 --- a/dist/linux/dbus.service.flatpak.in +++ b/dist/linux/dbus.service.flatpak.in @@ -1,3 +1,3 @@ [D-BUS Service] Name=@APPID@ -Exec=@GHOSTTY@ --launched-from=dbus +Exec=@GHOSTTY@ --gtk-single-instance=true --initial-window=false diff --git a/dist/linux/dbus.service.in b/dist/linux/dbus.service.in index df31a1abd..8758a34a2 100644 --- a/dist/linux/dbus.service.in +++ b/dist/linux/dbus.service.in @@ -1,4 +1,4 @@ [D-BUS Service] Name=@APPID@ SystemdService=app-@APPID@.service -Exec=@GHOSTTY@ --launched-from=dbus +Exec=@GHOSTTY@ --gtk-single-instance=true --initial-window=false diff --git a/dist/linux/systemd.service.in b/dist/linux/systemd.service.in index 76ccdd3f4..17589f00f 100644 --- a/dist/linux/systemd.service.in +++ b/dist/linux/systemd.service.in @@ -8,7 +8,7 @@ Requires=dbus.socket Type=notify-reload ReloadSignal=SIGUSR2 BusName=@APPID@ -ExecStart=@GHOSTTY@ --launched-from=systemd +ExecStart=@GHOSTTY@ --gtk-single-instance=true --initial-window=false [Install] WantedBy=graphical-session.target diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 390292601..08d8291ef 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -909,10 +909,7 @@ pub const Surface = struct { // our translation settings for Ghostty. If we aren't from // the desktop then we didn't set our LANGUAGE var so we // don't need to remove it. - switch (self.app.config.@"launched-from") { - .desktop => env.remove("LANGUAGE"), - .dbus, .systemd, .cli => {}, - } + if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE"); } return env; diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index e9ff3dd81..5f87613cd 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -416,9 +416,7 @@ pub const Application = extern struct { // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows // for launching Ghostty in the "background" without immediately opening - // a window). An initial window will not be immediately created if we were - // launched by D-Bus activation or systemd. D-Bus activation will send it's - // own `activate` or `new-window` signal later. + // a window). // // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 const priv = self.private(); @@ -426,15 +424,11 @@ pub const Application = extern struct { // We need to scope any config access because once we run our // event loop, this can change out from underneath us. const config = priv.config.get(); - if (config.@"initial-window") switch (config.@"launched-from") { - .desktop, .cli => self.as(gio.Application).activate(), - .dbus, .systemd => {}, - }; + if (config.@"initial-window") self.as(gio.Application).activate(); } // If we are NOT the primary instance, then we never want to run. - // This means that another instance of the GTK app is running and - // our "activate" call above will open a window. + // This means that another instance of the GTK app is running. if (self.as(gio.Application).getIsRemote() != 0) { log.debug( "application is remote, exiting run loop after activation", diff --git a/src/config/Config.zig b/src/config/Config.zig index debf5e9d5..221a7cf93 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -73,6 +73,10 @@ pub const compatibility = std.StaticStringMap( // Ghostty 1.2 merged `bold-is-bright` into the new `bold-color` // by setting the value to "bright". .{ "bold-is-bright", compatBoldIsBright }, + + // Ghostty 1.2 removed the "desktop" option and renamed it to "detect". + // The semantics also changed slightly but this is the correct mapping. + .{ "gtk-single-instance", compatGtkSingleInstance }, }); /// The font families to use. @@ -2975,16 +2979,23 @@ else /// /// If `false`, each new ghostty process will launch a separate application. /// -/// If `detect`, Ghostty will act as if it was `true` if one of the following -/// conditions is true: +/// If `detect`, Ghostty will assume true (single instance) unless one of +/// the following scenarios is found: /// -/// 1. If no CLI arguments have been set. -/// 2. If `--launched-from` has been set to `desktop`, `dbus`, or `systemd`. +/// 1. TERM_PROGRAM environment variable is a non-empty value. In this +/// case, we assume Ghostty is being launched from a graphical terminal +/// session and you want a dedicated instance. /// -/// Otherwise, Ghostty will act as if it was `false`. +/// 2. Any CLI arguments exist. In this case, we assume you are passing +/// custom Ghostty configuration. Single instance mode inherits the +/// configuration from when it was launched, so we must disable single +/// instance to load the new configuration. /// -/// The pre-1.2 option `desktop` has been deprecated. If encountered it will be -/// treated as `detect`. +/// If either of these scenarios is producing a false positive, you can +/// set this configuration explicitly to the behavior you want. +/// +/// The pre-1.2 option `desktop` has been deprecated. Please replace +/// this with `detect`. /// /// The default value is `detect`. /// @@ -3112,23 +3123,6 @@ term: []const u8 = "xterm-ghostty", /// running. Defaults to an empty string if not set. @"enquiry-response": []const u8 = "", -/// The mechanism used to launch Ghostty. This should generally not be -/// set by users, see the warning below. -/// -/// WARNING: This is a low-level configuration that is not intended to be -/// modified by users. All the values will be automatically detected as they -/// are needed by Ghostty. This is only here in case our detection logic is -/// incorrect for your environment or for developers who want to test -/// Ghostty's behavior in different, forced environments. -/// -/// Specific details about the available values are documented on LaunchSource -/// in the code. Since this isn't intended to be modified by users, the -/// documentation is lighter than the other configurations and users are -/// expected to refer to the code for details. -/// -/// Available since: 1.2.0 -@"launched-from": LaunchSource = .default, - /// Configures the low-level API to use for async IO, eventing, etc. /// /// Most users should leave this set to `auto`. This will automatically detect @@ -3493,24 +3487,8 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { switch (builtin.os.tag) { .windows => {}, - // Fast-path if we are Linux and have no args. - .linux, .freebsd => { - if (std.os.argv.len <= 1) { - if (self.@"gtk-single-instance" == .detect) { - const arena_alloc = self._arena.?.allocator(); - // Add an artificial replay step so that replaying the - // inputs doesn't undo this change. - try self._replay_steps.append( - arena_alloc, - .{ - .arg = "--gtk-single-instance=true", - }, - ); - self.@"gtk-single-instance" = .true; - } - return; - } - }, + // Fast-path if we are Linux/BSD and have no args. + .linux, .freebsd => if (std.os.argv.len <= 1) return, // Everything else we have to at least try because it may // not use std.os.argv. @@ -3606,34 +3584,6 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // directory. var buf: [std.fs.max_path_bytes]u8 = undefined; try self.expandPaths(try std.fs.cwd().realpath(".", &buf)); - - if (self.@"gtk-single-instance" == .detect) { - const arena_alloc = self._arena.?.allocator(); - switch (self.@"launched-from") { - .cli => { - // Add an artificial replay step so that replaying the - // inputs doesn't undo this change. - try self._replay_steps.append( - arena_alloc, - .{ - .arg = "--gtk-single-instance=false", - }, - ); - self.@"gtk-single-instance" = .false; - }, - .desktop, .systemd, .dbus => { - // Add an artificial replay step so that replaying the - // inputs doesn't undo this change. - try self._replay_steps.append( - arena_alloc, - .{ - .arg = "--gtk-single-instance=true", - }, - ); - self.@"gtk-single-instance" = .true; - }, - } - } } /// Load and parse the config files that were added in the "config-file" key. @@ -3967,6 +3917,10 @@ pub fn finalize(self: *Config) !void { const alloc = self._arena.?.allocator(); + // Used for a variety of defaults. See the function docs as well the + // specific variable use sites for more details. + const probable_cli = probableCliEnvironment(); + // If we have a font-family set and don't set the others, default // the others to the font family. This way, if someone does // --font-family=foo, then we try to get the stylized versions of @@ -3991,12 +3945,14 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse switch (self.@"launched-from") { - // If we have no working directory set, our default depends on - // whether we were launched from the desktop or elsewhere. - .desktop => "home", - .cli, .dbus, .systemd => "inherit", - }; + const wd = self.@"working-directory" orelse if (probable_cli) + // From the CLI, we want to inherit where we were launched from. + "inherit" + else + // Otherwise we typically just want the home directory because + // our pwd is probably a runtime state dir or root or something + // (launchers and desktop environments typically do this). + "home"; // If we are missing either a command or home directory, we need // to look up defaults which is kind of expensive. We only do this @@ -4016,12 +3972,9 @@ pub fn finalize(self: *Config) !void { if (internal_os.isFlatpak()) break :shell_env; // If we were launched from the desktop, our SHELL env var - // will represent our SHELL at login time. We want to use the - // latest shell from /etc/passwd or directory services. - switch (self.@"launched-from") { - .desktop, .dbus, .systemd => break :shell_env, - .cli => {}, - } + // will represent our SHELL at login time. We only want to + // read from SHELL if we're in a probable CLI environment. + if (!probable_cli) break :shell_env; if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| { log.info("default shell source=env value={s}", .{value}); @@ -4074,6 +4027,23 @@ pub fn finalize(self: *Config) !void { } } + // Apprt-specific defaults + switch (build_config.app_runtime) { + .none => {}, + .gtk => { + switch (self.@"gtk-single-instance") { + .true, .false => {}, + + // For detection, we assume single instance unless we're + // in a CLI environment, then we disable single instance. + .detect => self.@"gtk-single-instance" = if (probable_cli) + .false + else + .true, + } + }, + } + // If we have the special value "inherit" then set it to null which // does the same. In the future we should change to a tagged union. if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null; @@ -4201,6 +4171,23 @@ fn compatGtkTabsLocation( return false; } +fn compatGtkSingleInstance( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "gtk-single-instance")); + + if (std.mem.eql(u8, value orelse "", "desktop")) { + self.@"gtk-single-instance" = .detect; + return true; + } + + return false; +} + fn compatCursorInvertFgBg( self: *Config, alloc: Allocator, @@ -4538,6 +4525,32 @@ fn equalField(comptime T: type, old: T, new: T) bool { } } +/// This runs a heuristic to determine if we are likely running +/// Ghostty in a CLI environment. We need this to change some behaviors. +/// We should keep the set of behaviors that depend on this as small +/// as possible because magic sucks, but each place is well documented. +fn probableCliEnvironment() bool { + switch (builtin.os.tag) { + // Windows has its own problems, just ignore it for now since + // its not a real supported target and GTK via WSL2 assuming + // single instance is probably fine. + .windows => return false, + else => {}, + } + + // If we have TERM_PROGRAM set to a non-empty value, we assume + // a graphical terminal environment. + if (std.posix.getenv("TERM_PROGRAM")) |v| { + if (v.len > 0) return true; + } + + // CLI arguments makes things probable + if (std.os.argv.len > 1) return true; + + // Unlikely CLI environment + return false; +} + /// This is used to "replay" the configuration. See loadTheme for details. const Replay = struct { const Step = union(enum) { @@ -7175,18 +7188,6 @@ pub const GtkSingleInstance = enum { detect, pub const default: GtkSingleInstance = .detect; - - pub fn parseCLI(input_: ?[]const u8) error{ ValueRequired, InvalidValue }!GtkSingleInstance { - const input = std.mem.trim( - u8, - input_ orelse return error.ValueRequired, - cli.args.whitespace, - ); - - if (std.mem.eql(u8, input, "desktop")) return .detect; - - return std.meta.stringToEnum(GtkSingleInstance, input) orelse error.InvalidValue; - } }; /// See gtk-tabs-location @@ -8078,30 +8079,6 @@ pub const Duration = struct { } }; -pub const LaunchSource = enum { - /// Ghostty was launched via the CLI. This is the default on non-macOS - /// platforms. - cli, - - /// Ghostty was launched in a desktop environment (not via the CLI). - /// This is used to determine some behaviors such as how to read - /// settings, whether single instance defaults to true, etc. - /// - /// This is the default on macOS. - desktop, - - /// Ghostty was started via dbus activation. - dbus, - - /// Ghostty was started via systemd unit. - systemd, - - pub const default: LaunchSource = switch (builtin.os.tag) { - .macos => .desktop, - else => .cli, - }; -}; - pub const WindowPadding = struct { const Self = @This(); @@ -8766,6 +8743,27 @@ test "theme specifying light/dark sets theme usage in conditional state" { } } +test "compatibility: gtk-single-instance desktop" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--gtk-single-instance=desktop", + } }; + try cfg.loadIter(alloc, &it); + + // We need to test this BEFORE finalize, because finalize will + // convert our detect to a real value. + try testing.expectEqual( + GtkSingleInstance.detect, + cfg.@"gtk-single-instance", + ); + } +} + test "compatibility: removed cursor-invert-fg-bg" { const testing = std.testing; const alloc = testing.allocator; From 1ef220a6792b90e1633fcd1ee5410ba5001ffd0b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 5 Sep 2025 11:40:03 -0500 Subject: [PATCH 37/42] render: address review feedback 1. `inline` the table get. 2. Delete unused functions on the LUT table. 3. Disable the isSymbol test under valgrind --- src/unicode/lut.zig | 31 +++---------------------------- src/unicode/symbols.zig | 4 +++- 2 files changed, 6 insertions(+), 29 deletions(-) diff --git a/src/unicode/lut.zig b/src/unicode/lut.zig index e709bf1fe..e10c5c0b8 100644 --- a/src/unicode/lut.zig +++ b/src/unicode/lut.zig @@ -83,7 +83,7 @@ pub fn Generator( block_len += 1; // If we still have space and we're not done with codepoints, - // we keep building up the bock. Conversely: we finalize this + // we keep building up the block. Conversely: we finalize this // block if we've filled it or are out of codepoints. if (block_len < block_size and cp != std.math.maxInt(u21)) continue; if (block_len < block_size) @memset(block[block_len..block_size], 0); @@ -136,38 +136,12 @@ pub fn Tables(comptime Elem: type) type { stage3: []const Elem, /// Given a codepoint, returns the mapping for that codepoint. - pub fn get(self: *const Self, cp: u21) Elem { + pub inline fn get(self: *const Self, cp: u21) Elem { const high = cp >> 8; const low = cp & 0xFF; return self.stage3[self.stage2[self.stage1[high] + low]]; } - pub inline fn getInline(self: *const Self, cp: u21) Elem { - const high = cp >> 8; - const low = cp & 0xFF; - return self.stage3[self.stage2[self.stage1[high] + low]]; - } - - pub fn getBool(self: *const Self, cp: u21) bool { - assert(Elem == bool); - assert(self.stage3.len == 2); - assert(self.stage3[0] == false); - assert(self.stage3[1] == true); - const high = cp >> 8; - const low = cp & 0xFF; - return self.stage2[self.stage1[high] + low] != 0; - } - - pub inline fn getBoolInline(self: *const Self, cp: u21) bool { - assert(Elem == bool); - assert(self.stage3.len == 2); - assert(self.stage3[0] == false); - assert(self.stage3[1] == true); - const high = cp >> 8; - const low = cp & 0xFF; - return self.stage2[self.stage1[high] + low] != 0; - } - /// Writes the lookup table as Zig to the given writer. The /// written file exports three constants: stage1, stage2, and /// stage3. These can be used to rebuild the lookup table in Zig. @@ -199,6 +173,7 @@ pub fn Tables(comptime Elem: type) type { \\}; \\ }; \\} + \\ ); } }; diff --git a/src/unicode/symbols.zig b/src/unicode/symbols.zig index 3e038fe7d..3c2a84e76 100644 --- a/src/unicode/symbols.zig +++ b/src/unicode/symbols.zig @@ -78,7 +78,9 @@ pub fn main() !void { // This is not very fast in debug modes, so its commented by default. // IMPORTANT: UNCOMMENT THIS WHENEVER MAKING CHANGES. -test "unicode symbols1: tables match ziglyph" { +test "unicode symbols: tables match ziglyph" { + if (std.valgrind.runningOnValgrind() > 0) return error.SkipZigTest; + const testing = std.testing; for (0..std.math.maxInt(u21)) |cp| { From ef822612d3664f2e7cccd7d15b195b016bfdfc4e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Sep 2025 11:13:04 -0700 Subject: [PATCH 38/42] apprt/gtk: don't use Stacked for surface error status page Fixes #8533 Replace the usage of `Stacked` for error pages with programmatically swapping the child of the `adw.Bin`. I regret to say I don't know the root cause of this. I only know that the usage of `Stacked` plus `Gtk.Paned` and the way we programmatically change the paned position and stack child during initialization causes major issues. This change isn't without its warts, too, and you can see them heavily commented in the diff. (1) We have to workaround a GTK template double-free bug that is well known to us: if you bind a template child that is also the direct child of the template class, GTK does a double free on dispose. We workaround this by removing our child in dispose. Valgrind verifies the fix. (2) We have to workaround an issue where setting an `Adw.Bin` child during a glarea realize causes some kind of critical GTK error that results in a hard crash. We delay changing our bin child to an idle tick. --- src/apprt/gtk/class/surface.zig | 54 ++++-- src/apprt/gtk/ui/1.2/surface.blp | 320 +++++++++++++++---------------- 2 files changed, 197 insertions(+), 177 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 25ee1f94f..c26d0c1ef 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -496,6 +496,9 @@ pub const Surface = extern struct { /// if this is true, then it means the terminal is non-functional. @"error": bool = false, + /// The source that handles setting our child property. + idle_rechild: ?c_uint = null, + /// A weak reference to an inspector window. inspector: ?*InspectorWindow = null, @@ -504,6 +507,8 @@ pub const Surface = extern struct { context_menu: *gtk.PopoverMenu, drop_target: *gtk.DropTarget, progress_bar_overlay: *gtk.ProgressBar, + error_page: *adw.StatusPage, + terminal_page: *gtk.Overlay, pub var offset: c_int = 0; }; @@ -595,17 +600,6 @@ pub const Surface = extern struct { return @intFromBool(config.@"bell-features".border); } - fn closureStackChildName( - _: *Self, - error_: c_int, - ) callconv(.c) ?[*:0]const u8 { - const err = error_ != 0; - return if (err) - glib.ext.dupeZ(u8, "error") - else - glib.ext.dupeZ(u8, "terminal"); - } - pub fn toggleFullscreen(self: *Self) void { signals.@"toggle-fullscreen".impl.emit( self, @@ -1370,6 +1364,19 @@ pub const Surface = extern struct { priv.progress_bar_timer = null; } + if (priv.idle_rechild) |v| { + if (glib.Source.remove(v) == 0) { + log.warn("unable to remove idle source", .{}); + } + priv.idle_rechild = null; + } + + // This works around a GTK double-free bug where if you bind + // to a top-level template child, it frees twice if the widget is + // also the root child of the template. By unsetting the child here, + // we avoid the double-free. + self.as(adw.Bin).setChild(null); + gtk.Widget.disposeTemplate( self.as(gtk.Widget), getGObjectType(), @@ -1651,8 +1658,26 @@ pub const Surface = extern struct { self.as(gtk.Widget).removeCssClass("background"); } - // Note above: in both cases setting our error view is handled by - // a Gtk.Stack visible-child-name binding. + // We need to set our child property on an idle tick, because the + // error property can be triggered by signals that are in the middle + // of widget mapping and changing our child during that time + // results in a hard gtk crash. + if (priv.idle_rechild == null) priv.idle_rechild = glib.idleAdd( + onIdleRechild, + self, + ); + } + + fn onIdleRechild(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + priv.idle_rechild = null; + if (priv.@"error") { + self.as(adw.Bin).setChild(priv.error_page.as(gtk.Widget)); + } else { + self.as(adw.Bin).setChild(priv.terminal_page.as(gtk.Widget)); + } + return 0; } fn propMouseHoverUrl( @@ -2699,8 +2724,10 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("url_right", .{}); class.bindTemplateChildPrivate("child_exited_overlay", .{}); class.bindTemplateChildPrivate("context_menu", .{}); + class.bindTemplateChildPrivate("error_page", .{}); class.bindTemplateChildPrivate("progress_bar_overlay", .{}); class.bindTemplateChildPrivate("resize_overlay", .{}); + class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); class.bindTemplateChildPrivate("im_context", .{}); @@ -2736,7 +2763,6 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); class.bindTemplateCallback("notify_bell_ringing", &propBellRinging); class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); - class.bindTemplateCallback("stack_child_name", &closureStackChildName); // Properties gobject.ext.registerProperties(class, &.{ diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index 39c88ff33..f22f2c09a 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -1,6 +1,155 @@ using Gtk 4.0; using Adw 1; +Adw.StatusPage error_page { + icon-name: "computer-fail-symbolic"; + title: _("Oh, no."); + description: _("Unable to acquire an OpenGL context for rendering."); + + child: LinkButton { + label: "https://ghostty.org/docs/help/gtk-opengl-context"; + uri: "https://ghostty.org/docs/help/gtk-opengl-context"; + }; +} + +Overlay terminal_page { + focusable: false; + focus-on-click: false; + + child: Box { + hexpand: true; + vexpand: true; + + GLArea gl_area { + realize => $gl_realize(); + unrealize => $gl_unrealize(); + render => $gl_render(); + resize => $gl_resize(); + hexpand: true; + vexpand: true; + focusable: true; + focus-on-click: true; + has-stencil-buffer: false; + has-depth-buffer: false; + allowed-apis: gl; + } + + PopoverMenu context_menu { + closed => $context_menu_closed(); + menu-model: context_menu_model; + flags: nested; + halign: start; + has-arrow: false; + } + }; + + [overlay] + ProgressBar progress_bar_overlay { + styles [ + "osd", + ] + + visible: false; + halign: fill; + valign: start; + } + + [overlay] + // The "border" bell feature is implemented here as an overlay rather than + // just adding a border to the GLArea or other widget for two reasons. + // First, adding a border to an existing widget causes a resize of the + // widget which undesirable side effects. Second, we can make it reactive + // here in the blueprint with relatively little code. + Revealer { + reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; + transition-type: crossfade; + transition-duration: 500; + + Box bell_overlay { + styles [ + "bell-overlay", + ] + + halign: fill; + valign: fill; + } + } + + [overlay] + $GhosttySurfaceChildExited child_exited_overlay { + visible: bind template.child-exited; + close-request => $child_exited_close(); + } + + [overlay] + $GhosttyResizeOverlay resize_overlay {} + + [overlay] + Label url_left { + styles [ + "background", + "url-overlay", + ] + + visible: false; + halign: start; + valign: end; + label: bind template.mouse-hover-url; + + EventControllerMotion url_ec_motion { + enter => $url_mouse_enter(); + leave => $url_mouse_leave(); + } + } + + [overlay] + Label url_right { + styles [ + "background", + "url-overlay", + ] + + visible: false; + halign: end; + valign: end; + label: bind template.mouse-hover-url; + } + + // Event controllers for interactivity + EventControllerFocus { + enter => $focus_enter(); + leave => $focus_leave(); + } + + EventControllerKey { + key-pressed => $key_pressed(); + key-released => $key_released(); + } + + EventControllerMotion { + motion => $mouse_motion(); + leave => $mouse_leave(); + } + + EventControllerScroll { + scroll => $scroll(); + scroll-begin => $scroll_begin(); + scroll-end => $scroll_end(); + flags: both_axes; + } + + GestureClick { + pressed => $mouse_down(); + released => $mouse_up(); + button: 0; + } + + DropTarget drop_target { + drop => $drop(); + actions: copy; + } +} + template $GhosttySurface: Adw.Bin { styles [ "surface", @@ -12,169 +161,14 @@ template $GhosttySurface: Adw.Bin { notify::mouse-hover-url => $notify_mouse_hover_url(); notify::mouse-hidden => $notify_mouse_hidden(); notify::mouse-shape => $notify_mouse_shape(); - - Stack { - StackPage { - name: "terminal"; - - child: Overlay { - focusable: false; - focus-on-click: false; - - child: Box { - hexpand: true; - vexpand: true; - - GLArea gl_area { - realize => $gl_realize(); - unrealize => $gl_unrealize(); - render => $gl_render(); - resize => $gl_resize(); - hexpand: true; - vexpand: true; - focusable: true; - focus-on-click: true; - has-stencil-buffer: false; - has-depth-buffer: false; - allowed-apis: gl; - } - - PopoverMenu context_menu { - closed => $context_menu_closed(); - menu-model: context_menu_model; - flags: nested; - halign: start; - has-arrow: false; - } - }; - - [overlay] - ProgressBar progress_bar_overlay { - styles [ - "osd", - ] - - visible: false; - halign: fill; - valign: start; - } - - [overlay] - // The "border" bell feature is implemented here as an overlay rather than - // just adding a border to the GLArea or other widget for two reasons. - // First, adding a border to an existing widget causes a resize of the - // widget which undesirable side effects. Second, we can make it reactive - // here in the blueprint with relatively little code. - Revealer { - reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as ; - transition-type: crossfade; - transition-duration: 500; - - Box bell_overlay { - styles [ - "bell-overlay", - ] - - halign: fill; - valign: fill; - } - } - - [overlay] - $GhosttySurfaceChildExited child_exited_overlay { - visible: bind template.child-exited; - close-request => $child_exited_close(); - } - - [overlay] - $GhosttyResizeOverlay resize_overlay {} - - [overlay] - Label url_left { - styles [ - "background", - "url-overlay", - ] - - visible: false; - halign: start; - valign: end; - label: bind template.mouse-hover-url; - - EventControllerMotion url_ec_motion { - enter => $url_mouse_enter(); - leave => $url_mouse_leave(); - } - } - - [overlay] - Label url_right { - styles [ - "background", - "url-overlay", - ] - - visible: false; - halign: end; - valign: end; - label: bind template.mouse-hover-url; - } - - // Event controllers for interactivity - EventControllerFocus { - enter => $focus_enter(); - leave => $focus_leave(); - } - - EventControllerKey { - key-pressed => $key_pressed(); - key-released => $key_released(); - } - - EventControllerMotion { - motion => $mouse_motion(); - leave => $mouse_leave(); - } - - EventControllerScroll { - scroll => $scroll(); - scroll-begin => $scroll_begin(); - scroll-end => $scroll_end(); - flags: both_axes; - } - - GestureClick { - pressed => $mouse_down(); - released => $mouse_up(); - button: 0; - } - - DropTarget drop_target { - drop => $drop(); - actions: copy; - } - }; - } - - StackPage { - name: "error"; - - child: Adw.StatusPage { - icon-name: "computer-fail-symbolic"; - title: _("Oh, no."); - description: _("Unable to acquire an OpenGL context for rendering."); - - child: LinkButton { - label: "https://ghostty.org/docs/help/gtk-opengl-context"; - uri: "https://ghostty.org/docs/help/gtk-opengl-context"; - }; - }; - } - - // The order matters here: we can only set this after the stack - // pages above have been created. - visible-child-name: bind $stack_child_name(template.error) as ; - } + // Some history: we used to use a Stack here and swap between the + // terminal and error pages as needed. But a Stack doesn't play nice + // with our SplitTree and Gtk.Paned usage[^1]. Replacing this with + // a manual programmatic child swap fixed this. So if you ever change + // this, be sure to test many splits! + // + // [^1]: https://github.com/ghostty-org/ghostty/issues/8533 + child: terminal_page; } IMMulticontext im_context { From 12bd7baaeb738ae290d48f498b3e9d701af1982d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Sep 2025 11:51:52 -0700 Subject: [PATCH 39/42] apprt/gtk: set the title on the window immediately if set Fixes #5934 This was never confirmed to be a real issue on GTK, but it is theoretically possible and good hygience in general. Typically, we'd get the title through a binding which comes from a bindinggroup which comes from the active surface in the active tab. All of this takes multiple event loop ticks to settle, if you will. This commit changes it so that if an explicit, static title is set, we set that title on startup before the window is mapped. The syncing still happens later, but at least the window will have a title from the initialization. --- src/apprt/gtk/class/window.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index 862455fc8..df6ea647f 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -306,6 +306,13 @@ pub const Window = extern struct { const config = config_obj.get(); if (config.maximize) self.as(gtk.Window).maximize(); if (config.fullscreen) self.as(gtk.Window).fullscreen(); + + // If we have an explicit title set, we set that immediately + // so that any applications inspecting the window states see + // an immediate title set when the window appears, rather than + // waiting possibly a few event loop ticks for it to sync from + // the surface. + if (config.title) |v| self.as(gtk.Window).setTitle(v); } // We always sync our appearance at the end because loading our From 8824256059d130a48d9daa4b489de21a043cfcd2 Mon Sep 17 00:00:00 2001 From: Jesse Miller Date: Fri, 5 Sep 2025 13:34:10 -0600 Subject: [PATCH 40/42] Micro-optimize GlyphKey Context Use fast hash function on key for better distribution. Direct compare glyph in eql to avoid Packed.from() if not neccessary. 16% -> 6.4% reduction during profiling runs. --- src/font/SharedGrid.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 980b0314c..ff05d1a59 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -332,11 +332,12 @@ const GlyphKey = struct { const Context = struct { pub fn hash(_: Context, key: GlyphKey) u64 { - return @bitCast(Packed.from(key)); + const packed_key = Packed.from(key); + return std.hash.CityHash64.hash(std.mem.asBytes(&packed_key)); } pub fn eql(_: Context, a: GlyphKey, b: GlyphKey) bool { - return Packed.from(a) == Packed.from(b); + return a.glyph == b.glyph and Packed.from(a) == Packed.from(b); } }; From cf39d5c512655cfd01d9da53531e965f024396c2 Mon Sep 17 00:00:00 2001 From: Jesse Miller Date: Fri, 5 Sep 2025 15:52:41 -0600 Subject: [PATCH 41/42] Glphkey.hash CityHash64 -> hash.int --- src/font/SharedGrid.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index ff05d1a59..e79fd117f 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -332,11 +332,15 @@ const GlyphKey = struct { const Context = struct { pub fn hash(_: Context, key: GlyphKey) u64 { + // Packed is a u64 but std.hash.int improves uniformity and + // avoids collisions in our hashmap. const packed_key = Packed.from(key); - return std.hash.CityHash64.hash(std.mem.asBytes(&packed_key)); + return std.hash.int(@as(u64, @bitCast(packed_key))); } pub fn eql(_: Context, a: GlyphKey, b: GlyphKey) bool { + // Packed checks glyphs but in most cases the glyphs are NOT + // equal so the first check leads to increased throughput. return a.glyph == b.glyph and Packed.from(a) == Packed.from(b); } }; From 314191737d35f2a9ca2f4e7c368ae3d7f1db094d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 6 Sep 2025 09:10:49 -0500 Subject: [PATCH 42/42] update zon2nix to version that builds with Zig 0.15 --- flake.lock | 11 ++++------- flake.nix | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/flake.lock b/flake.lock index ba1adb08a..e4a5d0be8 100644 --- a/flake.lock +++ b/flake.lock @@ -112,23 +112,20 @@ }, "zon2nix": { "inputs": { - "flake-utils": [ - "flake-utils" - ], "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1756000480, - "narHash": "sha256-fR5pdcjO0II5MNdCzqvyokyuFkmff7/FyBAjUS6sMfA=", + "lastModified": 1757167408, + "narHash": "sha256-4XyJ6fmKd9wgJ7vHUQuULYy5ps2gUgkkDk/PrJb2OPY=", "owner": "jcollie", "repo": "zon2nix", - "rev": "d9dc9ef1ab9ae45b5c9d80c6a747cc9968ee0c60", + "rev": "dc78177e2ad28d5a407c9e783ee781bd559d7dd5", "type": "github" }, "original": { "owner": "jcollie", "repo": "zon2nix", - "rev": "d9dc9ef1ab9ae45b5c9d80c6a747cc9968ee0c60", + "rev": "dc78177e2ad28d5a407c9e783ee781bd559d7dd5", "type": "github" } } diff --git a/flake.nix b/flake.nix index 99f7fcb7c..dd97744b6 100644 --- a/flake.nix +++ b/flake.nix @@ -24,7 +24,7 @@ }; zon2nix = { - url = "github:jcollie/zon2nix?rev=d9dc9ef1ab9ae45b5c9d80c6a747cc9968ee0c60"; + url = "github:jcollie/zon2nix?rev=dc78177e2ad28d5a407c9e783ee781bd559d7dd5"; inputs = { # Don't override nixpkgs until Zig 0.15 is available in the Nix branch # we are using for "normal" builds.