From c16dbc01f0ffcc5ff61156dcf9a0d17eb5a5f3ef Mon Sep 17 00:00:00 2001 From: Ryan Rotter Date: Tue, 31 Dec 2024 22:59:32 -0500 Subject: [PATCH 001/238] correct default keybinding cmd+backspace for macOS cmd+backspace=text:\x15 --- src/config/Config.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 91c07cc78..f03a0d726 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2603,7 +2603,7 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ .key = .{ .translated = .backspace }, .mods = .{ .super = true } }, - .{ .esc = "\x15" }, + .{ .text = "\\x15" }, ); try result.keybind.set.put( alloc, From b52e76334ee634df243750065d0dba66ca657a52 Mon Sep 17 00:00:00 2001 From: Damien MEHALA Date: Wed, 1 Jan 2025 17:02:06 +0100 Subject: [PATCH 002/238] feat: parse ConEmu OSC9;1 --- src/terminal/osc.zig | 84 ++++++++++++++++++++++++++++++++++++++++- src/terminal/stream.zig | 2 +- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 19d8212a0..da081daa9 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -67,7 +67,7 @@ pub const Command = union(enum) { /// End of current command. /// - /// The exit-code need not be specified if if there are no options, + /// The exit-code need not be specified if there are no options, /// or if the command was cancelled (no OSC "133;C"), such as by typing /// an interrupt/cancel character (typically ctrl-C) during line-editing. /// Otherwise, it must be an integer code, where 0 means the command @@ -158,6 +158,9 @@ pub const Command = union(enum) { /// End a hyperlink (OSC 8) hyperlink_end: void, + /// Sleep (OSC 9;1) in ms + sleep: u16, + /// Set progress state (OSC 9;4) progress: struct { state: ProgressState, @@ -353,6 +356,8 @@ pub const Parser = struct { osc_9, // ConEmu specific substates + conemu_sleep, + conemu_sleep_value, conemu_progress_prestate, conemu_progress_state, conemu_progress_prevalue, @@ -777,6 +782,9 @@ pub const Parser = struct { }, .osc_9 => switch (c) { + '1' => { + self.state = .conemu_sleep; + }, '4' => { self.state = .conemu_progress_prestate; }, @@ -788,6 +796,21 @@ pub const Parser = struct { else => self.showDesktopNotification(), }, + .conemu_sleep => switch (c) { + ';' => { + self.command = .{ .sleep = 100 }; + self.buf_start = self.buf_idx; + self.complete = true; + self.state = .conemu_sleep_value; + }, + else => self.state = .invalid, + }, + + .conemu_sleep_value => switch (c) { + '0'...'9' => {}, + else => self.state = .invalid, + }, + .conemu_progress_prestate => switch (c) { ';' => { self.command = .{ .progress = .{ @@ -1147,6 +1170,22 @@ pub const Parser = struct { self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx]; } + fn endConEmuSleepValue(self: *Parser) void { + switch (self.command) { + .sleep => |*v| { + const str = self.buf[self.buf_start..self.buf_idx]; + if (str.len != 0) { + if (std.fmt.parseUnsigned(u16, str, 10)) |num| { + v.* = @min(num, 10000); + } else |_| { + v.* = 10000; + } + } + }, + else => {}, + } + } + fn endKittyColorProtocolOption(self: *Parser, kind: enum { key_only, key_and_value }, final: bool) void { if (self.temp_state.key.len == 0) { log.warn("zero length key in kitty color protocol", .{}); @@ -1225,6 +1264,7 @@ pub const Parser = struct { .semantic_option_value => self.endSemanticOptionValue(), .hyperlink_uri => self.endHyperlink(), .string => self.endString(), + .conemu_sleep_value => self.endConEmuSleepValue(), .allocable_string => self.endAllocableString(), .kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true), .kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true), @@ -1634,6 +1674,48 @@ test "OSC: set palette color" { try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc"); } +test "OSC: conemu sleep" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;1;420"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + try testing.expect(cmd == .sleep); + try testing.expectEqual(420, cmd.sleep); +} + +test "OSC: conemu sleep with no value default to 100ms" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;1;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + try testing.expect(cmd == .sleep); + try testing.expectEqual(100, cmd.sleep); +} + +test "OSC: conemu sleep cannot exceed 10000ms" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;1;12345"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + try testing.expect(cmd == .sleep); + try testing.expectEqual(10000, cmd.sleep); +} + test "OSC: show desktop notification" { const testing = std.testing; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 59a8e704d..8772050a9 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1455,7 +1455,7 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .progress => { + .progress, .sleep => { log.warn("unimplemented OSC callback: {}", .{cmd}); }, } From c98d207eb98b1a0d822e148c79792f1b17c3a98a Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Thu, 2 Jan 2025 00:13:55 +0100 Subject: [PATCH 003/238] code review - Add test with invalid value. - Fix inspector compilation. --- src/inspector/termio.zig | 2 +- src/terminal/osc.zig | 31 +++++++++++++++++++++++-------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 78b35e19b..18a692822 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -265,7 +265,7 @@ pub const VTEvent = struct { ), else => switch (Value) { - u8 => try md.put( + u8, u16 => try md.put( key, try std.fmt.allocPrintZ(alloc, "{}", .{value}), ), diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index da081daa9..300e1e5b5 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -158,8 +158,10 @@ pub const Command = union(enum) { /// End a hyperlink (OSC 8) hyperlink_end: void, - /// Sleep (OSC 9;1) in ms - sleep: u16, + /// Sleep (OSC 9;1) + sleep: struct { + duration_ms: u16, + }, /// Set progress state (OSC 9;4) progress: struct { @@ -798,7 +800,7 @@ pub const Parser = struct { .conemu_sleep => switch (c) { ';' => { - self.command = .{ .sleep = 100 }; + self.command = .{ .sleep = .{ .duration_ms = 100 } }; self.buf_start = self.buf_idx; self.complete = true; self.state = .conemu_sleep_value; @@ -1176,9 +1178,9 @@ pub const Parser = struct { const str = self.buf[self.buf_start..self.buf_idx]; if (str.len != 0) { if (std.fmt.parseUnsigned(u16, str, 10)) |num| { - v.* = @min(num, 10000); + v.duration_ms = @min(num, 10_000); } else |_| { - v.* = 10000; + v.duration_ms = 10_000; } } }, @@ -1685,7 +1687,7 @@ test "OSC: conemu sleep" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .sleep); - try testing.expectEqual(420, cmd.sleep); + try testing.expectEqual(420, cmd.sleep.duration_ms); } test "OSC: conemu sleep with no value default to 100ms" { @@ -1699,7 +1701,7 @@ test "OSC: conemu sleep with no value default to 100ms" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .sleep); - try testing.expectEqual(100, cmd.sleep); + try testing.expectEqual(100, cmd.sleep.duration_ms); } test "OSC: conemu sleep cannot exceed 10000ms" { @@ -1713,7 +1715,20 @@ test "OSC: conemu sleep cannot exceed 10000ms" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .sleep); - try testing.expectEqual(10000, cmd.sleep); + try testing.expectEqual(10000, cmd.sleep.duration_ms); +} + +test "OSC: conemu sleep invalid input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;1;foo"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + + try testing.expect(cmd == null); } test "OSC: show desktop notification" { From 9d9fa60ece3736001be39ec2ac80b0e6c4b44f17 Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Thu, 2 Jan 2025 23:57:53 +0100 Subject: [PATCH 004/238] code review - Default to 100 if the value can't be parsed as an integer or is missing entirely. --- src/terminal/osc.zig | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 300e1e5b5..1a336a604 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -809,8 +809,7 @@ pub const Parser = struct { }, .conemu_sleep_value => switch (c) { - '0'...'9' => {}, - else => self.state = .invalid, + else => self.complete = true, }, .conemu_progress_prestate => switch (c) { @@ -1174,14 +1173,14 @@ pub const Parser = struct { fn endConEmuSleepValue(self: *Parser) void { switch (self.command) { - .sleep => |*v| { + .sleep => |*v| v.duration_ms = value: { const str = self.buf[self.buf_start..self.buf_idx]; - if (str.len != 0) { - if (std.fmt.parseUnsigned(u16, str, 10)) |num| { - v.duration_ms = @min(num, 10_000); - } else |_| { - v.duration_ms = 10_000; - } + if (str.len == 0) break :value 100; + + if (std.fmt.parseUnsigned(u16, str, 10)) |num| { + break :value @min(num, 10_000); + } else |_| { + break :value 100; } }, else => {}, @@ -1726,9 +1725,10 @@ test "OSC: conemu sleep invalid input" { const input = "9;1;foo"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b'); + const cmd = p.end('\x1b').?; - try testing.expect(cmd == null); + try testing.expect(cmd == .sleep); + try testing.expectEqual(100, cmd.sleep.duration_ms); } test "OSC: show desktop notification" { From 8d7ed3e0fc2e1e0c220d7c330c121ed56298df2b Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Fri, 3 Jan 2025 00:20:54 +0100 Subject: [PATCH 005/238] feat: parse ConEmu OSC9;2 --- src/terminal/osc.zig | 32 ++++++++++++++++++++++++++++++++ src/terminal/stream.zig | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 19d8212a0..848a03ec1 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -158,6 +158,11 @@ pub const Command = union(enum) { /// End a hyperlink (OSC 8) hyperlink_end: void, + /// Show GUI message Box (OSC 9;2) + show_message_box: struct { + content: []const u8, + }, + /// Set progress state (OSC 9;4) progress: struct { state: ProgressState, @@ -353,6 +358,7 @@ pub const Parser = struct { osc_9, // ConEmu specific substates + conemu_message_box, conemu_progress_prestate, conemu_progress_state, conemu_progress_prevalue, @@ -777,6 +783,9 @@ pub const Parser = struct { }, .osc_9 => switch (c) { + '2' => { + self.state = .conemu_message_box; + }, '4' => { self.state = .conemu_progress_prestate; }, @@ -788,6 +797,16 @@ pub const Parser = struct { else => self.showDesktopNotification(), }, + .conemu_message_box => switch (c) { + ';' => { + self.command = .{ .show_message_box = .{ .content = undefined } }; + self.temp_state = .{ .str = &self.command.show_message_box.content }; + self.buf_start = self.buf_idx; + self.prepAllocableString(); + }, + else => self.state = .invalid, + }, + .conemu_progress_prestate => switch (c) { ';' => { self.command = .{ .progress = .{ @@ -1662,6 +1681,19 @@ test "OSC: show desktop notification with title" { try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); } +test "OSC: OSC9;2 conemu message box" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;2;hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_message_box); + try testing.expectEqualStrings("hello world", cmd.show_message_box.content); +} + test "OSC: OSC9 progress set" { const testing = std.testing; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index a4a32e169..5e0752fc9 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1605,7 +1605,7 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .progress => { + .progress, .show_message_box => { log.warn("unimplemented OSC callback: {}", .{cmd}); }, } From 9fa404c3907d3cb483f2a038170ce39197f07707 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Fri, 3 Jan 2025 16:23:10 +0800 Subject: [PATCH 006/238] Ensure all search results are visible in theme list When searching in the theme list (e.g., searching for "Snazzy"), some matching themes might be hidden due to incorrect window position handling. This fix ensures all matching themes are visible by adjusting the window position logic. --- src/cli/list_themes.zig | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index c4dd415e7..22e22a972 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -11,6 +11,12 @@ const global_state = &@import("../global.zig").state; const vaxis = @import("vaxis"); const zf = @import("zf"); +// When the number of filtered themes is less than or equal to this threshold, +// the window position will be reset to 0 to show all results from the top. +// This ensures better visibility for small result sets while maintaining +// scroll position for larger lists. +const SMALL_LIST_THRESHOLD = 10; + pub const Options = struct { /// If true, print the full path to the theme. path: bool = false, @@ -323,9 +329,15 @@ const Preview = struct { } self.current, self.window = current: { + if (selected.len == 0) break :current .{ 0, 0 }; + for (self.filtered.items, 0..) |index, i| { - if (std.mem.eql(u8, self.themes[index].theme, selected)) - break :current .{ i, i -| relative }; + if (std.mem.eql(u8, self.themes[index].theme, selected)) { + // Keep the relative position but ensure all search results are visible + const new_window = i -| relative; + // If the new window would hide some results at the top, adjust it + break :current .{ i, if (self.filtered.items.len <= SMALL_LIST_THRESHOLD) 0 else new_window }; + } } break :current .{ 0, 0 }; }; From 8a3aae2cafe41c9f52d5ed62c30050013cfdee31 Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Fri, 3 Jan 2025 11:57:31 +0100 Subject: [PATCH 007/238] code review - Change show_message_box from struct to string. - Add tests: - Blank message - Spaces only message - No trailing semicolon OSC 9;2 --- src/terminal/osc.zig | 51 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 848a03ec1..a66e9d8d3 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -159,9 +159,7 @@ pub const Command = union(enum) { hyperlink_end: void, /// Show GUI message Box (OSC 9;2) - show_message_box: struct { - content: []const u8, - }, + show_message_box: []const u8, /// Set progress state (OSC 9;4) progress: struct { @@ -799,9 +797,10 @@ pub const Parser = struct { .conemu_message_box => switch (c) { ';' => { - self.command = .{ .show_message_box = .{ .content = undefined } }; - self.temp_state = .{ .str = &self.command.show_message_box.content }; + self.command = .{ .show_message_box = undefined }; + self.temp_state = .{ .str = &self.command.show_message_box }; self.buf_start = self.buf_idx; + self.complete = true; self.prepAllocableString(); }, else => self.state = .invalid, @@ -1681,7 +1680,7 @@ test "OSC: show desktop notification with title" { try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); } -test "OSC: OSC9;2 conemu message box" { +test "OSC: conemu message box" { const testing = std.testing; var p: Parser = .{}; @@ -1691,7 +1690,45 @@ test "OSC: OSC9;2 conemu message box" { const cmd = p.end('\x1b').?; try testing.expect(cmd == .show_message_box); - try testing.expectEqualStrings("hello world", cmd.show_message_box.content); + try testing.expectEqualStrings("hello world", cmd.show_message_box); +} + +test "OSC: conemu message box invalid input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;2"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} + +test "OSC: conemu message box empty message" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;2;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_message_box); + try testing.expectEqualStrings("", cmd.show_message_box); +} + +test "OSC: conemu message box spaces only message" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;2; "; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_message_box); + try testing.expectEqualStrings(" ", cmd.show_message_box); } test "OSC: OSC9 progress set" { From 6b4e6d2fa53c8cfae865356dcc780d9b49f7feae Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Fri, 3 Jan 2025 12:48:52 +0100 Subject: [PATCH 008/238] feat: Display memory usage and and limit in both bytes and Kb for improved readability --- src/inspector/Inspector.zig | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 7dd61c8a1..eae881ec4 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -285,6 +285,10 @@ fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { cimgui.c.igDockBuilderFinish(dock_id_main); } +fn bytesToKb(bytes: usize) usize { + return bytes / 1024; +} + fn renderScreenWindow(self: *Inspector) void { // Start our window. If we're collapsed we do nothing. defer cimgui.c.igEnd(); @@ -440,7 +444,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", kitty_images.total_bytes); + cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_bytes, bytesToKb(kitty_images.total_bytes)); } } @@ -452,7 +456,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", kitty_images.total_limit); + cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_limit, bytesToKb(kitty_images.total_limit)); } } @@ -518,7 +522,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", pages.page_size); + cimgui.c.igText("%d bytes (%d KB)", pages.page_size, bytesToKb(pages.page_size)); } } @@ -530,7 +534,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes", pages.maxSize()); + cimgui.c.igText("%d bytes (%d KB)", pages.maxSize(), bytesToKb(pages.maxSize())); } } From b0404867b78dff773f57937988deec960bf9bccf Mon Sep 17 00:00:00 2001 From: acehinnnqru Date: Fri, 3 Jan 2025 22:44:26 +0800 Subject: [PATCH 009/238] fix: macos incorrect quick terminal position --- .../Features/QuickTerminal/QuickTerminalPosition.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index 3d2a2a045..0acbfec1b 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -89,13 +89,13 @@ enum QuickTerminalPosition : String { return .init(x: screen.frame.minX, y: -window.frame.height) case .left: - return .init(x: -window.frame.width, y: 0) + return .init(x: screen.frame.minX-window.frame.width, y: 0) case .right: return .init(x: screen.frame.maxX, y: 0) case .center: - return .init(x: (screen.visibleFrame.maxX - window.frame.width) / 2, y: screen.visibleFrame.maxY - window.frame.width) + return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.height - window.frame.width) } } @@ -115,7 +115,7 @@ enum QuickTerminalPosition : String { return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y) case .center: - return .init(x: (screen.visibleFrame.maxX - window.frame.width) / 2, y: (screen.visibleFrame.maxY - window.frame.height) / 2) + return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2) } } } From 25a112469c7dabea552b33165868206d3caeeb5a Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 3 Jan 2025 14:19:19 -0500 Subject: [PATCH 010/238] font(coretext): add config to adjust strength of `font-thicken`. This is achieved by rendering to an alpha-only context rather than a normal single-channel context, and adjusting the brightness at which coretext thinks it's drawing the glyph, which affects how it applies font smoothing (which is what `font-thicken` enables). --- src/config/Config.zig | 14 ++++++++++++-- src/font/face.zig | 9 +++++++++ src/font/face/coretext.zig | 9 +++++---- src/renderer/Metal.zig | 3 +++ src/renderer/OpenGL.zig | 3 +++ 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index e1a7483ea..d6448b960 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -234,10 +234,20 @@ const c = @cImport({ /// i.e. new windows, tabs, etc. @"font-codepoint-map": RepeatableCodepointMap = .{}, -/// Draw fonts with a thicker stroke, if supported. This is only supported -/// currently on macOS. +/// Draw fonts with a thicker stroke, if supported. +/// This is currently only supported on macOS. @"font-thicken": bool = false, +/// Strength of thickening when `font-thicken` is enabled. +/// +/// Valid values are integers between `0` and `255`. `0` does not correspond to +/// *no* thickening, rather it corresponds to the lightest available thickening. +/// +/// Has no effect when `font-thicken` is set to `false`. +/// +/// This is currently only supported on macOS. +@"font-thicken-strength": u8 = 255, + /// All of the configurations behavior adjust various metrics determined by the /// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%, /// etc.). In each case, the values represent the amount to change the original diff --git a/src/font/face.zig b/src/font/face.zig index 9f80c5637..1c74515e3 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -100,6 +100,15 @@ pub const RenderOptions = struct { /// /// This only works with CoreText currently. thicken: bool = false, + + /// "Strength" of the thickening, between `0` and `255`. + /// Only has an effect when `thicken` is enabled. + /// + /// `0` does not correspond to *no* thickening, + /// just the *lightest* thickening available. + /// + /// CoreText only. + thicken_strength: u8 = 255, }; test { diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index dd4f6432e..8da2b6a55 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -354,7 +354,7 @@ pub const Face = struct { .depth = 1, .space = try macos.graphics.ColorSpace.createDeviceGray(), .context_opts = @intFromEnum(macos.graphics.BitmapInfo.alpha_mask) & - @intFromEnum(macos.graphics.ImageAlphaInfo.none), + @intFromEnum(macos.graphics.ImageAlphaInfo.only), } else .{ .color = true, .depth = 4, @@ -398,7 +398,7 @@ pub const Face = struct { if (color.color) context.setRGBFillColor(ctx, 1, 1, 1, 0) else - context.setGrayFillColor(ctx, 0, 0); + context.setGrayFillColor(ctx, 1, 0); context.fillRect(ctx, .{ .origin = .{ .x = 0, .y = 0 }, .size = .{ @@ -421,8 +421,9 @@ pub const Face = struct { context.setRGBFillColor(ctx, 1, 1, 1, 1); context.setRGBStrokeColor(ctx, 1, 1, 1, 1); } else { - context.setGrayFillColor(ctx, 1, 1); - context.setGrayStrokeColor(ctx, 1, 1); + const strength: f64 = @floatFromInt(opts.thicken_strength); + context.setGrayFillColor(ctx, strength / 255.0, 1); + context.setGrayStrokeColor(ctx, strength / 255.0, 1); } // If we are drawing with synthetic bold then use a fill stroke diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index b37f440f4..75e61ebc0 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -360,6 +360,7 @@ pub const DerivedConfig = struct { arena: ArenaAllocator, font_thicken: bool, + font_thicken_strength: u8, font_features: std.ArrayListUnmanaged([:0]const u8), font_styles: font.CodepointResolver.StyleStatus, cursor_color: ?terminal.color.RGB, @@ -410,6 +411,7 @@ pub const DerivedConfig = struct { return .{ .background_opacity = @max(0, @min(1, config.@"background-opacity")), .font_thicken = config.@"font-thicken", + .font_thicken_strength = config.@"font-thicken-strength", .font_features = font_features.list, .font_styles = font_styles, @@ -2837,6 +2839,7 @@ fn addGlyph( .{ .grid_metrics = self.grid_metrics, .thicken = self.config.font_thicken, + .thicken_strength = self.config.font_thicken_strength, }, ); diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 6521226a3..494a8fcc8 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -272,6 +272,7 @@ pub const DerivedConfig = struct { arena: ArenaAllocator, font_thicken: bool, + font_thicken_strength: u8, font_features: std.ArrayListUnmanaged([:0]const u8), font_styles: font.CodepointResolver.StyleStatus, cursor_color: ?terminal.color.RGB, @@ -321,6 +322,7 @@ pub const DerivedConfig = struct { return .{ .background_opacity = @max(0, @min(1, config.@"background-opacity")), .font_thicken = config.@"font-thicken", + .font_thicken_strength = config.@"font-thicken-strength", .font_features = font_features.list, .font_styles = font_styles, @@ -2093,6 +2095,7 @@ fn addGlyph( .{ .grid_metrics = self.grid_metrics, .thicken = self.config.font_thicken, + .thicken_strength = self.config.font_thicken_strength, }, ); From 72e0fb14fe1cf0453d585c1d58836e3a888107c7 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Fri, 3 Jan 2025 22:41:15 +0100 Subject: [PATCH 011/238] don't build freetype2 when system integration is enabled --- pkg/freetype/build.zig | 81 ++++++++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig index 46458c15c..6ff4f4340 100644 --- a/pkg/freetype/build.zig +++ b/pkg/freetype/build.zig @@ -5,7 +5,61 @@ pub fn build(b: *std.Build) !void { const optimize = b.standardOptimizeOption(.{}); const libpng_enabled = b.option(bool, "enable-libpng", "Build libpng") orelse false; - const module = b.addModule("freetype", .{ .root_source_file = b.path("main.zig") }); + const module = b.addModule("freetype", .{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + + // 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. + const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, + }; + + var test_exe: ?*std.Build.Step.Compile = null; + if (target.query.isNative()) { + test_exe = b.addTest(.{ + .name = "test", + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + const tests_run = b.addRunArtifact(test_exe.?); + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&tests_run.step); + } + + module.addIncludePath(b.path("")); + + if (b.systemIntegrationOption("freetype", .{})) { + module.linkSystemLibrary("freetype2", dynamic_link_opts); + if (test_exe) |exe| { + exe.linkSystemLibrary2("freetype2", dynamic_link_opts); + } + } else { + const lib = try buildLib(b, module, .{ + .target = target, + .optimize = optimize, + + .libpng_enabled = libpng_enabled, + + .dynamic_link_opts = dynamic_link_opts, + }); + + if (test_exe) |exe| { + exe.linkLibrary(lib); + } + } +} + +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const target = options.target; + const optimize = options.optimize; + + const libpng_enabled = options.libpng_enabled; const upstream = b.dependency("freetype", .{}); const lib = b.addStaticLibrary(.{ @@ -21,16 +75,6 @@ pub fn build(b: *std.Build) !void { } module.addIncludePath(upstream.path("include")); - module.addIncludePath(b.path("")); - - // 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. - const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ - .preferred_link_mode = .dynamic, - .search_strategy = .mode_first, - }; - var flags = std.ArrayList([]const u8).init(b.allocator); defer flags.deinit(); try flags.appendSlice(&.{ @@ -44,6 +88,8 @@ pub fn build(b: *std.Build) !void { "-fno-sanitize=undefined", }); + const dynamic_link_opts = options.dynamic_link_opts; + // Zlib if (b.systemIntegrationOption("zlib", .{})) { lib.linkSystemLibrary2("zlib", dynamic_link_opts); @@ -113,18 +159,7 @@ pub fn build(b: *std.Build) !void { b.installArtifact(lib); - if (target.query.isNative()) { - const test_exe = b.addTest(.{ - .name = "test", - .root_source_file = b.path("main.zig"), - .target = target, - .optimize = optimize, - }); - test_exe.linkLibrary(lib); - const tests_run = b.addRunArtifact(test_exe); - const test_step = b.step("test", "Run tests"); - test_step.dependOn(&tests_run.step); - } + return lib; } const srcs: []const []const u8 = &.{ From 1dc9157727146aaa148ce86e757bb3a40b3ff496 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Fri, 3 Jan 2025 22:42:01 +0100 Subject: [PATCH 012/238] always link system freetype2 using pkg-config --- pkg/fontconfig/build.zig | 2 +- pkg/harfbuzz/build.zig | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/fontconfig/build.zig b/pkg/fontconfig/build.zig index fb4dbfb36..a7a1ba9ef 100644 --- a/pkg/fontconfig/build.zig +++ b/pkg/fontconfig/build.zig @@ -186,7 +186,7 @@ pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*st _ = b.systemIntegrationOption("freetype", .{}); // So it shows up in help if (freetype_enabled) { if (b.systemIntegrationOption("freetype", .{})) { - lib.linkSystemLibrary2("freetype", dynamic_link_opts); + lib.linkSystemLibrary2("freetype2", dynamic_link_opts); } else { const freetype_dep = b.dependency( "freetype", diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index 983ec9ffc..a55dc7300 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -43,7 +43,11 @@ pub fn build(b: *std.Build) !void { { var it = module.import_table.iterator(); while (it.next()) |entry| test_exe.root_module.addImport(entry.key_ptr.*, entry.value_ptr.*); - test_exe.linkLibrary(freetype.artifact("freetype")); + if (b.systemIntegrationOption("freetype", .{})) { + test_exe.linkSystemLibrary2("freetype2", dynamic_link_opts); + } else { + test_exe.linkLibrary(freetype.artifact("freetype")); + } const tests_run = b.addRunArtifact(test_exe); const test_step = b.step("test", "Run tests"); test_step.dependOn(&tests_run.step); From 0493b79cafb3c90ba99285ba86480276a7839635 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Fri, 3 Jan 2025 22:42:29 +0100 Subject: [PATCH 013/238] don't make library building logic public --- pkg/fontconfig/build.zig | 2 +- pkg/harfbuzz/build.zig | 2 +- pkg/oniguruma/build.zig | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/fontconfig/build.zig b/pkg/fontconfig/build.zig index a7a1ba9ef..96c4ffe4a 100644 --- a/pkg/fontconfig/build.zig +++ b/pkg/fontconfig/build.zig @@ -56,7 +56,7 @@ pub fn build(b: *std.Build) !void { } } -pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { const target = options.target; const optimize = options.optimize; diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index a55dc7300..5b7d86408 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -71,7 +71,7 @@ pub fn build(b: *std.Build) !void { } } -pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { const target = options.target; const optimize = options.optimize; diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig index 886bfc5bd..da7c90674 100644 --- a/pkg/oniguruma/build.zig +++ b/pkg/oniguruma/build.zig @@ -53,7 +53,7 @@ pub fn build(b: *std.Build) !void { } } -pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { +fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { const target = options.target; const optimize = options.optimize; From 69e4428d802272417fc0e40a7d0d04edc5bb43c3 Mon Sep 17 00:00:00 2001 From: dkmar Date: Fri, 3 Jan 2025 23:34:39 -0800 Subject: [PATCH 014/238] fix typo: CSI header --- src/input/Binding.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 33ed80c8b..5a8a3b562 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -230,7 +230,7 @@ pub const Action = union(enum) { unbind: void, /// Send a CSI sequence. The value should be the CSI sequence without the - /// CSI header (`ESC ]` or `\x1b]`). + /// CSI header (`ESC [` or `\x1b[`). csi: []const u8, /// Send an `ESC` sequence. From 0599f73fac18217a7118437fd72e73a68c48098c Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Sat, 4 Jan 2025 09:02:25 +0100 Subject: [PATCH 015/238] chore: use KiB notation for representing memory size --- src/inspector/Inspector.zig | 8 ++++---- src/inspector/page.zig | 2 +- src/inspector/utils.zig | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index bcdef1b47..73d063125 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -441,7 +441,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_bytes, utils.toKiloBytes(kitty_images.total_bytes)); + cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_bytes, utils.toKibiBytes(kitty_images.total_bytes)); } } @@ -453,7 +453,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KB)", kitty_images.total_limit, utils.toKiloBytes(kitty_images.total_limit)); + cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_limit, utils.toKibiBytes(kitty_images.total_limit)); } } @@ -519,7 +519,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KB)", pages.page_size, utils.toKiloBytes(pages.page_size)); + cimgui.c.igText("%d bytes (%d KiB)", pages.page_size, utils.toKibiBytes(pages.page_size)); } } @@ -531,7 +531,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KB)", pages.maxSize(), utils.toKiloBytes(pages.maxSize())); + cimgui.c.igText("%d bytes (%d KiB)", pages.maxSize(), utils.toKibiBytes(pages.maxSize())); } } diff --git a/src/inspector/page.zig b/src/inspector/page.zig index 2852b719e..c3ca93c5f 100644 --- a/src/inspector/page.zig +++ b/src/inspector/page.zig @@ -27,7 +27,7 @@ pub fn render(page: *const terminal.Page) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d Kb)", page.memory.len, utils.toKiloBytes(page.memory.len)); + cimgui.c.igText("%d bytes (%d KiB)", page.memory.len, utils.toKibiBytes(page.memory.len)); cimgui.c.igText("%d VM pages", page.memory.len / std.mem.page_size); } } diff --git a/src/inspector/utils.zig b/src/inspector/utils.zig index 87c617a23..28f928232 100644 --- a/src/inspector/utils.zig +++ b/src/inspector/utils.zig @@ -1,3 +1,3 @@ -pub fn toKiloBytes(bytes: usize) usize { +pub fn toKibiBytes(bytes: usize) usize { return bytes / 1024; } From e8811ac6fb0063887adcaa58892a76e77a5c180c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jan 2025 07:10:07 -0800 Subject: [PATCH 016/238] Move app quit to apprt action This changes quit signaling from a boolean return from core app `tick()` to an apprt action. This simplifies the API and conceptually makes more sense to me now. This wasn't done just for that; this change was also needed so that macOS can quit cleanly while fixing #4540 since we may no longer trigger menu items. I wanted to split this out into a separate commit/PR because it adds complexity making the diff harder to read. --- include/ghostty.h | 3 ++- macos/Sources/Ghostty/Ghostty.App.swift | 36 +++++++++++++------------ src/App.zig | 25 +++-------------- src/apprt/action.zig | 4 +++ src/apprt/embedded.zig | 5 ++-- src/apprt/glfw.zig | 12 +++++++-- src/apprt/gtk/App.zig | 14 +++++----- 7 files changed, 46 insertions(+), 53 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 4b8d409e9..cbb77f00c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -559,6 +559,7 @@ typedef struct { // apprt.Action.Key typedef enum { + GHOSTTY_ACTION_QUIT, GHOSTTY_ACTION_NEW_WINDOW, GHOSTTY_ACTION_NEW_TAB, GHOSTTY_ACTION_NEW_SPLIT, @@ -681,7 +682,7 @@ void ghostty_config_open(); ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, ghostty_config_t); void ghostty_app_free(ghostty_app_t); -bool ghostty_app_tick(ghostty_app_t); +void ghostty_app_tick(ghostty_app_t); void* ghostty_app_userdata(ghostty_app_t); void ghostty_app_set_focus(ghostty_app_t, bool); bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2d9822d6e..ed140dcd5 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -117,23 +117,7 @@ extension Ghostty { func appTick() { guard let app = self.app else { return } - - // Tick our app, which lets us know if we want to quit - let exit = ghostty_app_tick(app) - if (!exit) { return } - - // On iOS, applications do not terminate programmatically like they do - // on macOS. On iOS, applications are only terminated when a user physically - // closes the application (i.e. going to the home screen). If we request - // exit on iOS we ignore it. - #if os(iOS) - logger.info("quit request received, ignoring on iOS") - #endif - - #if os(macOS) - // We want to quit, start that process - NSApplication.shared.terminate(nil) - #endif + ghostty_app_tick(app) } func openConfig() { @@ -454,6 +438,9 @@ extension Ghostty { // Action dispatch switch (action.tag) { + case GHOSTTY_ACTION_QUIT: + quit(app) + case GHOSTTY_ACTION_NEW_WINDOW: newWindow(app, target: target) @@ -559,6 +546,21 @@ extension Ghostty { } } + private static func quit(_ app: ghostty_app_t) { + // On iOS, applications do not terminate programmatically like they do + // on macOS. On iOS, applications are only terminated when a user physically + // closes the application (i.e. going to the home screen). If we request + // exit on iOS we ignore it. + #if os(iOS) + logger.info("quit request received, ignoring on iOS") + #endif + + #if os(macOS) + // We want to quit, start that process + NSApplication.shared.terminate(nil) + #endif + } + private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) { switch (target.tag) { case GHOSTTY_TARGET_APP: diff --git a/src/App.zig b/src/App.zig index 279c4e497..b0de85c95 100644 --- a/src/App.zig +++ b/src/App.zig @@ -54,9 +54,6 @@ focused_surface: ?*Surface = null, /// this is a blocking queue so if it is full you will get errors (or block). mailbox: Mailbox.Queue, -/// Set to true once we're quitting. This never goes false again. -quit: bool, - /// The set of font GroupCache instances shared by surfaces with the /// same font configuration. font_grid_set: font.SharedGridSet, @@ -98,7 +95,6 @@ pub fn create( .alloc = alloc, .surfaces = .{}, .mailbox = .{}, - .quit = false, .font_grid_set = font_grid_set, .config_conditional_state = .{}, }; @@ -125,9 +121,7 @@ pub fn destroy(self: *App) void { /// Tick ticks the app loop. This will drain our mailbox and process those /// events. This should be called by the application runtime on every loop /// tick. -/// -/// This returns whether the app should quit or not. -pub fn tick(self: *App, rt_app: *apprt.App) !bool { +pub fn tick(self: *App, rt_app: *apprt.App) !void { // If any surfaces are closing, destroy them var i: usize = 0; while (i < self.surfaces.items.len) { @@ -142,13 +136,6 @@ pub fn tick(self: *App, rt_app: *apprt.App) !bool { // Drain our mailbox try self.drainMailbox(rt_app); - - // No matter what, we reset the quit flag after a tick. If the apprt - // doesn't want to quit, then we can't force it to. - defer self.quit = false; - - // We quit if our quit flag is on - return self.quit; } /// Update the configuration associated with the app. This can only be @@ -272,7 +259,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { // can try to quit as quickly as possible. .quit => { log.info("quit message received, short circuiting mailbox drain", .{}); - self.setQuit(); + try self.performAction(rt_app, .quit); return; }, } @@ -314,12 +301,6 @@ pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void { ); } -/// Start quitting -pub fn setQuit(self: *App) void { - if (self.quit) return; - self.quit = true; -} - /// Handle an app-level focus event. This should be called whenever /// the focus state of the entire app containing Ghostty changes. /// This is separate from surface focus events. See the `focused` @@ -437,7 +418,7 @@ pub fn performAction( switch (action) { .unbind => unreachable, .ignore => {}, - .quit => self.setQuit(), + .quit => try rt_app.performAction(.app, .quit, {}), .new_window => try self.newWindow(rt_app, .{ .parent = null }), .open_config => try rt_app.performAction(.app, .open_config, {}), .reload_config => try rt_app.performAction(.app, .reload_config, .{}), diff --git a/src/apprt/action.zig b/src/apprt/action.zig index de6758d6c..df30f7b7b 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -70,6 +70,9 @@ pub const Action = union(Key) { // entry. If the value type is void then only the key needs to be // added. Ensure the order matches exactly with the Zig code. + /// Quit the application. + quit, + /// Open a new window. The target determines whether properties such /// as font size should be inherited. new_window, @@ -219,6 +222,7 @@ pub const Action = union(Key) { /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { + quit, new_window, new_tab, new_split, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index b42225906..59f81e694 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1332,10 +1332,9 @@ pub const CAPI = struct { /// Tick the event loop. This should be called whenever the "wakeup" /// callback is invoked for the runtime. - export fn ghostty_app_tick(v: *App) bool { - return v.core_app.tick(v) catch |err| err: { + export fn ghostty_app_tick(v: *App) void { + v.core_app.tick(v) catch |err| { log.err("error app tick err={}", .{err}); - break :err false; }; } diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 3fbef0f22..c91464068 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -35,6 +35,10 @@ pub const App = struct { app: *CoreApp, config: Config, + /// Flips to true to quit on the next event loop tick. This + /// never goes false and forces the event loop to exit. + quit: bool = false, + /// Mac-specific state. darwin: if (Darwin.enabled) Darwin else void, @@ -124,8 +128,10 @@ pub const App = struct { glfw.waitEvents(); // Tick the terminal app - const should_quit = try self.app.tick(self); - if (should_quit or self.app.surfaces.items.len == 0) { + try self.app.tick(self); + + // If the tick caused us to quit, then we're done. + if (self.quit or self.app.surfaces.items.len == 0) { for (self.app.surfaces.items) |surface| { surface.close(false); } @@ -149,6 +155,8 @@ pub const App = struct { value: apprt.Action.Value(action), ) !void { switch (action) { + .quit => self.quit = true, + .new_window => _ = try self.newSurface(switch (target) { .app => null, .surface => |v| v, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 12bac989a..9fe5a8c4d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -460,6 +460,7 @@ pub fn performAction( value: apprt.Action.Value(action), ) !void { switch (action) { + .quit => self.quit(), .new_window => _ = try self.newWindow(switch (target) { .app => null, .surface => |v| v, @@ -1075,9 +1076,7 @@ fn loadCustomCss(self: *App) !void { 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 + const contents = try file.reader().readAllAlloc(self.core_app.alloc, 5 * 1024 * 1024 // 5MB ); defer self.core_app.alloc.free(contents); @@ -1174,14 +1173,10 @@ pub fn run(self: *App) !void { _ = c.g_main_context_iteration(self.ctx, 1); // Tick the terminal app and see if we should quit. - const should_quit = try self.core_app.tick(self); + try self.core_app.tick(self); // Check if we must quit based on the current state. const must_quit = q: { - // If we've been told by GTK that we should quit, do so regardless - // of any other setting. - if (should_quit) break :q true; - // If we are configured to always stay running, don't quit. if (!self.config.@"quit-after-last-window-closed") break :q false; @@ -1285,6 +1280,9 @@ fn newWindow(self: *App, parent_: ?*CoreSurface) !void { } fn quit(self: *App) void { + // If we're already not running, do nothing. + if (!self.running) return; + // If we have no toplevel windows, then we're done. const list = c.gtk_window_list_toplevels(); if (list == null) { From d3334ecb06ac5afc5bb4a28f90c83cbced7c80d0 Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Sat, 4 Jan 2025 15:11:36 +0100 Subject: [PATCH 017/238] [3/12] parse ConEmu OSC9;3 --- src/inspector/termio.zig | 14 +++++++ src/terminal/osc.zig | 81 ++++++++++++++++++++++++++++++++++++++++ src/terminal/stream.zig | 2 +- 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 78b35e19b..04bca0273 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -208,6 +208,20 @@ pub const VTEvent = struct { ); }, + .Union => |info| { + const Tag = info.tag_type orelse @compileError("Unions must have a tag"); + const tag_name = @tagName(@as(Tag, v)); + inline for (info.fields) |field| { + if (std.mem.eql(u8, field.name, tag_name)) { + if (field.type == void) { + break try md.put("data", tag_name); + } else { + break try encodeMetadataSingle(alloc, md, tag_name, @field(v, field.name)); + } + } + } + }, + else => { @compileLog(T); @compileError("unsupported type, see log"); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index a66e9d8d3..dc56e10fd 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -161,6 +161,12 @@ pub const Command = union(enum) { /// Show GUI message Box (OSC 9;2) show_message_box: []const u8, + /// Change ConEmu tab (OSC 9;3) + change_conemu_tab_title: union(enum) { + reset: void, + value: []const u8, + }, + /// Set progress state (OSC 9;4) progress: struct { state: ProgressState, @@ -357,6 +363,8 @@ pub const Parser = struct { // ConEmu specific substates conemu_message_box, + conemu_tab, + conemu_tab_txt, conemu_progress_prestate, conemu_progress_state, conemu_progress_prevalue, @@ -784,6 +792,9 @@ pub const Parser = struct { '2' => { self.state = .conemu_message_box; }, + '3' => { + self.state = .conemu_tab; + }, '4' => { self.state = .conemu_progress_prestate; }, @@ -806,6 +817,23 @@ pub const Parser = struct { else => self.state = .invalid, }, + .conemu_tab => switch (c) { + ';' => { + self.state = .conemu_tab_txt; + self.command = .{ .change_conemu_tab_title = .{ .reset = {} } }; + self.buf_start = self.buf_idx; + self.complete = true; + }, + else => self.state = .invalid, + }, + + .conemu_tab_txt => { + self.command = .{ .change_conemu_tab_title = .{ .value = undefined } }; + self.temp_state = .{ .str = &self.command.change_conemu_tab_title.value }; + self.complete = true; + self.prepAllocableString(); + }, + .conemu_progress_prestate => switch (c) { ';' => { self.command = .{ .progress = .{ @@ -1731,6 +1759,59 @@ test "OSC: conemu message box spaces only message" { try testing.expectEqualStrings(" ", cmd.show_message_box); } +test "OSC: conemu change tab title" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;3;foo bar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .change_conemu_tab_title); + try testing.expectEqualStrings("foo bar", cmd.change_conemu_tab_title.value); +} + +test "OSC: conemu change tab reset title" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;3;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + const expected_command: Command = .{ .change_conemu_tab_title = .{ .reset = {} } }; + try testing.expectEqual(expected_command, cmd); +} + +test "OSC: conemu change tab spaces only title" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;3; "; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + + try testing.expect(cmd == .change_conemu_tab_title); + try testing.expectEqualStrings(" ", cmd.change_conemu_tab_title.value); +} + +test "OSC: conemu change tab invalid input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;3"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} + test "OSC: OSC9 progress set" { const testing = std.testing; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 5e0752fc9..b83f2dc1e 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1605,7 +1605,7 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .progress, .show_message_box => { + .progress, .show_message_box, .change_conemu_tab_title => { log.warn("unimplemented OSC callback: {}", .{cmd}); }, } From 6b307367762549a379f56f8cc0fb2373ee98a399 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jan 2025 07:10:07 -0800 Subject: [PATCH 018/238] Move app quit to apprt action This changes quit signaling from a boolean return from core app `tick()` to an apprt action. This simplifies the API and conceptually makes more sense to me now. This wasn't done just for that; this change was also needed so that macOS can quit cleanly while fixing #4540 since we may no longer trigger menu items. I wanted to split this out into a separate commit/PR because it adds complexity making the diff harder to read. --- src/apprt/gtk/App.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 9fe5a8c4d..4e1e28ee6 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1623,7 +1623,9 @@ fn gtkActionQuit( ud: ?*anyopaque, ) callconv(.C) void { const self: *App = @ptrCast(@alignCast(ud orelse return)); - self.core_app.setQuit(); + 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 From 2dc518d8b09761478931c6756289782d7ce96ca6 Mon Sep 17 00:00:00 2001 From: Kiril Angov Date: Sat, 4 Jan 2025 12:57:38 -0500 Subject: [PATCH 019/238] Improve the documentation for move_tab keybind action --- src/input/Binding.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 33ed80c8b..f4d117f91 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -329,7 +329,7 @@ pub const Action = union(enum) { goto_tab: usize, /// Moves a tab by a relative offset. - /// Adjusts the tab position based on `offset` (e.g., -1 for left, +1 for right). + /// Adjusts the tab position based on `offset`. For example `move_tab:-1` for left, `move_tab:1` for right. /// If the new position is out of bounds, it wraps around cyclically within the tab range. move_tab: isize, @@ -341,7 +341,8 @@ pub const Action = union(enum) { /// the direction given. For example `new_split:up`. Valid values are left, right, up, down and auto. new_split: SplitDirection, - /// Focus on a split in a given direction. For example `goto_split:up`. Valid values are left, right, up, down, previous and next. + /// Focus on a split in a given direction. For example `goto_split:up`. + /// Valid values are left, right, up, down, previous and next. goto_split: SplitFocusDirection, /// zoom/unzoom the current split. From 4d103ca16d657ef95748fe4a141e9934217a0559 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Jan 2025 13:40:47 -0800 Subject: [PATCH 020/238] core: add keyEventIsBinding This API can be used to determine if the next key event, if given as-is, would result in a key binding being triggered. --- include/ghostty.h | 1 + src/Surface.zig | 27 ++++++++++++++++++- src/apprt/embedded.zig | 59 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index cbb77f00c..b88fd9888 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -714,6 +714,7 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); void ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); +bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); bool ghostty_surface_mouse_captured(ghostty_surface_t); bool ghostty_surface_mouse_button(ghostty_surface_t, diff --git a/src/Surface.zig b/src/Surface.zig index 389e7f7e4..214bdae7e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1637,13 +1637,38 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { try self.queueRender(); } +/// Returns true if the given key event would trigger a keybinding +/// if it were to be processed. This is useful for determining if +/// a key event should be sent to the terminal or not. +/// +/// Note that this function does not check if the binding itself +/// is performable, only if the key event would trigger a binding. +/// If a performable binding is found and the event is not performable, +/// then Ghosty will act as though the binding does not exist. +pub fn keyEventIsBinding( + self: *Surface, + event: input.KeyEvent, +) bool { + switch (event.action) { + .release => return false, + .press, .repeat => {}, + } + + // Our keybinding set is either our current nested set (for + // sequences) or the root set. + const set = self.keyboard.bindings orelse &self.config.keybind.set; + + // If we have a keybinding for this event then we return true. + return set.getEvent(event) != null; +} + /// Called for any key events. This handles keybindings, encoding and /// sending to the terminal, etc. pub fn keyCallback( self: *Surface, event: input.KeyEvent, ) !InputEffect { - // log.debug("text keyCallback event={}", .{event}); + log.debug("text keyCallback event={}", .{event}); // Crash metadata in case we crash in here crash.sentry.thread_state = self.crashThreadState(); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 59f81e694..3de9e4281 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -147,12 +147,12 @@ pub const App = struct { self.core_app.focusEvent(focused); } - /// See CoreApp.keyEvent. - pub fn keyEvent( + /// Convert a C key event into a Zig key event. + fn coreKeyEvent( self: *App, target: KeyTarget, event: KeyEvent, - ) !bool { + ) !?input.KeyEvent { const action = event.action; const keycode = event.keycode; const mods = event.mods; @@ -243,7 +243,7 @@ pub const App = struct { result.text, ) catch |err| { log.err("error in preedit callback err={}", .{err}); - return false; + return null; }, } } else { @@ -251,7 +251,7 @@ pub const App = struct { .app => {}, .surface => |surface| surface.core_surface.preeditCallback(null) catch |err| { log.err("error in preedit callback err={}", .{err}); - return false; + return null; }, } @@ -335,7 +335,7 @@ pub const App = struct { } else .invalid; // Build our final key event - const input_event: input.KeyEvent = .{ + return .{ .action = action, .key = key, .physical_key = physical_key, @@ -345,24 +345,39 @@ pub const App = struct { .utf8 = result.text, .unshifted_codepoint = unshifted_codepoint, }; + } + + /// See CoreApp.keyEvent. + pub fn keyEvent( + self: *App, + target: KeyTarget, + event: KeyEvent, + ) !bool { + // Convert our C key event into a Zig one. + const input_event: input.KeyEvent = (try self.coreKeyEvent( + target, + event, + )) orelse return false; // Invoke the core Ghostty logic to handle this input. const effect: CoreSurface.InputEffect = switch (target) { .app => if (self.core_app.keyEvent( self, input_event, - )) - .consumed - else - .ignored, + )) .consumed else .ignored, - .surface => |surface| try surface.core_surface.keyCallback(input_event), + .surface => |surface| try surface.core_surface.keyCallback( + input_event, + ), }; return switch (effect) { .closed => true, .ignored => false, .consumed => consumed: { + const is_down = input_event.action == .press or + input_event.action == .repeat; + if (is_down) { // If we consume the key then we want to reset the dead // key state. @@ -1601,6 +1616,28 @@ pub const CAPI = struct { }; } + /// Returns true if the given key event would trigger a binding + /// if it were sent to the surface right now. The "right now" + /// is important because things like trigger sequences are only + /// valid until the next key event. + export fn ghostty_surface_key_is_binding( + surface: *Surface, + event: KeyEvent, + ) bool { + const core_event = surface.app.coreKeyEvent( + .{ .surface = surface }, + event.keyEvent(), + ) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + } orelse { + log.warn("error processing key event", .{}); + return false; + }; + + return surface.core_surface.keyEventIsBinding(core_event); + } + /// Send raw text to the terminal. This is treated like a paste /// so this isn't useful for sending escape sequences. For that, /// individual key input should be used. From 8b8c53fc4cbc21118ee35f16eb8cc71c22932fc3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Jan 2025 14:36:49 -0800 Subject: [PATCH 021/238] macos: add NSEvent extension to convert to libghostty key events --- macos/Ghostty.xcodeproj/project.pbxproj | 12 +++++++---- macos/Sources/App/macOS/AppDelegate.swift | 8 +------- macos/Sources/Ghostty/NSEvent+Extension.swift | 15 ++++++++++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 20 +++++-------------- 4 files changed, 29 insertions(+), 26 deletions(-) create mode 100644 macos/Sources/Ghostty/NSEvent+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 42479f0b3..fd4f10f24 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -10,8 +10,8 @@ 29C15B1D2CDC3B2900520DD4 /* bat in Resources */ = {isa = PBXBuildFile; fileRef = 29C15B1C2CDC3B2000520DD4 /* bat */; }; 55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; }; 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; - 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; + 9351BE8E3D22937F003B3499 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* vim */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; @@ -87,6 +87,7 @@ A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; + A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */; }; A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; }; A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; }; A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; }; @@ -108,8 +109,8 @@ 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; }; 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = ""; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; - 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/nvim"; sourceTree = ""; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; + 9351BE8E2D22937F003B3499 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; @@ -177,6 +178,7 @@ A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; + A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSEvent+Extension.swift"; sourceTree = ""; }; A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = ""; }; A5D0AF3C2B37804400D21823 /* CodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableBridge.swift; sourceTree = ""; }; A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -357,6 +359,7 @@ A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, + A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */, ); path = Ghostty; sourceTree = ""; @@ -405,7 +408,7 @@ A5985CE52C33060F00C57AD3 /* man */, A5A1F8842A489D6800D1E8BC /* terminfo */, FC5218F92D10FFC7004C93E0 /* zsh */, - 9351BE8E2D22937F003B3499 /* nvim */, + 9351BE8E2D22937F003B3499 /* vim */, ); name = Resources; sourceTree = ""; @@ -582,7 +585,7 @@ A5985CE62C33060F00C57AD3 /* man in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, 552964E62B34A9B400030505 /* vim in Resources */, - 9351BE8E3D22937F003B3499 /* nvim in Resources */, + 9351BE8E3D22937F003B3499 /* vim in Resources */, A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -611,6 +614,7 @@ A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, + A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8564bbb1e..513a6872e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -438,13 +438,7 @@ class AppDelegate: NSObject, guard let ghostty = self.ghostty.app else { return event } // Build our event input and call ghostty - var key_ev = ghostty_input_key_s() - key_ev.action = GHOSTTY_ACTION_PRESS - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = nil - key_ev.composing = false - if (ghostty_app_key(ghostty, key_ev)) { + if (ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) { // The key was used so we want to stop it from going to our Mac app Ghostty.logger.debug("local key event handled event=\(event)") return nil diff --git a/macos/Sources/Ghostty/NSEvent+Extension.swift b/macos/Sources/Ghostty/NSEvent+Extension.swift new file mode 100644 index 000000000..4118cd94d --- /dev/null +++ b/macos/Sources/Ghostty/NSEvent+Extension.swift @@ -0,0 +1,15 @@ +import Cocoa +import GhosttyKit + +extension NSEvent { + /// Create a Ghostty key event for a given keyboard action. + func ghosttyKeyEvent(_ action: ghostty_input_action_e) -> ghostty_input_key_s { + var key_ev = ghostty_input_key_s() + key_ev.action = action + key_ev.mods = Ghostty.ghosttyMods(modifierFlags) + key_ev.keycode = UInt32(keyCode) + key_ev.text = nil + key_ev.composing = false + return key_ev + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2cac4a0dd..8e68161b1 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -810,6 +810,8 @@ extension Ghostty { return false } + // If this event as-is would result in a key event then + // Only process keys when Control is active. All known issues we're // resolving happen only in this scenario. This probably isn't fully robust // but we can broaden the scope as we find more cases. @@ -903,23 +905,14 @@ extension Ghostty { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { guard let surface = self.surface else { return } - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = nil - key_ev.composing = false - ghostty_surface_key(surface, key_ev) + ghostty_surface_key(surface, event.ghosttyKeyEvent(action)) } private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) { guard let surface = self.surface else { return } preedit.withCString { ptr in - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) + var key_ev = event.ghosttyKeyEvent(action) key_ev.text = ptr key_ev.composing = true ghostty_surface_key(surface, key_ev) @@ -930,10 +923,7 @@ extension Ghostty { guard let surface = self.surface else { return } text.withCString { ptr in - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) + var key_ev = event.ghosttyKeyEvent(action) key_ev.text = ptr ghostty_surface_key(surface, key_ev) } From 4031815a8db163950b97de18026003c10b41eac7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Jan 2025 14:39:22 -0800 Subject: [PATCH 022/238] macos: if a key event would result in an immediate binding then do it --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8e68161b1..634224c8e 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -810,7 +810,14 @@ extension Ghostty { return false } - // If this event as-is would result in a key event then + // If this event as-is would result in a key binding then we send it. + if let surface, + ghostty_surface_key_is_binding( + surface, + event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { + self.keyDown(with: event) + return true + } // Only process keys when Control is active. All known issues we're // resolving happen only in this scenario. This probably isn't fully robust From 3e89c4c2f4203009fb03bcbb04d3c55ae98be0ee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jan 2025 12:45:03 -0800 Subject: [PATCH 023/238] Key events return boolean if handled --- include/ghostty.h | 2 +- macos/Ghostty.xcodeproj/project.pbxproj | 4 + macos/Sources/Ghostty/Ghostty.Event.swift | 15 ++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 74 ++++++++++++------- src/apprt/embedded.zig | 6 +- 5 files changed, 69 insertions(+), 32 deletions(-) create mode 100644 macos/Sources/Ghostty/Ghostty.Event.swift diff --git a/include/ghostty.h b/include/ghostty.h index b88fd9888..8af181051 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -713,7 +713,7 @@ void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_color_scheme_e); ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); -void ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); +bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); bool ghostty_surface_mouse_captured(ghostty_surface_t); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index fd4f10f24..adb27338b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */; }; + A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */; }; A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */; }; A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D0AF3C2B37804400D21823 /* CodableBridge.swift */; }; A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; }; @@ -179,6 +180,7 @@ A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSEvent+Extension.swift"; sourceTree = ""; }; + A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Event.swift; sourceTree = ""; }; A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRestorable.swift; sourceTree = ""; }; A5D0AF3C2B37804400D21823 /* CodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableBridge.swift; sourceTree = ""; }; A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -353,6 +355,7 @@ A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */, + A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, @@ -621,6 +624,7 @@ A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, + A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, diff --git a/macos/Sources/Ghostty/Ghostty.Event.swift b/macos/Sources/Ghostty/Ghostty.Event.swift new file mode 100644 index 000000000..1cde50ee7 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Event.swift @@ -0,0 +1,15 @@ +import Cocoa +import GhosttyKit + +extension Ghostty { + /// A comparable event. + struct ComparableKeyEvent: Equatable { + let keyCode: UInt16 + let flags: NSEvent.ModifierFlags + + init(event: NSEvent) { + self.keyCode = event.keyCode + self.flags = event.modifierFlags + } + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 634224c8e..d5dcd12ce 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -773,7 +773,7 @@ extension Ghostty { if let list = keyTextAccumulator, list.count > 0 { handled = true for text in list { - keyAction(action, event: event, text: text) + _ = keyAction(action, event: event, text: text) } } @@ -783,29 +783,38 @@ extension Ghostty { // the preedit. if (markedText.length > 0 || markedTextBefore) { handled = true - keyAction(action, event: event, preedit: markedText.string) + _ = keyAction(action, event: event, preedit: markedText.string) } if (!handled) { // No text or anything, we want to handle this manually. - keyAction(action, event: event) + _ = keyAction(action, event: event) } } override func keyUp(with event: NSEvent) { - keyAction(GHOSTTY_ACTION_RELEASE, event: event) + _ = keyAction(GHOSTTY_ACTION_RELEASE, event: event) } /// Special case handling for some control keys override func performKeyEquivalent(with event: NSEvent) -> Bool { - // Only process key down events - if (event.type != .keyDown) { + switch (event.type) { + case .keyDown: + // Continue, we care about key down events + break + + default: + // Any other key event we don't care about. I don't think its even + // possible to receive any other event type. return false } // Only process events if we're focused. Some key events like C-/ macOS // appears to send to the first view in the hierarchy rather than the // the first responder (I don't know why). This prevents us from handling it. + // Besides C-/, its important we don't process key equivalents if unfocused + // because there are other event listeners for that (i.e. AppDelegate's + // local event handler). if (!focused) { return false } @@ -819,13 +828,6 @@ extension Ghostty { return true } - // Only process keys when Control is active. All known issues we're - // resolving happen only in this scenario. This probably isn't fully robust - // but we can broaden the scope as we find more cases. - if (!event.modifierFlags.contains(.control)) { - return false - } - let equivalent: String switch (event.charactersIgnoringModifiers) { case "/": @@ -841,14 +843,25 @@ extension Ghostty { case "\r": // Pass C- through verbatim // (prevent the default context menu equivalent) + if (!event.modifierFlags.contains(.control)) { + return false + } + equivalent = "\r" + case ".": + if (!event.modifierFlags.contains(.command)) { + return false + } + + equivalent = "." + default: // Ignore other events return false } - let newEvent = NSEvent.keyEvent( + let finalEvent = NSEvent.keyEvent( with: .keyDown, location: event.locationInWindow, modifierFlags: event.modifierFlags, @@ -861,7 +874,7 @@ extension Ghostty { keyCode: event.keyCode ) - self.keyDown(with: newEvent!) + self.keyDown(with: finalEvent!) return true } @@ -906,33 +919,38 @@ extension Ghostty { } } - keyAction(action, event: event) + _ = keyAction(action, event: event) } - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { - guard let surface = self.surface else { return } - - ghostty_surface_key(surface, event.ghosttyKeyEvent(action)) + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) -> Bool { + guard let surface = self.surface else { return false } + return ghostty_surface_key(surface, event.ghosttyKeyEvent(action)) } - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) { - guard let surface = self.surface else { return } + private func keyAction( + _ action: ghostty_input_action_e, + event: NSEvent, preedit: String + ) -> Bool { + guard let surface = self.surface else { return false } - preedit.withCString { ptr in + return preedit.withCString { ptr in var key_ev = event.ghosttyKeyEvent(action) key_ev.text = ptr key_ev.composing = true - ghostty_surface_key(surface, key_ev) + return ghostty_surface_key(surface, key_ev) } } - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) { - guard let surface = self.surface else { return } + private func keyAction( + _ action: ghostty_input_action_e, + event: NSEvent, text: String + ) -> Bool { + guard let surface = self.surface else { return false } - text.withCString { ptr in + return text.withCString { ptr in var key_ev = event.ghosttyKeyEvent(action) key_ev.text = ptr - ghostty_surface_key(surface, key_ev) + return ghostty_surface_key(surface, key_ev) } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 3de9e4281..758a3ff87 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1606,13 +1606,13 @@ pub const CAPI = struct { export fn ghostty_surface_key( surface: *Surface, event: KeyEvent, - ) void { - _ = surface.app.keyEvent( + ) bool { + return surface.app.keyEvent( .{ .surface = surface }, event.keyEvent(), ) catch |err| { log.warn("error processing key event err={}", .{err}); - return; + return false; }; } From 1bcfff3b794f28ca3b6134e64e0ca6a39f8f8be1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jan 2025 13:43:36 -0800 Subject: [PATCH 024/238] macos: manual send keyUp event for command key --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 41 +++++++++++++++++++ src/Surface.zig | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d5dcd12ce..297ca8ea0 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -113,6 +113,9 @@ extension Ghostty { // A small delay that is introduced before a title change to avoid flickers private var titleChangeTimer: Timer? + /// Event monitor (see individual events for why) + private var eventMonitor: Any? = nil + // We need to support being a first responder so that we can get input events override var acceptsFirstResponder: Bool { return true } @@ -170,6 +173,15 @@ extension Ghostty { name: NSWindow.didChangeScreenNotification, object: nil) + // Listen for local events that we need to know of outside of + // single surface handlers. + self.eventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [ + // We need keyUp because command+key events don't trigger keyUp. + .keyUp + ] + ) { [weak self] event in self?.localEventHandler(event) } + // Setup our surface. This will also initialize all the terminal IO. let surface_cfg = baseConfig ?? SurfaceConfiguration() var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) @@ -212,6 +224,11 @@ extension Ghostty { let center = NotificationCenter.default center.removeObserver(self) + // Remove our event monitor + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } + // Whenever the surface is removed, we need to note that our restorable // state is invalid to prevent the surface from being restored. invalidateRestorableState() @@ -356,6 +373,30 @@ extension Ghostty { } } + // MARK: Local Events + + private func localEventHandler(_ event: NSEvent) -> NSEvent? { + return switch event.type { + case .keyUp: + localEventKeyUp(event) + + default: + event + } + } + + private func localEventKeyUp(_ event: NSEvent) -> NSEvent? { + // We only care about events with "command" because all others will + // trigger the normal responder chain. + if (!event.modifierFlags.contains(.command)) { return event } + + // Command keyUp events are never sent to the normal responder chain + // so we send them here. + guard focused else { return event } + self.keyUp(with: event) + return nil + } + // MARK: - Notifications @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { diff --git a/src/Surface.zig b/src/Surface.zig index 214bdae7e..1dc10fb27 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1668,7 +1668,7 @@ pub fn keyCallback( self: *Surface, event: input.KeyEvent, ) !InputEffect { - log.debug("text keyCallback event={}", .{event}); + // log.debug("text keyCallback event={}", .{event}); // Crash metadata in case we crash in here crash.sentry.thread_state = self.crashThreadState(); From 40bdea73357ded7a3a753ee8f26d65a07434f087 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jan 2025 14:07:33 -0800 Subject: [PATCH 025/238] macos: handle overridden system bindings with no focused window --- include/ghostty.h | 1 + macos/Sources/App/macOS/AppDelegate.swift | 9 +++++++++ src/App.zig | 19 +++++++++++++++++++ src/apprt/embedded.zig | 22 ++++++++++++++++++++++ 4 files changed, 51 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 8af181051..0e444a2fa 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -686,6 +686,7 @@ void ghostty_app_tick(ghostty_app_t); void* ghostty_app_userdata(ghostty_app_t); void ghostty_app_set_focus(ghostty_app_t, bool); bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); +bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); void ghostty_app_keyboard_changed(ghostty_app_t); void ghostty_app_open_config(ghostty_app_t); void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 513a6872e..70873236a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -425,6 +425,15 @@ class AppDelegate: NSObject, // because we let it capture and propagate. guard NSApp.mainWindow == nil else { return event } + // If this event as-is would result in a key binding then we send it. + if let app = ghostty.app, + ghostty_app_key_is_binding( + app, + event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { + ghostty_app_key(app, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) + return nil + } + // If this event would be handled by our menu then we do nothing. if let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) { diff --git a/src/App.zig b/src/App.zig index b0de85c95..a6b54db23 100644 --- a/src/App.zig +++ b/src/App.zig @@ -313,6 +313,25 @@ pub fn focusEvent(self: *App, focused: bool) void { self.focused = focused; } +/// Returns true if the given key event would trigger a keybinding +/// if it were to be processed. This is useful for determining if +/// a key event should be sent to the terminal or not. +pub fn keyEventIsBinding( + self: *App, + rt_app: *apprt.App, + event: input.KeyEvent, +) bool { + _ = self; + + switch (event.action) { + .release => return false, + .press, .repeat => {}, + } + + // If we have a keybinding for this event then we return true. + return rt_app.config.keybind.set.getEvent(event) != null; +} + /// Handle a key event at the app-scope. If this key event is used, /// this will return true and the caller shouldn't continue processing /// the event. If the event is not used, this will return false. diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 758a3ff87..10d09988d 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1386,6 +1386,28 @@ pub const CAPI = struct { }; } + /// Returns true if the given key event would trigger a binding + /// if it were sent to the surface right now. The "right now" + /// is important because things like trigger sequences are only + /// valid until the next key event. + export fn ghostty_app_key_is_binding( + app: *App, + event: KeyEvent, + ) bool { + const core_event = app.coreKeyEvent( + .app, + event.keyEvent(), + ) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + } orelse { + log.warn("error processing key event", .{}); + return false; + }; + + return app.core_app.keyEventIsBinding(app, core_event); + } + /// Notify the app that the keyboard was changed. This causes the /// keyboard layout to be reloaded from the OS. export fn ghostty_app_keyboard_changed(v: *App) void { From 62fae29395b658b014b574623ae9c3eff61af91a Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Sat, 4 Jan 2025 23:37:54 +0100 Subject: [PATCH 026/238] chore: rename file --- src/inspector/Inspector.zig | 10 +++++----- src/inspector/page.zig | 4 ++-- src/inspector/{utils.zig => units.zig} | 0 3 files changed, 7 insertions(+), 7 deletions(-) rename src/inspector/{utils.zig => units.zig} (100%) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 73d063125..54d49b088 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -14,7 +14,7 @@ const input = @import("../input.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); -const utils = @import("utils.zig"); +const units = @import("units.zig"); /// The window names. These are used with docking so we need to have access. const window_cell = "Cell"; @@ -441,7 +441,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_bytes, utils.toKibiBytes(kitty_images.total_bytes)); + cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_bytes, units.toKibiBytes(kitty_images.total_bytes)); } } @@ -453,7 +453,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_limit, utils.toKibiBytes(kitty_images.total_limit)); + cimgui.c.igText("%d bytes (%d KiB)", kitty_images.total_limit, units.toKibiBytes(kitty_images.total_limit)); } } @@ -519,7 +519,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KiB)", pages.page_size, utils.toKibiBytes(pages.page_size)); + cimgui.c.igText("%d bytes (%d KiB)", pages.page_size, units.toKibiBytes(pages.page_size)); } } @@ -531,7 +531,7 @@ fn renderScreenWindow(self: *Inspector) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KiB)", pages.maxSize(), utils.toKibiBytes(pages.maxSize())); + cimgui.c.igText("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize())); } } diff --git a/src/inspector/page.zig b/src/inspector/page.zig index c3ca93c5f..bb95d59b9 100644 --- a/src/inspector/page.zig +++ b/src/inspector/page.zig @@ -4,7 +4,7 @@ const Allocator = std.mem.Allocator; const cimgui = @import("cimgui"); const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); -const utils = @import("utils.zig"); +const units = @import("units.zig"); pub fn render(page: *const terminal.Page) void { cimgui.c.igPushID_Ptr(page); @@ -27,7 +27,7 @@ pub fn render(page: *const terminal.Page) void { } { _ = cimgui.c.igTableSetColumnIndex(1); - cimgui.c.igText("%d bytes (%d KiB)", page.memory.len, utils.toKibiBytes(page.memory.len)); + cimgui.c.igText("%d bytes (%d KiB)", page.memory.len, units.toKibiBytes(page.memory.len)); cimgui.c.igText("%d VM pages", page.memory.len / std.mem.page_size); } } diff --git a/src/inspector/utils.zig b/src/inspector/units.zig similarity index 100% rename from src/inspector/utils.zig rename to src/inspector/units.zig From f3cb95ac1f38575456fbd4b6c7b587e35ac5ea75 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 4 Jan 2025 16:56:52 -0600 Subject: [PATCH 027/238] gtk: add `split-separator-color` config Fixes #4326 for GTK --- src/apprt/gtk/App.zig | 15 +++++++++++++++ src/config/Config.zig | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 4e1e28ee6..e98c58089 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -996,6 +996,21 @@ fn loadRuntimeCss( 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 (version.atLeast(4, 16, 0)) { switch (window_theme) { .ghostty => try writer.print( diff --git a/src/config/Config.zig b/src/config/Config.zig index 8283c2a22..cade03734 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -610,6 +610,10 @@ palette: Palette = .{}, /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"unfocused-split-fill": ?Color = null, +/// The color of the divider between splits. If unset the default system color +/// will be used. GTK only. +@"split-divider-color": ?Color = null, + /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up /// from your system. The rules for the default lookup are: From 5fa9e8848208dbdab0250c9924f09fe4ab9e2cd4 Mon Sep 17 00:00:00 2001 From: Patrick Reynolds Date: Sat, 4 Jan 2025 19:26:14 -0500 Subject: [PATCH 028/238] Use \w instead of $PWD for title bar --- src/shell-integration/bash/ghostty.bash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 72ae455df..1cd939659 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -141,7 +141,7 @@ function __ghostty_precmd() { # Command and working directory # shellcheck disable=SC2016 PS0=$PS0'$(__ghostty_get_current_command)' - PS1=$PS1'\[\e]2;$PWD\a\]' + PS1=$PS1'\[\e]2;\w\a\]' fi fi From 6db39e827ecccf3980bef56b95dc747c27e11d72 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 5 Jan 2025 01:00:16 +0000 Subject: [PATCH 029/238] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 5c202e9cd..4a6fdb4b1 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -49,8 +49,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz", - .hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/4762ad5bd6d3906e28babdc2bda8a967d63a63be.tar.gz", + .hash = "1220a263b22113273d01bd33e3c06b8119cb2f63b4e5d414a85d88e3aa95bb68a2de", }, .vaxis = .{ .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 60e9e58a4..0523f8e96 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-njCce+r1DPTKLNrmrD2ObEoBS9nR7q03hqegQWe1UuY=" +"sha256-l+tZVL18qhm8BoBsQVbKfYmXQVObD0QMzQe6VBM/8Oo=" From da80531c2222a7d0004cea1a5a3238f995ef482e Mon Sep 17 00:00:00 2001 From: Christian Schneider Date: Sat, 4 Jan 2025 23:49:27 +0100 Subject: [PATCH 030/238] Implement configuration option split-divider-color for macOS --- macos/Sources/Ghostty/Ghostty.Config.swift | 15 ++++++++++++++- src/config/Config.zig | 4 ++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index b6da07612..0c1f4e07a 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -381,7 +381,20 @@ extension Ghostty { let backgroundColor = OSColor(backgroundColor) let isLightBackground = backgroundColor.isLightColor let newColor = isLightBackground ? backgroundColor.darken(by: 0.08) : backgroundColor.darken(by: 0.4) - return Color(newColor) + + guard let config = self.config else { return Color(newColor) } + + var color: ghostty_config_color_s = .init(); + let key = "split-divider-color" + if (!ghostty_config_get(config, &color, key, UInt(key.count))) { + return Color(newColor) + } + + return .init( + red: Double(color.r) / 255, + green: Double(color.g) / 255, + blue: Double(color.b) / 255 + ) } #if canImport(AppKit) diff --git a/src/config/Config.zig b/src/config/Config.zig index cade03734..6fc9ddf9e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -610,8 +610,8 @@ palette: Palette = .{}, /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"unfocused-split-fill": ?Color = null, -/// The color of the divider between splits. If unset the default system color -/// will be used. GTK only. +/// The color of the split divider. If this is not set, a default will be chosen. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"split-divider-color": ?Color = null, /// The command to run, usually a shell. If this is not an absolute path, it'll From a670836d7abc1e7e377d6af7c599fe433a387cd1 Mon Sep 17 00:00:00 2001 From: Christian Schneider Date: Sun, 5 Jan 2025 00:44:41 +0100 Subject: [PATCH 031/238] Remove outdated comment --- macos/Sources/Ghostty/Ghostty.Config.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 0c1f4e07a..ed9364914 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -375,8 +375,6 @@ extension Ghostty { ) } - // This isn't actually a configurable value currently but it could be done day. - // We put it here because it is a color that changes depending on the configuration. var splitDividerColor: Color { let backgroundColor = OSColor(backgroundColor) let isLightBackground = backgroundColor.isLightColor From 51c42795fca93145118fa39f3fd8dabdc5616448 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 3 Jan 2025 22:44:13 -0600 Subject: [PATCH 032/238] gtk: enable window-title-font-family --- src/apprt/gtk/App.zig | 20 ++++++++++++------- src/apprt/gtk/ClipboardConfirmationWindow.zig | 1 + src/apprt/gtk/ConfigErrorsWindow.zig | 1 + src/apprt/gtk/Window.zig | 1 + src/apprt/gtk/inspector.zig | 1 + src/config/Config.zig | 5 ++++- 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index e98c58089..6a1c089e5 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1002,13 +1002,19 @@ fn loadRuntimeCss( \\ 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, - }, - ); + , .{ + .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 (version.atLeast(4, 16, 0)) { diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index b6db7c5ef..a04271497 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -64,6 +64,7 @@ fn init( c.gtk_window_set_title(gtk_window, titleText(request)); c.gtk_window_set_default_size(gtk_window, 550, 275); c.gtk_window_set_resizable(gtk_window, 0); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window"); c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "clipboard-confirmation-window"); _ = c.g_signal_connect_data( window, diff --git a/src/apprt/gtk/ConfigErrorsWindow.zig b/src/apprt/gtk/ConfigErrorsWindow.zig index ff2791997..5fbf8e835 100644 --- a/src/apprt/gtk/ConfigErrorsWindow.zig +++ b/src/apprt/gtk/ConfigErrorsWindow.zig @@ -55,6 +55,7 @@ fn init(self: *ConfigErrors, app: *App) !void { c.gtk_window_set_default_size(gtk_window, 600, 275); c.gtk_window_set_resizable(gtk_window, 0); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window"); c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "config-errors-window"); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 516ea7fc5..fecd05dbd 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -99,6 +99,7 @@ pub fn init(self: *Window, app: *App) !void { self.window = gtk_window; c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_default_size(gtk_window, 1000, 600); + c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window"); c.gtk_widget_add_css_class(@ptrCast(gtk_window), "terminal-window"); // GTK4 grabs F10 input by default to focus the menubar icon. We want diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig index 0c5514ce8..558175751 100644 --- a/src/apprt/gtk/inspector.zig +++ b/src/apprt/gtk/inspector.zig @@ -143,6 +143,7 @@ const Window = struct { c.gtk_window_set_title(gtk_window, "Ghostty: Terminal Inspector"); c.gtk_window_set_default_size(gtk_window, 1000, 600); c.gtk_window_set_icon_name(gtk_window, build_config.bundle_id); + c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "window"); c.gtk_widget_add_css_class(@ptrCast(@alignCast(gtk_window)), "inspector-window"); // Initialize our imgui widget diff --git a/src/config/Config.zig b/src/config/Config.zig index cade03734..615efe7a0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1077,7 +1077,10 @@ keybind: Keybinds = .{}, /// The font that will be used for the application's window and tab titles. /// -/// This is currently only supported on macOS. +/// If this setting is left unset, the system default font will be used. +/// +/// Note: any font available on the system may be used, this font is not +/// required to be a fixed-width font. @"window-title-font-family": ?[:0]const u8 = null, /// The theme to use for the windows. Valid values: From e05c3b6fd7081b0a6578e668c7f23b994c3b45c1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jan 2025 20:29:45 -0800 Subject: [PATCH 033/238] macos: alphabetize resources in xcode project --- macos/Ghostty.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index adb27338b..a24217cac 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -11,7 +11,7 @@ 55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; }; 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; - 9351BE8E3D22937F003B3499 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* vim */; }; + 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; @@ -111,7 +111,7 @@ 55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = ""; }; 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; - 9351BE8E2D22937F003B3499 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/nvim"; sourceTree = ""; }; + 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = ""; }; @@ -405,13 +405,13 @@ children = ( FC9ABA9B2D0F538D0020D4C8 /* bash-completion */, 29C15B1C2CDC3B2000520DD4 /* bat */, - 55154BDF2B33911F001622DC /* ghostty */, - 552964E52B34A9B400030505 /* vim */, A586167B2B7703CC009BDB1D /* fish */, + 55154BDF2B33911F001622DC /* ghostty */, A5985CE52C33060F00C57AD3 /* man */, + 9351BE8E2D22937F003B3499 /* nvim */, A5A1F8842A489D6800D1E8BC /* terminfo */, + 552964E52B34A9B400030505 /* vim */, FC5218F92D10FFC7004C93E0 /* zsh */, - 9351BE8E2D22937F003B3499 /* vim */, ); name = Resources; sourceTree = ""; @@ -588,7 +588,7 @@ A5985CE62C33060F00C57AD3 /* man in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, 552964E62B34A9B400030505 /* vim in Resources */, - 9351BE8E3D22937F003B3499 /* vim in Resources */, + 9351BE8E3D22937F003B3499 /* nvim in Resources */, A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; From 4ffd281de313b8eacb2e4fada39fdc15fd94cf80 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 4 Jan 2025 21:41:04 -0800 Subject: [PATCH 034/238] macos: detect IME input source change as part of keyDown event Fixes #4539 AquaSKK is a Japanese IME (Input Method Editor) for macOS. It uses keyboard inputs to switch between input modes. I don't know any other IMEs that do this, but it's possible that there are others. Prior to this change, the keyboard inputs to switch between input modes were being sent to the terminal, resulting in erroneous characters being written. This change adds a check during keyDown events to see if the input source changed _during the event_. If it did, we assume an IME captured it and we don't pass the event to the terminal. This makes AquaSKK functional in Ghostty. --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++++ macos/Sources/Ghostty/SurfaceView_AppKit.swift | 15 +++++++++++++++ macos/Sources/Helpers/KeyboardLayout.swift | 14 ++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 macos/Sources/Helpers/KeyboardLayout.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a24217cac..1e37006c2 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -71,6 +71,7 @@ A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; @@ -164,6 +165,7 @@ A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = ""; }; @@ -267,6 +269,7 @@ A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, + A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, @@ -655,6 +658,7 @@ A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, + A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */, A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 297ca8ea0..4e0550cc2 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -805,8 +805,23 @@ extension Ghostty { // know if these events cleared it. let markedTextBefore = markedText.length > 0 + // We need to know the keyboard layout before below because some keyboard + // input events will change our keyboard layout and we don't want those + // going to the terminal. + let keyboardIdBefore: String? = if (!markedTextBefore) { + KeyboardLayout.id + } else { + nil + } + self.interpretKeyEvents([translationEvent]) + // If our keyboard changed from this we just assume an input method + // grabbed it and do nothing. + if (!markedTextBefore && keyboardIdBefore != KeyboardLayout.id) { + return + } + // If we have text, then we've composed a character, send that down. We do this // first because if we completed a preedit, the text will be available here // AND we'll have a preedit. diff --git a/macos/Sources/Helpers/KeyboardLayout.swift b/macos/Sources/Helpers/KeyboardLayout.swift new file mode 100644 index 000000000..8e573f495 --- /dev/null +++ b/macos/Sources/Helpers/KeyboardLayout.swift @@ -0,0 +1,14 @@ +import Carbon + +class KeyboardLayout { + /// Return a string ID of the current keyboard input source. + static var id: String? { + if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), + let sourceIdPointer = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) { + let sourceId = unsafeBitCast(sourceIdPointer, to: CFString.self) + return sourceId as String + } + + return nil + } +} From 4d4b785a58e7c890a6a316801cb704bd1df12cb8 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Sun, 5 Jan 2025 09:25:47 -0600 Subject: [PATCH 035/238] gtk: send copy_to_clipboard toast from Surface Move the toast we send when copying to the clipboard to the Surface implementation. Previously, we only called this from the gtk accelerator callback which we only call when the *last set* keybind is activated. We also only send a toast if we have copied to the standard clipboard, as opposed to the selection clipboard. By default, we have copy-to-clipboard true for linux, which sets the selection keyboard on any select. This becomes *very* noisy. --- src/apprt/gtk/Surface.zig | 7 +++++++ src/apprt/gtk/Window.zig | 6 +----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index c53190ccc..056a3f40b 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1080,6 +1080,13 @@ pub fn setClipboardString( if (!confirm) { const clipboard = getClipboard(@ptrCast(self.gl_area), clipboard_type); c.gdk_clipboard_set_text(clipboard, val.ptr); + // We only toast if we are copying to the standard clipboard. + if (clipboard_type == .standard and + self.app.config.@"adw-toast".@"clipboard-copy") + { + if (self.container.window()) |window| + window.sendToast("Copied to clipboard"); + } return; } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index fecd05dbd..9b9e26383 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -543,7 +543,7 @@ pub fn onConfigReloaded(self: *Window) void { self.sendToast("Reloaded the configuration"); } -fn sendToast(self: *Window, title: [:0]const u8) void { +pub fn sendToast(self: *Window, title: [:0]const u8) void { if (comptime !adwaita.versionAtLeast(0, 0, 0)) return; const toast_overlay = self.toast_overlay orelse return; const toast = c.adw_toast_new(title); @@ -895,10 +895,6 @@ fn gtkActionCopy( log.warn("error performing binding action error={}", .{err}); return; }; - - if (self.app.config.@"adw-toast".@"clipboard-copy") { - self.sendToast("Copied to clipboard"); - } } fn gtkActionPaste( From 9cf9e0639fa3a03250c3ebb20ecfb734b7a20414 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Sun, 5 Jan 2025 09:37:47 -0600 Subject: [PATCH 036/238] config: rearrange default copy_to_clipboard keybinds Move the newly added *+insert keybinds to before the ctrl+shift+* keybinds. This is needed to have the ctrl+shift keybinds be the ones that show up in the menu. --- src/config/Config.zig | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index cbeafdd1c..e31b9c5e9 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2165,6 +2165,25 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { ); { + // On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an + // alt keybinding for Copy and shift+ins is an alt keybinding for Paste + // + // The order of these blocks is important. The *last* added keybind for a given action is + // what will display in the menu. We want the more typical keybinds after this block to be + // the standard + if (!builtin.target.isDarwin()) { + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .insert }, .mods = .{ .ctrl = true } }, + .{ .copy_to_clipboard = {} }, + ); + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } }, + .{ .paste_from_clipboard = {} }, + ); + } + // On macOS we default to super but Linux ctrl+shift since // ctrl+c is to kill the process. const mods: inputpkg.Mods = if (builtin.target.isDarwin()) @@ -2182,20 +2201,6 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .v }, .mods = mods }, .{ .paste_from_clipboard = {} }, ); - // On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an - // alt keybinding for Copy and shift+ins is an alt keybinding for Paste - if (!builtin.target.isDarwin()) { - try result.keybind.set.put( - alloc, - .{ .key = .{ .translated = .insert }, .mods = .{ .ctrl = true } }, - .{ .copy_to_clipboard = {} }, - ); - try result.keybind.set.put( - alloc, - .{ .key = .{ .translated = .insert }, .mods = .{ .shift = true } }, - .{ .paste_from_clipboard = {} }, - ); - } } // Increase font size mapping for keyboards with dedicated plus keys (like german) From 8d7e57f64b4d2d88cb50fafebc88c309d0ba6571 Mon Sep 17 00:00:00 2001 From: Beau McCartney Date: Sun, 5 Jan 2025 13:06:32 -0700 Subject: [PATCH 037/238] vim compiler plugin for ghostty filetype - validates config `:make` will call `ghostty +validate-config` and populate the quickfix list with the errors that can be navigated to (e.g. with `:cnext`) `:h write-compiler-plugin`, and neovim's built in ftplugin/ and compiler/ plugins were used as references --- build.zig | 2 ++ src/config/vim.zig | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/build.zig b/build.zig index d92d3e719..09b029f6f 100644 --- a/build.zig +++ b/build.zig @@ -615,6 +615,7 @@ pub fn build(b: *std.Build) !void { _ = wf.add("syntax/ghostty.vim", config_vim.syntax); _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); + _ = wf.add("compiler/ghostty.vim", config_vim.compiler); b.installDirectory(.{ .source_dir = wf.getDirectory(), .install_dir = .prefix, @@ -631,6 +632,7 @@ pub fn build(b: *std.Build) !void { _ = wf.add("syntax/ghostty.vim", config_vim.syntax); _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); + _ = wf.add("compiler/ghostty.vim", config_vim.compiler); b.installDirectory(.{ .source_dir = wf.getDirectory(), .install_dir = .prefix, diff --git a/src/config/vim.zig b/src/config/vim.zig index d048fc990..00836cd40 100644 --- a/src/config/vim.zig +++ b/src/config/vim.zig @@ -24,6 +24,21 @@ pub const ftplugin = \\ \\let b:undo_ftplugin = 'setl cms< isk< ofu<' \\ + \\if !exists('current_compiler') + \\ compiler ghostty + \\ let b:undo_ftplugin .= "| compiler make" + \\endif + \\ +; +pub const compiler = + \\if exists("current_compiler") + \\ finish + \\endif + \\let current_compiler = "ghostty" + \\ + \\CompilerSet makeprg=ghostty\ +validate-config + \\CompilerSet errorformat=%f:%l:%m + \\ ; /// Generates the syntax file at comptime. From 31439f311d511421690cd134d9f613960ea3de33 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 2 Jan 2025 21:44:16 +0800 Subject: [PATCH 038/238] build: add wayland --- build.zig | 50 ++++++++++++++++------ build.zig.zon | 8 ++++ nix/devShell.nix | 7 +++ nix/package.nix | 44 ++++++++++++------- src/apprt/gtk/App.zig | 9 ++++ src/apprt/gtk/Window.zig | 17 ++++++++ src/apprt/gtk/c.zig | 3 ++ src/apprt/gtk/wayland.zig | 90 +++++++++++++++++++++++++++++++++++++++ src/build_config.zig | 2 + src/cli/version.zig | 8 ++++ 10 files changed, 211 insertions(+), 27 deletions(-) create mode 100644 src/apprt/gtk/wayland.zig diff --git a/build.zig b/build.zig index d92d3e719..6c030a9c5 100644 --- a/build.zig +++ b/build.zig @@ -24,6 +24,8 @@ const XCFrameworkStep = @import("src/build/XCFrameworkStep.zig"); const Version = @import("src/build/Version.zig"); const Command = @import("src/Command.zig"); +const Scanner = @import("zig_wayland").Scanner; + comptime { // This is the required Zig version for building this project. We allow // any patch version but the major and minor must match exactly. @@ -105,19 +107,19 @@ pub fn build(b: *std.Build) !void { "Enables the use of Adwaita when using the GTK rendering backend.", ) orelse true; - config.x11 = b.option( - bool, - "gtk-x11", - "Enables linking against X11 libraries when using the GTK rendering backend.", - ) orelse x11: { - if (target.result.os.tag != .linux) break :x11 false; + var x11 = false; + var wayland = false; + if (target.result.os.tag == .linux) pkgconfig: { var pkgconfig = std.process.Child.init(&.{ "pkg-config", "--variable=targets", "gtk4" }, b.allocator); pkgconfig.stdout_behavior = .Pipe; pkgconfig.stderr_behavior = .Pipe; - try pkgconfig.spawn(); + pkgconfig.spawn() catch |err| { + std.log.warn("failed to spawn pkg-config - disabling X11 and Wayland integrations: {}", .{err}); + break :pkgconfig; + }; const output_max_size = 50 * 1024; @@ -139,18 +141,31 @@ pub fn build(b: *std.Build) !void { switch (term) { .Exited => |code| { if (code == 0) { - if (std.mem.indexOf(u8, stdout.items, "x11")) |_| break :x11 true; - break :x11 false; + if (std.mem.indexOf(u8, stdout.items, "x11")) |_| x11 = true; + if (std.mem.indexOf(u8, stdout.items, "wayland")) |_| wayland = true; + } else { + std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); + return error.Unexpected; } - std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); - break :x11 false; }, inline else => |code| { std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); return error.Unexpected; }, } - }; + } + + config.x11 = b.option( + bool, + "gtk-x11", + "Enables linking against X11 libraries when using the GTK rendering backend.", + ) orelse x11; + + config.wayland = b.option( + bool, + "gtk-wayland", + "Enables linking against Wayland libraries when using the GTK rendering backend.", + ) orelse wayland; config.sentry = b.option( bool, @@ -1459,6 +1474,17 @@ fn addDeps( if (config.adwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts); if (config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts); + if (config.wayland) { + const scanner = Scanner.create(b, .{}); + + const wayland = b.createModule(.{ .root_source_file = scanner.result }); + + scanner.generate("wl_compositor", 1); + + step.root_module.addImport("wayland", wayland); + step.linkSystemLibrary2("wayland-client", dynamic_link_opts); + } + { const gresource = @import("src/apprt/gtk/gresource.zig"); diff --git a/build.zig.zon b/build.zig.zon index 4a6fdb4b1..33be26193 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -25,6 +25,10 @@ .url = "https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz", .hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25", }, + .zig_wayland = .{ + .url = "https://codeberg.org/ifreund/zig-wayland/archive/a5e2e9b6a6d7fba638ace4d4b24a3b576a02685b.tar.gz", + .hash = "1220d41b23ae70e93355bb29dac1c07aa6aeb92427a2dffc4375e94b4de18111248c", + }, // C libs .cimgui = .{ .path = "./pkg/cimgui" }, @@ -64,5 +68,9 @@ .url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a", .hash = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a", }, + .plasma_wayland_protocols = .{ + .url = "git+https://invent.kde.org/libraries/plasma-wayland-protocols.git?ref=master#db525e8f9da548cffa2ac77618dd0fbe7f511b86", + .hash = "12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566", + }, }, } diff --git a/nix/devShell.nix b/nix/devShell.nix index 5e86427fe..c52afb6c0 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -51,6 +51,9 @@ pandoc, hyperfine, typos, + wayland, + wayland-scanner, + wayland-protocols, }: let # See package.nix. Keep in sync. rpathLibs = @@ -80,6 +83,7 @@ libadwaita gtk4 glib + wayland ]; in mkShell { @@ -153,6 +157,9 @@ in libadwaita gtk4 glib + wayland + wayland-scanner + wayland-protocols ]; # This should be set onto the rpath of the ghostty binary if you want diff --git a/nix/package.nix b/nix/package.nix index 78d2e2fdd..1155b76b6 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -10,10 +10,6 @@ oniguruma, zlib, libGL, - libX11, - libXcursor, - libXi, - libXrandr, glib, gtk4, libadwaita, @@ -26,7 +22,17 @@ pandoc, revision ? "dirty", optimize ? "Debug", - x11 ? true, + + enableX11 ? true, + libX11, + libXcursor, + libXi, + libXrandr, + + enableWayland ? true, + wayland, + wayland-protocols, + wayland-scanner, }: let # The Zig hook has no way to select the release type without actual # overriding of the default flags. @@ -114,14 +120,19 @@ in version = "1.0.2"; inherit src; - nativeBuildInputs = [ - git - ncurses - pandoc - pkg-config - zig_hook - wrapGAppsHook4 - ]; + nativeBuildInputs = + [ + git + ncurses + pandoc + pkg-config + zig_hook + wrapGAppsHook4 + ] + ++ lib.optionals enableWayland [ + wayland-scanner + wayland-protocols + ]; buildInputs = [ @@ -142,16 +153,19 @@ in glib gsettings-desktop-schemas ] - ++ lib.optionals x11 [ + ++ lib.optionals enableX11 [ libX11 libXcursor libXi libXrandr + ] + ++ lib.optionals enableWayland [ + wayland ]; dontConfigure = true; - zigBuildFlags = "-Dversion-string=${finalAttrs.version}-${revision}-nix -Dgtk-x11=${lib.boolToString x11}"; + zigBuildFlags = "-Dversion-string=${finalAttrs.version}-${revision}-nix -Dgtk-x11=${lib.boolToString enableX11} -Dgtk-wayland=${lib.boolToString enableWayland}"; preBuild = '' rm -rf $ZIG_GLOBAL_CACHE_DIR diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 6a1c089e5..b43f79274 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -37,6 +37,7 @@ const version = @import("version.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); const x11 = @import("x11.zig"); +const wayland = @import("wayland.zig"); const testing = std.testing; const log = std.log.scoped(.gtk); @@ -73,6 +74,9 @@ running: bool = true, /// Xkb state (X11 only). Will be null on Wayland. x11_xkb: ?x11.Xkb = null, +/// Wayland app state. Will be null on X11. +wayland: ?wayland.AppState = null, + /// 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. @@ -397,6 +401,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { break :x11_xkb try x11.Xkb.init(display); }; + // Initialize Wayland state + var wl = wayland.AppState.init(display); + if (wl) |*w| try w.register(); + // 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 @@ -422,6 +430,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { .ctx = ctx, .cursor_none = cursor_none, .x11_xkb = x11_xkb, + .wayland = wl, .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 diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 9b9e26383..d41fda138 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -25,6 +25,7 @@ const gtk_key = @import("key.zig"); const Notebook = @import("notebook.zig").Notebook; const HeaderBar = @import("headerbar.zig").HeaderBar; const version = @import("version.zig"); +const wayland = @import("wayland.zig"); const log = std.log.scoped(.gtk); @@ -55,6 +56,8 @@ toast_overlay: ?*c.GtkWidget, /// See adwTabOverviewOpen for why we have this. adw_tab_overview_focus_timer: ?c.guint = null, +wayland: ?wayland.SurfaceState, + 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 @@ -79,6 +82,7 @@ pub fn init(self: *Window, app: *App) !void { .notebook = undefined, .context_menu = undefined, .toast_overlay = undefined, + .wayland = null, }; // Create the window @@ -290,6 +294,7 @@ pub fn init(self: *Window, app: *App) !void { // All of our events _ = c.g_signal_connect_data(self.context_menu, "closed", c.G_CALLBACK(>kRefocusTerm), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(window, "realize", c.G_CALLBACK(>kRealize), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "close-request", c.G_CALLBACK(>kCloseRequest), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(ec_key_press, "key-pressed", c.G_CALLBACK(>kKeyPressed), self, null, c.G_CONNECT_DEFAULT); @@ -424,6 +429,8 @@ fn initActions(self: *Window) void { pub fn deinit(self: *Window) void { c.gtk_widget_unparent(@ptrCast(self.context_menu)); + if (self.wayland) |*wl| wl.deinit(); + if (self.adw_tab_overview_focus_timer) |timer| { _ = c.g_source_remove(timer); } @@ -551,6 +558,16 @@ pub fn sendToast(self: *Window, title: [:0]const u8) void { c.adw_toast_overlay_add_toast(@ptrCast(toast_overlay), toast); } +fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { + const self = userdataSelf(ud.?); + + if (self.app.wayland) |*wl| { + self.wayland = wayland.SurfaceState.init(v, wl); + } + + return true; +} + // Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab // sends an undefined value. fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { diff --git a/src/apprt/gtk/c.zig b/src/apprt/gtk/c.zig index abd4821d3..dde99c78e 100644 --- a/src/apprt/gtk/c.zig +++ b/src/apprt/gtk/c.zig @@ -14,6 +14,9 @@ pub const c = @cImport({ // Xkb for X11 state handling @cInclude("X11/XKBlib.h"); } + if (build_options.wayland) { + @cInclude("gdk/wayland/gdkwayland.h"); + } // generated header files @cInclude("ghostty_resources.h"); diff --git a/src/apprt/gtk/wayland.zig b/src/apprt/gtk/wayland.zig new file mode 100644 index 000000000..034309812 --- /dev/null +++ b/src/apprt/gtk/wayland.zig @@ -0,0 +1,90 @@ +const std = @import("std"); +const c = @import("c.zig").c; +const wayland = @import("wayland"); +const wl = wayland.client.wl; +const build_options = @import("build_options"); + +const log = std.log.scoped(.gtk_wayland); + +/// Wayland state that contains application-wide Wayland objects (e.g. wl_display). +pub const AppState = struct { + display: *wl.Display, + + pub fn init(display: ?*c.GdkDisplay) ?AppState { + if (comptime !build_options.wayland) return null; + + // It should really never be null + const display_ = display orelse return null; + + // Check if we're actually on Wayland + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(display_)), + c.gdk_wayland_display_get_type(), + ) == 0) + return null; + + const wl_display: *wl.Display = @ptrCast(c.gdk_wayland_display_get_wl_display(display_) orelse return null); + + return .{ + .display = wl_display, + }; + } + + pub fn register(self: *AppState) !void { + const registry = try self.display.getRegistry(); + + registry.setListener(*AppState, registryListener, self); + if (self.display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + + log.debug("app wayland init={}", .{self}); + } +}; + +/// Wayland state that contains Wayland objects associated with a window (e.g. wl_surface). +pub const SurfaceState = struct { + app_state: *AppState, + surface: *wl.Surface, + + pub fn init(window: *c.GtkWindow, app_state: *AppState) ?SurfaceState { + if (comptime !build_options.wayland) return null; + + const surface = c.gtk_native_get_surface(@ptrCast(window)) orelse return null; + + // Check if we're actually on Wayland + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(surface)), + c.gdk_wayland_surface_get_type(), + ) == 0) + return null; + + const wl_surface: *wl.Surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface(surface) orelse return null); + + return .{ + .app_state = app_state, + .surface = wl_surface, + }; + } + + pub fn deinit(self: *SurfaceState) void { + } +}; + +fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, state: *AppState) void { + switch (event) { + .global => |global| { + log.debug("got global interface={s}", .{global.interface}); + }, + .global_remove => {}, + } +} + +fn bindInterface(comptime T: type, registry: *wl.Registry, global: anytype, version: u32) ?*T { + if (std.mem.orderZ(u8, global.interface, T.interface.name) == .eq) { + return registry.bind(global.name, T, version) catch |err| { + log.warn("encountered error={} while binding interface {s}", .{ err, global.interface }); + return null; + }; + } else { + return null; + } +} diff --git a/src/build_config.zig b/src/build_config.zig index c70615144..13131c132 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -23,6 +23,7 @@ pub const BuildConfig = struct { flatpak: bool = false, adwaita: bool = false, x11: bool = false, + wayland: bool = false, sentry: bool = true, app_runtime: apprt.Runtime = .none, renderer: rendererpkg.Impl = .opengl, @@ -44,6 +45,7 @@ pub const BuildConfig = struct { step.addOption(bool, "flatpak", self.flatpak); step.addOption(bool, "adwaita", self.adwaita); step.addOption(bool, "x11", self.x11); + step.addOption(bool, "wayland", self.wayland); step.addOption(bool, "sentry", self.sentry); step.addOption(apprt.Runtime, "app_runtime", self.app_runtime); step.addOption(font.Backend, "font_backend", self.font_backend); diff --git a/src/cli/version.zig b/src/cli/version.zig index 99f03384b..b00152589 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -68,6 +68,14 @@ pub fn run(alloc: Allocator) !u8 { } else { try stdout.print(" - libX11 : disabled\n", .{}); } + + // We say `libwayland` since it is possible to build Ghostty without + // Wayland integration but with Wayland-enabled GTK + if (comptime build_options.wayland) { + try stdout.print(" - libwayland : enabled\n", .{}); + } else { + try stdout.print(" - libwayland : disabled\n", .{}); + } } return 0; } From 9184395cbaf7c5db4c87dba2493328173eececa5 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 2 Jan 2025 21:44:16 +0800 Subject: [PATCH 039/238] gtk(wayland): add support for background blur on KDE Plasma --- build.zig | 7 +++++++ src/apprt/gtk/App.zig | 8 +++++--- src/apprt/gtk/Window.zig | 15 +++++++++++++++ src/apprt/gtk/wayland.zig | 35 +++++++++++++++++++++++++++++++++++ src/config/Config.zig | 20 ++++++++++++++++++-- 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/build.zig b/build.zig index 6c030a9c5..ceb7ed381 100644 --- a/build.zig +++ b/build.zig @@ -1479,7 +1479,14 @@ fn addDeps( const wayland = b.createModule(.{ .root_source_file = scanner.result }); + const plasma_wayland_protocols = b.dependency("plasma_wayland_protocols", .{ + .target = target, + .optimize = optimize, + }); + scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/blur.xml")); + scanner.generate("wl_compositor", 1); + scanner.generate("org_kde_kwin_blur_manager", 1); step.root_module.addImport("wayland", wayland); step.linkSystemLibrary2("wayland-client", dynamic_link_opts); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index b43f79274..3cc1782c8 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -847,9 +847,11 @@ fn configChange( new_config: *const Config, ) void { switch (target) { - // We don't do anything for surface config change events. There - // is nothing to sync with regards to a surface today. - .surface => {}, + .surface => |surface| { + if (surface.rt_surface.container.window()) |window| window.syncAppearance(new_config) catch |err| { + log.warn("error syncing appearance changes to window err={}", .{err}); + }; + }, .app => { // We clone (to take ownership) and update our configuration. diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index d41fda138..26598d03a 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -392,6 +392,17 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_show(window); } +/// Updates appearance based on config settings. Will be called once upon window +/// realization, and every time the config is reloaded. +/// +/// TODO: Many of the initial style settings in `create` could possibly be made +/// reactive by moving them here. +pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { + if (self.wayland) |*wl| { + try wl.setBlur(config.@"background-blur-radius" > 0); + } +} + /// 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. @@ -565,6 +576,10 @@ fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { self.wayland = wayland.SurfaceState.init(v, wl); } + self.syncAppearance(&self.app.config) catch |err| { + log.err("failed to initialize appearance={}", .{err}); + }; + return true; } diff --git a/src/apprt/gtk/wayland.zig b/src/apprt/gtk/wayland.zig index 034309812..92446cc46 100644 --- a/src/apprt/gtk/wayland.zig +++ b/src/apprt/gtk/wayland.zig @@ -2,6 +2,7 @@ const std = @import("std"); const c = @import("c.zig").c; const wayland = @import("wayland"); const wl = wayland.client.wl; +const org = wayland.client.org; const build_options = @import("build_options"); const log = std.log.scoped(.gtk_wayland); @@ -9,6 +10,7 @@ const log = std.log.scoped(.gtk_wayland); /// Wayland state that contains application-wide Wayland objects (e.g. wl_display). pub const AppState = struct { display: *wl.Display, + blur_manager: ?*org.KdeKwinBlurManager = null, pub fn init(display: ?*c.GdkDisplay) ?AppState { if (comptime !build_options.wayland) return null; @@ -45,6 +47,9 @@ pub const SurfaceState = struct { app_state: *AppState, surface: *wl.Surface, + /// A token that, when present, indicates that the window is blurred. + blur_token: ?*org.KdeKwinBlur = null, + pub fn init(window: *c.GtkWindow, app_state: *AppState) ?SurfaceState { if (comptime !build_options.wayland) return null; @@ -66,6 +71,32 @@ pub const SurfaceState = struct { } pub fn deinit(self: *SurfaceState) void { + if (self.blur_token) |blur| blur.release(); + } + + pub fn setBlur(self: *SurfaceState, blurred: bool) !void { + log.debug("setting blur={}", .{blurred}); + + const mgr = self.app_state.blur_manager orelse { + log.warn("can't set blur: org_kde_kwin_blur_manager protocol unavailable", .{}); + return; + }; + + if (self.blur_token) |blur| { + // Only release token when transitioning from blurred -> not blurred + if (!blurred) { + mgr.unset(self.surface); + blur.release(); + self.blur_token = null; + } + } else { + // Only acquire token when transitioning from not blurred -> blurred + if (blurred) { + const blur_token = try mgr.create(self.surface); + blur_token.commit(); + self.blur_token = blur_token; + } + } } }; @@ -73,6 +104,10 @@ fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, state: *Ap switch (event) { .global => |global| { log.debug("got global interface={s}", .{global.interface}); + if (bindInterface(org.KdeKwinBlurManager, registry, global, 1)) |iface| { + state.blur_manager = iface; + return; + } }, .global_remove => {}, } diff --git a/src/config/Config.zig b/src/config/Config.zig index e31b9c5e9..7c25d5095 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -583,11 +583,27 @@ palette: Palette = .{}, @"background-opacity": f64 = 1.0, /// A positive value enables blurring of the background when background-opacity -/// is less than 1. The value is the blur radius to apply. A value of 20 +/// is less than 1. +/// +/// On macOS, the value is the blur radius to apply. A value of 20 /// is reasonable for a good looking blur. Higher values will cause strange /// rendering issues as well as performance issues. /// -/// This is only supported on macOS. +/// On KDE Plasma under Wayland, the exact value is _ignored_ — the reason is +/// that KWin, the window compositor powering Plasma, only has one global blur +/// setting and does not allow applications to have individual blur settings. +/// +/// To configure KWin's global blur setting, open System Settings and go to +/// "Apps & Windows" > "Window Management" > "Desktop Effects" and select the +/// "Blur" plugin. If disabled, enable it by ticking the checkbox to the left. +/// Then click on the "Configure" button and there will be two sliders that +/// allow you to set background blur and noise strengths for all apps, +/// including Ghostty. +/// +/// All other Linux desktop environments are as of now unsupported. Users may +/// need to set environment-specific settings and/or install third-party plugins +/// in order to support background blur, as there isn't a unified interface for +/// doing so. @"background-blur-radius": u8 = 0, /// The opacity level (opposite of transparency) of an unfocused split. From cd90821b937ce4f9619eac2e5606012b155cefe0 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 2 Jan 2025 21:44:16 +0800 Subject: [PATCH 040/238] fix(gtk): adjust `background` CSS class dynamically on config reload Currently the `background` CSS class is added once on startup and never removed or re-added. This is problematic as that if Ghostty was started with an opaque window but then its config was reloaded with a `background-opacity` less than 1, the window won't actually become translucent, and it would only appear as if the background colors had become faded (because the window is still styled to be opaque). --- src/apprt/gtk/Window.zig | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 26598d03a..554584127 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -119,11 +119,6 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window-theme-ghostty"); } - // Remove the window's background if any of the widgets need to be transparent - if (app.config.@"background-opacity" < 1) { - c.gtk_widget_remove_css_class(@ptrCast(window), "background"); - } - // Create our box which will hold our widgets in the main content area. const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); @@ -398,6 +393,12 @@ pub fn init(self: *Window, app: *App) !void { /// TODO: Many of the initial style settings in `create` could possibly be made /// reactive by moving them here. pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { + if (config.@"background-opacity" < 1) { + c.gtk_widget_remove_css_class(@ptrCast(self.window), "background"); + } else { + c.gtk_widget_add_css_class(@ptrCast(self.window), "background"); + } + if (self.wayland) |*wl| { try wl.setBlur(config.@"background-blur-radius" > 0); } From f2c357a2099420043edcb26b38b142ff3da0259f Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sat, 4 Jan 2025 14:11:35 +0800 Subject: [PATCH 041/238] config: allow booleans for `background-blur-radius` --- src/apprt/embedded.zig | 2 +- src/apprt/gtk/Window.zig | 7 +++- src/cli/args.zig | 2 +- src/config/Config.zig | 91 +++++++++++++++++++++++++++++++++++----- src/config/c_get.zig | 38 +++++++++++++++++ 5 files changed, 127 insertions(+), 13 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 10d09988d..50d1e90e4 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1953,7 +1953,7 @@ pub const CAPI = struct { _ = CGSSetWindowBackgroundBlurRadius( CGSDefaultConnectionForThread(), nswindow.msgSend(usize, objc.sel("windowNumber"), .{}), - @intCast(config.@"background-blur-radius"), + @intCast(config.@"background-blur-radius".cval()), ); } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 554584127..430a46f61 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -400,7 +400,12 @@ pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { } if (self.wayland) |*wl| { - try wl.setBlur(config.@"background-blur-radius" > 0); + const blurred = switch (config.@"background-blur-radius") { + .false => false, + .true => true, + .value => |v| v > 0, + }; + try wl.setBlur(blurred); } } diff --git a/src/cli/args.zig b/src/cli/args.zig index be71b9096..23dcf7733 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -533,7 +533,7 @@ fn parsePackedStruct(comptime T: type, v: []const u8) !T { return result; } -fn parseBool(v: []const u8) !bool { +pub fn parseBool(v: []const u8) !bool { const t = &[_][]const u8{ "1", "t", "T", "true" }; const f = &[_][]const u8{ "0", "f", "F", "false" }; diff --git a/src/config/Config.zig b/src/config/Config.zig index 7c25d5095..60f396d62 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -582,29 +582,38 @@ palette: Palette = .{}, /// On macOS, changing this configuration requires restarting Ghostty completely. @"background-opacity": f64 = 1.0, -/// A positive value enables blurring of the background when background-opacity -/// is less than 1. +/// Whether to blur the background when `background-opacity` is less than 1. /// -/// On macOS, the value is the blur radius to apply. A value of 20 -/// is reasonable for a good looking blur. Higher values will cause strange -/// rendering issues as well as performance issues. +/// Valid values are: /// -/// On KDE Plasma under Wayland, the exact value is _ignored_ — the reason is -/// that KWin, the window compositor powering Plasma, only has one global blur -/// setting and does not allow applications to have individual blur settings. +/// * a nonnegative integer specifying the *blur intensity* +/// * `false`, equivalent to a blur intensity of 0 +/// * `true`, equivalent to the default blur intensity of 20, which is +/// reasonable for a good looking blur. Higher blur intensities may +/// cause strange rendering and performance issues. +/// +/// Supported on macOS and on some Linux desktop environments, including: +/// +/// * KDE Plasma (Wayland only) +/// +/// Warning: the exact blur intensity is _ignored_ under KDE Plasma, and setting +/// this setting to either `true` or any positive blur intensity value would +/// achieve the same effect. The reason is that KWin, the window compositor +/// powering Plasma, only has one global blur setting and does not allow +/// applications to specify individual blur settings. /// /// To configure KWin's global blur setting, open System Settings and go to /// "Apps & Windows" > "Window Management" > "Desktop Effects" and select the /// "Blur" plugin. If disabled, enable it by ticking the checkbox to the left. /// Then click on the "Configure" button and there will be two sliders that -/// allow you to set background blur and noise strengths for all apps, +/// allow you to set background blur and noise intensities for all apps, /// including Ghostty. /// /// All other Linux desktop environments are as of now unsupported. Users may /// need to set environment-specific settings and/or install third-party plugins /// in order to support background blur, as there isn't a unified interface for /// doing so. -@"background-blur-radius": u8 = 0, +@"background-blur-radius": BackgroundBlur = .false, /// The opacity level (opposite of transparency) of an unfocused split. /// Unfocused splits by default are slightly faded out to make it easier to see @@ -5653,6 +5662,68 @@ pub const AutoUpdate = enum { download, }; +/// See background-blur-radius +pub const BackgroundBlur = union(enum) { + false, + true, + value: u8, + + pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void { + const input_ = input orelse { + // Emulate behavior for bools + self.* = .true; + return; + }; + + if (cli.args.parseBool(input_)) |b| { + self.* = if (b) .true else .false; + } else |_| { + const value = std.fmt.parseInt(u8, input_, 0) catch return error.InvalidValue; + self.* = .{ .value = value }; + } + } + + pub fn cval(self: BackgroundBlur) u8 { + return switch (self) { + .false => 0, + .true => 20, + .value => |v| v, + }; + } + + pub fn formatEntry( + self: BackgroundBlur, + formatter: anytype, + ) !void { + switch (self) { + .false => try formatter.formatEntry(bool, false), + .true => try formatter.formatEntry(bool, true), + .value => |v| try formatter.formatEntry(u8, v), + } + } + + test "parse BackgroundBlur" { + const testing = std.testing; + var v: BackgroundBlur = undefined; + + try v.parseCLI(null); + try testing.expectEqual(.true, v); + + try v.parseCLI("true"); + try testing.expectEqual(.true, v); + + try v.parseCLI("false"); + try testing.expectEqual(.false, v); + + try v.parseCLI("42"); + try testing.expectEqual(42, v.value); + + try testing.expectError(error.InvalidValue, v.parseCLI("")); + try testing.expectError(error.InvalidValue, v.parseCLI("aaaa")); + try testing.expectError(error.InvalidValue, v.parseCLI("420")); + } +}; + /// See theme pub const Theme = struct { light: []const u8, diff --git a/src/config/c_get.zig b/src/config/c_get.zig index d3f38415e..5b0db2531 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -84,6 +84,17 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { ptr.* = @intCast(@as(Backing, @bitCast(value))); }, + .Union => |_| { + if (@hasDecl(T, "cval")) { + const PtrT = @typeInfo(@TypeOf(T.cval)).Fn.return_type.?; + const ptr: *PtrT = @ptrCast(@alignCast(ptr_raw)); + ptr.* = value.cval(); + return true; + } + + return false; + }, + else => return false, }, } @@ -172,3 +183,30 @@ test "c_get: optional" { try testing.expectEqual(0, cval.b); } } + +test "c_get: background-blur" { + const testing = std.testing; + const alloc = testing.allocator; + + var c = try Config.default(alloc); + defer c.deinit(); + + { + c.@"background-blur-radius" = .false; + var cval: u8 = undefined; + try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval))); + try testing.expectEqual(0, cval); + } + { + c.@"background-blur-radius" = .true; + var cval: u8 = undefined; + try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval))); + try testing.expectEqual(20, cval); + } + { + c.@"background-blur-radius" = .{ .value = 42 }; + var cval: u8 = undefined; + try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval))); + try testing.expectEqual(42, cval); + } +} From 0ae8d9ed4211e1f1d795bb1f1256845482b0bff6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Jan 2025 12:30:10 -0800 Subject: [PATCH 042/238] nix: update hash --- nix/zigCacheHash.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 0523f8e96..f2592adf4 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-l+tZVL18qhm8BoBsQVbKfYmXQVObD0QMzQe6VBM/8Oo=" +"sha256-eUY6MS3//r6pA/w9b+E4+YqmqUbzpUfL3afJJlnMhLY=" From bb83a14d7a764bcc5061c7c475878ad3ff68b0f3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Jan 2025 12:35:41 -0800 Subject: [PATCH 043/238] config: minor config changes --- src/apprt/gtk/Window.zig | 2 +- src/config/Config.zig | 22 ++++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 430a46f61..63ee57d95 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -403,7 +403,7 @@ pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { const blurred = switch (config.@"background-blur-radius") { .false => false, .true => true, - .value => |v| v > 0, + .radius => |v| v > 0, }; try wl.setBlur(blurred); } diff --git a/src/config/Config.zig b/src/config/Config.zig index 60f396d62..01cb924fc 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5666,7 +5666,7 @@ pub const AutoUpdate = enum { pub const BackgroundBlur = union(enum) { false, true, - value: u8, + radius: u8, pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void { const input_ = input orelse { @@ -5675,19 +5675,21 @@ pub const BackgroundBlur = union(enum) { return; }; - if (cli.args.parseBool(input_)) |b| { - self.* = if (b) .true else .false; - } else |_| { - const value = std.fmt.parseInt(u8, input_, 0) catch return error.InvalidValue; - self.* = .{ .value = value }; - } + self.* = if (cli.args.parseBool(input_)) |b| + if (b) .true else .false + else |_| + .{ .radius = std.fmt.parseInt( + u8, + input_, + 0, + ) catch return error.InvalidValue }; } pub fn cval(self: BackgroundBlur) u8 { return switch (self) { .false => 0, .true => 20, - .value => |v| v, + .radius => |v| v, }; } @@ -5698,7 +5700,7 @@ pub const BackgroundBlur = union(enum) { switch (self) { .false => try formatter.formatEntry(bool, false), .true => try formatter.formatEntry(bool, true), - .value => |v| try formatter.formatEntry(u8, v), + .radius => |v| try formatter.formatEntry(u8, v), } } @@ -5716,7 +5718,7 @@ pub const BackgroundBlur = union(enum) { try testing.expectEqual(.false, v); try v.parseCLI("42"); - try testing.expectEqual(42, v.value); + try testing.expectEqual(42, v.radius); try testing.expectError(error.InvalidValue, v.parseCLI("")); try testing.expectError(error.InvalidValue, v.parseCLI("aaaa")); From ce77b91bf67f4119783bc5b8ed8ccb3e74b35f6f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Jan 2025 12:37:51 -0800 Subject: [PATCH 044/238] nix fmt --- nix/package.nix | 2 -- 1 file changed, 2 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 1155b76b6..166a3c4fb 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -22,13 +22,11 @@ pandoc, revision ? "dirty", optimize ? "Debug", - enableX11 ? true, libX11, libXcursor, libXi, libXrandr, - enableWayland ? true, wayland, wayland-protocols, From 2fbe680aedc14b6272fe4221af0bb851d0afc0bd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Jan 2025 12:38:20 -0800 Subject: [PATCH 045/238] config: fix tests --- src/config/c_get.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/c_get.zig b/src/config/c_get.zig index 5b0db2531..6804b0ae0 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -204,7 +204,7 @@ test "c_get: background-blur" { try testing.expectEqual(20, cval); } { - c.@"background-blur-radius" = .{ .value = 42 }; + c.@"background-blur-radius" = .{ .radius = 42 }; var cval: u8 = undefined; try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval))); try testing.expectEqual(42, cval); From 057b196024b175fa88df513091f1f69489e023d6 Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Sun, 5 Jan 2025 21:50:20 +0100 Subject: [PATCH 046/238] docs: improve terminal page list documentation --- src/terminal/PageList.zig | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 5fb49ea66..260733b94 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3281,7 +3281,7 @@ fn markDirty(self: *PageList, pt: point.Point) void { /// point remains valid even through scrolling without any additional work. /// /// A downside is that the pin is only valid until the pagelist is modified -/// in a way that may invalid page pointers or shuffle rows, such as resizing, +/// in a way that may invalidate page pointers or shuffle rows, such as resizing, /// erasing rows, etc. /// /// A pin can also be "tracked" which means that it will be updated as the @@ -3389,9 +3389,9 @@ pub const Pin = struct { else => {}, } - // Never extend cell that has a default background. - // A default background is if there is no background - // on the style OR the explicitly set background + // Never extend a cell that has a default background. + // A default background is applied if there is no background + // on the style or the explicitly set background // matches our default background. const s = self.style(cell); const bg = s.bg(cell, palette) orelse return true; @@ -3486,7 +3486,7 @@ pub const Pin = struct { // If our y is after the top y but we're on the same page // then we're between the top and bottom if our y is less - // than or equal to the bottom y IF its the same page. If the + // than or equal to the bottom y if its the same page. If the // bottom is another page then it means that the range is // at least the full top page and since we're the same page // we're in the range. @@ -3508,7 +3508,7 @@ pub const Pin = struct { if (self.y > bottom.y) return false; if (self.y < bottom.y) return true; - // If our y is the same then we're between if we're before + // If our y is the same, then we're between if we're before // or equal to the bottom x. assert(self.y == bottom.y); return self.x <= bottom.x; From ed221f32fe8ae30f9109962f2f75e24248a0cc96 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Jan 2025 13:30:33 -0800 Subject: [PATCH 047/238] macos: ignore modifier changes while IM is active Fixes #4634 --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 4e0550cc2..cf4357a8c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -945,6 +945,9 @@ extension Ghostty { default: return } + // If we're in the middle of a preedit, don't do anything with mods. + if hasMarkedText() { return } + // The keyAction function will do this AGAIN below which sucks to repeat // but this is super cheap and flagsChanged isn't that common. let mods = Ghostty.ghosttyMods(event.modifierFlags) From f6d85baadb5cc1284241cf546cf5e1cb0b12bc0e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Jan 2025 13:57:41 -0800 Subject: [PATCH 048/238] config: store non-reproducible diagnostics in replay steps Fixes #4509 Our config has a replay system so that we can make changes and reproduce the configuration as if we were reloading all the files. This is useful because it lets us "reload" the config under various conditions (system theme change, etc.) without risking failures due to world state changing (i.e. config files change or disappear). The replay system assumed that all diagnostics were reproducible, but this is not the case. For example, we don't reload `config-file` so we can't reproduce diagnostics that come from it. This commit adds a new `diagnostic` replay step that can be used to store non-reproducible diagnostics and `config-file` is updated to use it. --- src/config/Config.zig | 48 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index b0580cf20..6cd6ad75e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3080,25 +3080,31 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { // We must only load a unique file once if (try loaded.fetchPut(path, {}) != null) { - try self._diagnostics.append(arena_alloc, .{ + const diag: cli.Diagnostic = .{ .message = try std.fmt.allocPrintZ( arena_alloc, "config-file {s}: cycle detected", .{path}, ), - }); + }; + + try self._diagnostics.append(arena_alloc, diag); + try self._replay_steps.append(arena_alloc, .{ .diagnostic = diag }); continue; } var file = std.fs.openFileAbsolute(path, .{}) catch |err| { if (err != error.FileNotFound or !optional) { - try self._diagnostics.append(arena_alloc, .{ + const diag: cli.Diagnostic = .{ .message = try std.fmt.allocPrintZ( arena_alloc, "error opening config-file {s}: {}", .{ path, err }, ), - }); + }; + + try self._diagnostics.append(arena_alloc, diag); + try self._replay_steps.append(arena_alloc, .{ .diagnostic = diag }); } continue; }; @@ -3108,13 +3114,16 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { switch (stat.kind) { .file => {}, else => |kind| { - try self._diagnostics.append(arena_alloc, .{ + const diag: cli.Diagnostic = .{ .message = try std.fmt.allocPrintZ( arena_alloc, "config-file {s}: not reading because file type is {s}", .{ path, @tagName(kind) }, ), - }); + }; + + try self._diagnostics.append(arena_alloc, diag); + try self._replay_steps.append(arena_alloc, .{ .diagnostic = diag }); continue; }, } @@ -3264,7 +3273,7 @@ fn loadTheme(self: *Config, theme: Theme) !void { // Setup our replay to be conditional. conditional: for (new_config._replay_steps.items) |*item| { switch (item.*) { - .expand => {}, + .expand, .diagnostic => {}, // If we see "-e" then we do NOT make the following arguments // conditional since they are supposed to be part of the @@ -3816,6 +3825,16 @@ const Replay = struct { arg: []const u8, }, + /// A diagnostic to be added to the new configuration when + /// replayed. This should only be used for diagnostics that won't + /// be reproduced during playback. For example, `config-file` + /// errors are not reloaded so they should be added here. + /// + /// Diagnostics cannot be conditional. They are always present + /// even if the conditionals don't match. This helps users find + /// errors in their configuration. + diagnostic: cli.Diagnostic, + /// The start of a "-e" argument. This marks the end of /// traditional configuration and the beginning of the /// "-e" initial command magic. This is separate from "arg" @@ -3832,6 +3851,7 @@ const Replay = struct { ) Allocator.Error!Step { return switch (self) { .@"-e" => self, + .diagnostic => |v| .{ .diagnostic = try v.clone(alloc) }, .arg => |v| .{ .arg = try alloc.dupe(u8, v) }, .expand => |v| .{ .expand = try alloc.dupe(u8, v) }, .conditional_arg => |v| conditional: { @@ -3867,6 +3887,20 @@ const Replay = struct { log.warn("error expanding paths err={}", .{err}); }, + .diagnostic => |diag| diag: { + // Best effort to clone and append the diagnostic. + // If it fails we log a warning and continue. + const arena_alloc = self.config._arena.?.allocator(); + const cloned = diag.clone(arena_alloc) catch |err| { + log.warn("error cloning diagnostic err={}", .{err}); + break :diag; + }; + self.config._diagnostics.append(arena_alloc, cloned) catch |err| { + log.warn("error appending diagnostic err={}", .{err}); + break :diag; + }; + }, + .conditional_arg => |v| conditional: { // All conditions must match. for (v.conditions) |cond| { From 94bf448eda7e3855e5973acd0674550458a08c4e Mon Sep 17 00:00:00 2001 From: Beau McCartney Date: Sun, 5 Jan 2025 17:08:52 -0700 Subject: [PATCH 049/238] just reset makeprg and errorformat --- src/config/vim.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/vim.zig b/src/config/vim.zig index 00836cd40..62255bd79 100644 --- a/src/config/vim.zig +++ b/src/config/vim.zig @@ -26,7 +26,7 @@ pub const ftplugin = \\ \\if !exists('current_compiler') \\ compiler ghostty - \\ let b:undo_ftplugin .= "| compiler make" + \\ let b:undo_ftplugin .= " makeprg< errorformat<" \\endif \\ ; From 781159af7d3b21f0b4d884ecaea94f0364b9358a Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Mon, 6 Jan 2025 14:53:57 +0100 Subject: [PATCH 050/238] don't error if gtk4 pkg-config configuration could not be found this prevents issues with people running `zig build --help` without gtk4 installed same as #4546 --- build.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/build.zig b/build.zig index 6f80a8961..7f0bf84c5 100644 --- a/build.zig +++ b/build.zig @@ -145,7 +145,6 @@ pub fn build(b: *std.Build) !void { if (std.mem.indexOf(u8, stdout.items, "wayland")) |_| wayland = true; } else { std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); - return error.Unexpected; } }, inline else => |code| { From 63a47d0ba573fea37a6d96630aba1a7e466f9e93 Mon Sep 17 00:00:00 2001 From: yonihemi <2340723+yonihemi@users.noreply.github.com> Date: Mon, 6 Jan 2025 22:01:41 +0800 Subject: [PATCH 051/238] iOS: Fix crash on device --- src/renderer/Metal.zig | 35 +++++++++++++++++++++++------------ src/renderer/metal/api.zig | 4 ++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 75e61ebc0..5ad45da8e 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -209,20 +209,31 @@ pub const GPUState = struct { } fn chooseDevice() error{NoMetalDevice}!objc.Object { - const devices = objc.Object.fromId(mtl.MTLCopyAllDevices()); - defer devices.release(); var chosen_device: ?objc.Object = null; - var iter = devices.iterate(); - while (iter.next()) |device| { - // We want a GPU that’s connected to a display. - if (device.getProperty(bool, "isHeadless")) continue; - chosen_device = device; - // If the user has an eGPU plugged in, they probably want - // to use it. Otherwise, integrated GPUs are better for - // battery life and thermals. - if (device.getProperty(bool, "isRemovable") or - device.getProperty(bool, "isLowPower")) break; + + switch (comptime builtin.os.tag) { + .macos => { + const devices = objc.Object.fromId(mtl.MTLCopyAllDevices()); + defer devices.release(); + + var iter = devices.iterate(); + while (iter.next()) |device| { + // We want a GPU that’s connected to a display. + if (device.getProperty(bool, "isHeadless")) continue; + chosen_device = device; + // If the user has an eGPU plugged in, they probably want + // to use it. Otherwise, integrated GPUs are better for + // battery life and thermals. + if (device.getProperty(bool, "isRemovable") or + device.getProperty(bool, "isLowPower")) break; + } + }, + .ios => { + chosen_device = objc.Object.fromId(mtl.MTLCreateSystemDefaultDevice()); + }, + else => @compileError("unsupported target for Metal"), } + const device = chosen_device orelse return error.NoMetalDevice; return device.retain(); } diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index bd4f407cd..48056ae5e 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -175,4 +175,8 @@ pub const MTLSize = extern struct { depth: c_ulong, }; +/// https://developer.apple.com/documentation/metal/1433367-mtlcopyalldevices pub extern "c" fn MTLCopyAllDevices() ?*anyopaque; + +/// https://developer.apple.com/documentation/metal/1433401-mtlcreatesystemdefaultdevice +pub extern "c" fn MTLCreateSystemDefaultDevice() ?*anyopaque; From 3698b37588a99af6b6664cb23caeece743d528e4 Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Sun, 17 Nov 2024 13:38:44 -0600 Subject: [PATCH 052/238] apprt/gtk: use a subtitle to mark the current working directory If the title is already the current working directory, hide the subtitle. Otherwise show the current working directory, like if a command is running for instance. Signed-off-by: Tristan Partin --- src/apprt/gtk/Surface.zig | 32 ++++++++++++++++++++++++++++++-- src/apprt/gtk/Window.zig | 16 ++++++++++++++++ src/apprt/gtk/headerbar.zig | 27 +++++++++++++++++++++++++-- src/apprt/gtk/notebook_adw.zig | 2 +- src/apprt/gtk/notebook_gtk.zig | 2 +- src/config/Config.zig | 14 ++++++++++++++ 6 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 056a3f40b..180f986ca 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -347,6 +347,11 @@ cursor: ?*c.GdkCursor = null, /// pass it to GTK. title_text: ?[: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.guint = null, @@ -628,6 +633,7 @@ fn realize(self: *Surface) !void { 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.pwd) |pwd| self.app.core_app.alloc.free(pwd); // We don't allocate anything if we aren't realized. if (!self.realized) return; @@ -876,7 +882,7 @@ fn updateTitleLabels(self: *Surface) void { // 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... - c.gtk_window_set_title(window.window, title.ptr); + window.setTitle(title); } } } @@ -929,11 +935,27 @@ pub fn getTitle(self: *Surface) ?[:0]const u8 { return null; } +/// 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 we have a tab and are the focused child, then we have to update the tab 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( @@ -1896,6 +1918,12 @@ fn gtkFocusEnter(_: *c.GtkEventControllerFocus, ud: ?*anyopaque) callconv(.C) vo 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}); diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 63ee57d95..c2c69e281 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -453,6 +453,22 @@ pub fn deinit(self: *Window) void { } } +/// Set the title of the window. +pub fn setTitle(self: *Window, title: [:0]const u8) void { + if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config) and self.app.config.@"gtk-titlebar") { + if (self.header) |header| header.setTitle(title); + } else { + c.gtk_window_set_title(self.window, title); + } +} + +/// Set the subtitle of the window if it has one. +pub fn setSubtitle(self: *Window, subtitle: [:0]const u8) void { + if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config) and self.app.config.@"gtk-titlebar") { + if (self.header) |header| header.setSubtitle(subtitle); + } +} + /// Add a new tab to this window. pub fn newTab(self: *Window, parent: ?*CoreSurface) !void { const alloc = self.app.core_app.alloc; diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig index 5bb92aca2..97c48a4c2 100644 --- a/src/apprt/gtk/headerbar.zig +++ b/src/apprt/gtk/headerbar.zig @@ -14,14 +14,15 @@ pub const HeaderBar = union(enum) { if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&window.app.config)) { - return initAdw(); + return initAdw(window); } return initGtk(); } - fn initAdw() HeaderBar { + fn initAdw(window: *Window) HeaderBar { const headerbar = c.adw_header_bar_new(); + c.adw_header_bar_set_title_widget(@ptrCast(headerbar), @ptrCast(c.adw_window_title_new(c.gtk_window_get_title(window.window) orelse "Ghostty", null))); return .{ .adw = @ptrCast(headerbar) }; } @@ -70,4 +71,26 @@ pub const HeaderBar = union(enum) { ), } } + + pub fn setTitle(self: HeaderBar, title: [:0]const u8) void { + switch (self) { + .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { + const window_title: *c.AdwWindowTitle = @ptrCast(c.adw_header_bar_get_title_widget(@ptrCast(headerbar))); + c.adw_window_title_set_title(window_title, title); + }, + // The title is owned by the window when not using Adwaita + .gtk => unreachable, + } + } + + pub fn setSubtitle(self: HeaderBar, subtitle: [:0]const u8) void { + switch (self) { + .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { + const window_title: *c.AdwWindowTitle = @ptrCast(c.adw_header_bar_get_title_widget(@ptrCast(headerbar))); + c.adw_window_title_set_subtitle(window_title, subtitle); + }, + // There is no subtitle unless Adwaita is used + .gtk => unreachable, + } + } }; diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig index 85083a97e..48f005467 100644 --- a/src/apprt/gtk/notebook_adw.zig +++ b/src/apprt/gtk/notebook_adw.zig @@ -159,5 +159,5 @@ fn adwSelectPage(_: *c.GObject, _: *c.GParamSpec, ud: ?*anyopaque) void { const window: *Window = @ptrCast(@alignCast(ud.?)); const page = c.adw_tab_view_get_selected_page(window.notebook.adw.tab_view) orelse return; const title = c.adw_tab_page_get_title(page); - c.gtk_window_set_title(window.window, title); + window.setTitle(std.mem.span(title)); } diff --git a/src/apprt/gtk/notebook_gtk.zig b/src/apprt/gtk/notebook_gtk.zig index 6e8b016ba..a2c482500 100644 --- a/src/apprt/gtk/notebook_gtk.zig +++ b/src/apprt/gtk/notebook_gtk.zig @@ -259,7 +259,7 @@ fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaqu const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(self.notebook, page))); const gtk_label = @as(*c.GtkLabel, @ptrCast(c.gtk_widget_get_first_child(gtk_label_box))); const label_text = c.gtk_label_get_text(gtk_label); - c.gtk_window_set_title(window.window, label_text); + window.setTitle(std.mem.span(label_text)); } fn gtkNotebookCreateWindow( diff --git a/src/config/Config.zig b/src/config/Config.zig index 6cd6ad75e..6d2c026fe 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1118,6 +1118,15 @@ keybind: Keybinds = .{}, /// required to be a fixed-width font. @"window-title-font-family": ?[:0]const u8 = null, +/// The text that will be displayed in the subtitle of the window. Valid values: +/// +/// * `false` - Disable the subtitle. +/// * `working-directory` - Set the subtitle to the working directory of the +/// surface. +/// +/// This feature is only supported on GTK with Adwaita enabled. +@"window-subtitle": WindowSubtitle = .false, + /// The theme to use for the windows. Valid values: /// /// * `auto` - Determine the theme based on the configured terminal @@ -3968,6 +3977,11 @@ pub const WindowPaddingColor = enum { @"extend-always", }; +pub const WindowSubtitle = enum { + false, + @"working-directory", +}; + /// Color represents a color using RGB. /// /// This is a packed struct so that the C API to read color values just From ae0248b5bcebeb559e9dda1e787bf279b4f9b8de Mon Sep 17 00:00:00 2001 From: Evelyn Harthbrooke Date: Sun, 5 Jan 2025 21:42:27 -0700 Subject: [PATCH 053/238] macOS: Add Bluetooth permission description; fixup other descs. Adds the missing Bluetooth permission description to ghostty's Xcode project description, and fixes up existing permissions to be clearer. Closes #3995 and #4512. --- macos/Ghostty.xcodeproj/project.pbxproj | 81 +++++++++++++------------ 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 1e37006c2..377b80a28 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -777,21 +777,22 @@ INFOPLIST_FILE = "Ghostty-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Ghostty; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript."; - INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar."; - INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera."; - INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts."; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth."; + INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar."; + INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera."; + INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network."; - INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily."; - INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network."; + INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily."; + INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information."; INFOPLIST_KEY_NSMainNibFile = MainMenu; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone."; - INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library."; - INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders."; - INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition."; - INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone."; + INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to use your Photos Library."; + INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; + INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -946,21 +947,22 @@ INFOPLIST_FILE = "Ghostty-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Ghostty; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript."; - INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar."; - INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera."; - INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts."; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth."; + INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar."; + INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera."; + INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network."; - INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily."; - INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network."; + INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily."; + INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information."; INFOPLIST_KEY_NSMainNibFile = MainMenu; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone."; - INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library."; - INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders."; - INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition."; - INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone."; + INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to use your Photos Library."; + INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; + INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -999,21 +1001,22 @@ INFOPLIST_FILE = "Ghostty-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Ghostty; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript."; - INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar."; - INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera."; - INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts."; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program running within Ghostty would like to use AppleScript."; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "A program running within Ghostty would like to use Bluetooth."; + INFOPLIST_KEY_NSCalendarsUsageDescription = "A program running within Ghostty would like to access your Calendar."; + INFOPLIST_KEY_NSCameraUsageDescription = "A program running within Ghostty would like to use the camera."; + INFOPLIST_KEY_NSContactsUsageDescription = "A program running within Ghostty would like to access your Contacts."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network."; - INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily."; - INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program running within Ghostty would like to access the local network."; + INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program running within Ghostty would like to use your location temporarily."; + INFOPLIST_KEY_NSLocationUsageDescription = "A program running within Ghostty would like to access your location information."; INFOPLIST_KEY_NSMainNibFile = MainMenu; - INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone."; - INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library."; - INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders."; - INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition."; - INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges."; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone."; + INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to use your Photos Library."; + INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; + INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", From c127daa552e2ba2bf17163b16159775e883f7090 Mon Sep 17 00:00:00 2001 From: George Joseph Date: Mon, 6 Jan 2025 07:13:51 -0700 Subject: [PATCH 054/238] Fix minimum initial window size Change the calculation of minimum initial window size so it agrees with the documented 10x4 cells instead of 640x480 px. Resolves: #4655 --- src/Surface.zig | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 1dc10fb27..70c32098f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -569,12 +569,16 @@ pub fn init( // Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app // but is otherwise somewhat arbitrary. + + const min_window_width_cells: u32 = 10; + const min_window_height_cells: u32 = 4; + try rt_app.performAction( .{ .surface = self }, .size_limit, .{ - .min_width = size.cell.width * 10, - .min_height = size.cell.height * 4, + .min_width = size.cell.width * min_window_width_cells, + .min_height = size.cell.height * min_window_height_cells, // No max: .max_width = 0, .max_height = 0, @@ -617,8 +621,8 @@ pub fn init( // start messing with the window. if (config.@"window-height" > 0 and config.@"window-width" > 0) init: { const scale = rt_surface.getContentScale() catch break :init; - const height = @max(config.@"window-height" * cell_size.height, 480); - const width = @max(config.@"window-width" * cell_size.width, 640); + const height = @max(config.@"window-height", min_window_height_cells) * cell_size.height; + const width = @max(config.@"window-width", min_window_width_cells) * cell_size.width; const width_f32: f32 = @floatFromInt(width); const height_f32: f32 = @floatFromInt(height); From dc4774c14727912f17fe790789bf4988a79baa88 Mon Sep 17 00:00:00 2001 From: Evelyn Harthbrooke Date: Mon, 6 Jan 2025 07:13:53 -0700 Subject: [PATCH 055/238] macOS: fixup Photo Library description to make more sense. --- macos/Ghostty.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 377b80a28..fded20911 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -789,7 +789,7 @@ INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone."; INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to use your Photos Library."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library."; INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; @@ -959,7 +959,7 @@ INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone."; INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to use your Photos Library."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library."; INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; @@ -1013,7 +1013,7 @@ INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program running within Ghostty would like to use your microphone."; INFOPLIST_KEY_NSMotionUsageDescription = "A program running within Ghostty would like to access motion data."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to use your Photos Library."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program running within Ghostty would like to access your Photo Library."; INFOPLIST_KEY_NSRemindersUsageDescription = "A program running within Ghostty would like to access your reminders."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program running within Ghostty would like to use speech recognition."; INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program running within Ghostty requires elevated privileges."; From f0c2d3d75a59004457e7c18e0075bd4df7b19d6a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jan 2025 07:01:44 -0800 Subject: [PATCH 056/238] macos: fix retain cycle preventing window from freeing --- macos/Sources/App/macOS/AppDelegate.swift | 16 ++++++++-------- .../Features/Terminal/TerminalWindow.swift | 6 +++++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 70873236a..1a23eca90 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -424,35 +424,35 @@ class AppDelegate: NSObject, // If we have a main window then we don't process any of the keys // because we let it capture and propagate. guard NSApp.mainWindow == nil else { return event } - + // If this event as-is would result in a key binding then we send it. if let app = ghostty.app, ghostty_app_key_is_binding( - app, - event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { + app, + event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { ghostty_app_key(app, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) return nil } - + // If this event would be handled by our menu then we do nothing. if let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) { return nil } - + // If we reach this point then we try to process the key event // through the Ghostty key mechanism. - + // Ghostty must be loaded guard let ghostty = self.ghostty.app else { return event } - + // Build our event input and call ghostty if (ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) { // The key was used so we want to stop it from going to our Mac app Ghostty.logger.debug("local key event handled event=\(event)") return nil } - + return event } diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 35f629bfd..0eb8daeeb 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -667,12 +667,16 @@ fileprivate class WindowDragView: NSView { // A view that matches the color of selected and unselected tabs in the adjacent tab bar. fileprivate class WindowButtonsBackdropView: NSView { - private let terminalWindow: TerminalWindow + // This must be weak because the window has this view. Otherwise + // a retain cycle occurs. + private weak var terminalWindow: TerminalWindow? private let isLightTheme: Bool private let overlayLayer = VibrantLayer() var isHighlighted: Bool = true { didSet { + guard let terminalWindow else { return } + if isLightTheme { overlayLayer.isHidden = isHighlighted layer?.backgroundColor = .clear From 237c94139534a3170bcf0c6a59d32a77073c0894 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 6 Jan 2025 10:02:24 -0500 Subject: [PATCH 057/238] bash: narrow the scope of GHOSTTY_BASH_ENV GHOSTTY_BASH_ENV is only set in the '--posix' path. This change is a code organization improvement and doesn't change the script's behavior. --- src/shell-integration/bash/ghostty.bash | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 1cd939659..1e27545b6 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -24,6 +24,7 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then builtin source "$GHOSTTY_BASH_ENV" builtin export ENV="$GHOSTTY_BASH_ENV" fi + builtin unset GHOSTTY_BASH_ENV else # Restore bash's default 'posix' behavior. Also reset 'inherit_errexit', # which doesn't happen as part of the 'posix' reset. @@ -64,7 +65,7 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then fi fi - builtin unset GHOSTTY_BASH_ENV GHOSTTY_BASH_RCFILE + builtin unset GHOSTTY_BASH_RCFILE builtin unset ghostty_bash_inject rcfile fi From ae0c4d927a6a9d2cc19065c6e653da17403d7a2a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jan 2025 07:13:40 -0800 Subject: [PATCH 058/238] macos: halt NSEvent processing at app scope only if event is handled Fixes #4677 --- macos/Sources/App/macOS/AppDelegate.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1a23eca90..e3518cd2b 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -430,8 +430,15 @@ class AppDelegate: NSObject, ghostty_app_key_is_binding( app, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { - ghostty_app_key(app, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) - return nil + // If the key was handled by Ghostty we stop the event chain. If + // the key wasn't handled then we let it fall through and continue + // processing. This is important because some bindings may have no + // affect at this scope. + if (ghostty_app_key( + app, + event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) { + return nil + } } // If this event would be handled by our menu then we do nothing. From 6d90a181cedd71b86eb6efef335a49f430104536 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Jan 2025 10:17:52 -0600 Subject: [PATCH 059/238] core: improve desktop environment detection --- src/os/desktop.zig | 78 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/src/os/desktop.zig b/src/os/desktop.zig index 3a61e2eaa..c73f150e0 100644 --- a/src/os/desktop.zig +++ b/src/os/desktop.zig @@ -60,6 +60,9 @@ pub fn launchedFromDesktop() bool { }; } +/// The list of desktop environments that we detect. New Linux desktop +/// environments should only be added to this list if there's a specific reason +/// to differentiate between `gnome` and `other`. pub const DesktopEnvironment = enum { gnome, macos, @@ -67,21 +70,84 @@ pub const DesktopEnvironment = enum { windows, }; -/// Detect what desktop environment we are running under. This is mainly used on -/// Linux to enable or disable GTK client-side decorations but there may be more -/// uses in the future. +/// Detect what desktop environment we are running under. This is mainly used +/// on Linux to enable or disable certain features but there may be more uses in +/// the future. pub fn desktopEnvironment() DesktopEnvironment { return switch (comptime builtin.os.tag) { .macos => .macos, .windows => .windows, .linux => de: { if (@inComptime()) @compileError("Checking for the desktop environment on Linux must be done at runtime."); - // use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux + + // Use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux // https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop= - const de = posix.getenv("XDG_SESSION_DESKTOP") orelse break :de .other; - if (std.ascii.eqlIgnoreCase("gnome", de)) break :de .gnome; + if (posix.getenv("XDG_SESSION_DESKTOP")) |sd| { + if (std.ascii.eqlIgnoreCase("gnome", sd)) break :de .gnome; + if (std.ascii.eqlIgnoreCase("gnome-xorg", sd)) break :de .gnome; + } + + // If $XDG_SESSION_DESKTOP is not set, or doesn't match any known + // DE, check $XDG_CURRENT_DESKTOP. $XDG_CURRENT_DESKTOP is a + // colon-separated list of up to three desktop names, although we + // only look at the first. + // https://specifications.freedesktop.org/desktop-entry-spec/latest/recognized-keys.html + if (posix.getenv("XDG_CURRENT_DESKTOP")) |cd| { + var cd_it = std.mem.splitScalar(u8, cd, ':'); + const cd_first = cd_it.first(); + if (std.ascii.eqlIgnoreCase(cd_first, "gnome")) break :de .gnome; + } + break :de .other; }, else => .other, }; } + +test "desktop environment" { + const testing = std.testing; + + switch (builtin.os.tag) { + .macos => try testing.expectEqual(.macos, desktopEnvironment()), + .windows => try testing.expectEqual(.windows, desktopEnvironment()), + .linux => { + const getenv = std.posix.getenv; + const setenv = @import("env.zig").setenv; + const unsetenv = @import("env.zig").unsetenv; + + const xdg_current_desktop = getenv("XDG_CURRENT_DESKTOP"); + defer if (xdg_current_desktop) |v| { + _ = setenv("XDG_CURRENT_DESKTOP", v); + } else { + _ = unsetenv("XDG_CURRENT_DESKTOP"); + }; + _ = unsetenv("XDG_CURRENT_DESKTOP"); + + const xdg_session_desktop = getenv("XDG_SESSION_DESKTOP"); + defer if (xdg_session_desktop) |v| { + _ = setenv("XDG_SESSION_DESKTOP", v); + } else { + _ = unsetenv("XDG_SESSION_DESKTOP"); + }; + _ = unsetenv("XDG_SESSION_DESKTOP"); + + _ = setenv("XDG_SESSION_DESKTOP", "gnome"); + try testing.expectEqual(.gnome, desktopEnvironment()); + _ = setenv("XDG_SESSION_DESKTOP", "gnome-xorg"); + try testing.expectEqual(.gnome, desktopEnvironment()); + _ = setenv("XDG_SESSION_DESKTOP", "foobar"); + try testing.expectEqual(.other, desktopEnvironment()); + + _ = unsetenv("XDG_SESSION_DESKTOP"); + try testing.expectEqual(.other, desktopEnvironment()); + + _ = setenv("XDG_CURRENT_DESKTOP", "GNOME"); + try testing.expectEqual(.gnome, desktopEnvironment()); + _ = setenv("XDG_CURRENT_DESKTOP", "FOOBAR"); + try testing.expectEqual(.other, desktopEnvironment()); + _ = unsetenv("XDG_CURRENT_DESKTOP"); + try testing.expectEqual(.other, desktopEnvironment()); + }, + else => try testing.expectEqual(.other, DesktopEnvironment()), + } +} From d3973b8fadea1247d758dea89fc7ac111a24bcbc Mon Sep 17 00:00:00 2001 From: Daniel Fox Date: Tue, 31 Dec 2024 14:18:04 -0800 Subject: [PATCH 060/238] Set the paste button in the GTK dialog as default --- src/apprt/gtk/ClipboardConfirmationWindow.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index a04271497..cfa29f378 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -89,6 +89,8 @@ fn init( const view = try PrimaryView.init(self, data); self.view = view; c.gtk_window_set_child(@ptrCast(window), view.root); + _ = c.gtk_widget_grab_focus(view.buttons.confirm_button); + c.gtk_widget_show(window); // Block the main window from input. @@ -104,6 +106,7 @@ fn gtkDestroy(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { const PrimaryView = struct { root: *c.GtkWidget, text: *c.GtkTextView, + buttons: ButtonsView, pub fn init(root: *ClipboardConfirmation, data: []const u8) !PrimaryView { // All our widgets @@ -135,7 +138,7 @@ const PrimaryView = struct { c.gtk_text_view_set_right_margin(@ptrCast(text), 8); c.gtk_text_view_set_monospace(@ptrCast(text), 1); - return .{ .root = view.root, .text = @ptrCast(text) }; + return .{ .root = view.root, .text = @ptrCast(text), .buttons = buttons }; } /// Returns the GtkTextBuffer for the data that was unsafe. @@ -158,6 +161,7 @@ const PrimaryView = struct { const ButtonsView = struct { root: *c.GtkWidget, + confirm_button: *c.GtkWidget, pub fn init(root: *ClipboardConfirmation) !ButtonsView { const cancel_text, const confirm_text = switch (root.pending_req) { @@ -172,7 +176,7 @@ const ButtonsView = struct { errdefer c.g_object_unref(confirm_button); // TODO: Focus on the paste button - // c.gtk_widget_grab_focus(confirm_button); + _ = c.gtk_widget_grab_focus(confirm_button); // Create our view const view = try View.init(&.{ @@ -198,7 +202,7 @@ const ButtonsView = struct { c.G_CONNECT_DEFAULT, ); - return .{ .root = view.root }; + return .{ .root = view.root, .confirm_button = confirm_button }; } fn gtkCancelClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { From 4fb253a3001b0d142acff69d126d5d030c4f2537 Mon Sep 17 00:00:00 2001 From: Daniel Fox Date: Fri, 3 Jan 2025 11:38:19 -0800 Subject: [PATCH 061/238] Expose clipboard cancel button and focus it --- src/apprt/gtk/ClipboardConfirmationWindow.zig | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index cfa29f378..e0960a0db 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -89,7 +89,7 @@ fn init( const view = try PrimaryView.init(self, data); self.view = view; c.gtk_window_set_child(@ptrCast(window), view.root); - _ = c.gtk_widget_grab_focus(view.buttons.confirm_button); + _ = c.gtk_widget_grab_focus(view.buttons.cancel_button); c.gtk_widget_show(window); @@ -162,6 +162,7 @@ const PrimaryView = struct { const ButtonsView = struct { root: *c.GtkWidget, confirm_button: *c.GtkWidget, + cancel_button: *c.GtkWidget, pub fn init(root: *ClipboardConfirmation) !ButtonsView { const cancel_text, const confirm_text = switch (root.pending_req) { @@ -175,9 +176,6 @@ const ButtonsView = struct { const confirm_button = c.gtk_button_new_with_label(confirm_text); errdefer c.g_object_unref(confirm_button); - // TODO: Focus on the paste button - _ = c.gtk_widget_grab_focus(confirm_button); - // Create our view const view = try View.init(&.{ .{ .name = "cancel", .widget = cancel_button }, @@ -202,7 +200,7 @@ const ButtonsView = struct { c.G_CONNECT_DEFAULT, ); - return .{ .root = view.root, .confirm_button = confirm_button }; + return .{ .root = view.root, .confirm_button = confirm_button, .cancel_button = cancel_button }; } fn gtkCancelClick(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { From d79a02db4424d3b0b11800a6ff6e437552945b52 Mon Sep 17 00:00:00 2001 From: Daniel Fox Date: Mon, 6 Jan 2025 09:25:42 -0800 Subject: [PATCH 062/238] Add destructive/suggested action classes --- src/apprt/gtk/ClipboardConfirmationWindow.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index e0960a0db..cf417b668 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -176,6 +176,9 @@ const ButtonsView = struct { const confirm_button = c.gtk_button_new_with_label(confirm_text); errdefer c.g_object_unref(confirm_button); + c.gtk_widget_add_css_class(confirm_button, "destructive-action"); + c.gtk_widget_add_css_class(cancel_button, "suggested-action"); + // Create our view const view = try View.init(&.{ .{ .name = "cancel", .widget = cancel_button }, From 359c390218025d57a262c7eefb4412c4c249f079 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Jan 2025 09:45:26 -0800 Subject: [PATCH 063/238] config: `unbind` keybind triggers unbinds both translated and physical Fixes #4703 This changes `unbind` so it always removes all keybinds with the given trigger pattern regardless of if it is translated or physical. The previous behavior was technically correct, but this implements the pattern of least surprise. I can't think of a scenario where you really want to be exact about what key you're unbinding. And if that scenario does exist, you can always fix it by rebinding after unbind. --- src/config/Config.zig | 4 +++- src/input/Binding.zig | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6cd6ad75e..e8acb9fea 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -914,7 +914,9 @@ class: ?[:0]const u8 = null, /// /// * `unbind` - Remove the binding. This makes it so the previous action /// is removed, and the key will be sent through to the child command -/// if it is printable. +/// if it is printable. Unbind will remove any matching trigger, +/// including `physical:`-prefixed triggers without specifying the +/// prefix. /// /// * `csi:text` - Send a CSI sequence. i.e. `csi:A` sends "cursor up". /// diff --git a/src/input/Binding.zig b/src/input/Binding.zig index e0ad6c571..64e07e85e 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1528,6 +1528,22 @@ pub const Set = struct { /// Remove a binding for a given trigger. pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void { + // Remove whatever this trigger is + self.removeExact(alloc, t); + + // If we have a physical we remove translated and vice versa. + const alternate: Trigger.Key = switch (t.key) { + .unicode => return, + .translated => |k| .{ .physical = k }, + .physical => |k| .{ .translated = k }, + }; + + var alt_t: Trigger = t; + alt_t.key = alternate; + self.removeExact(alloc, alt_t); + } + + fn removeExact(self: *Set, alloc: Allocator, t: Trigger) void { const entry = self.bindings.get(t) orelse return; _ = self.bindings.remove(t); @@ -1559,7 +1575,7 @@ pub const Set = struct { }, } } else { - // No over trigger points to this action so we remove + // No other trigger points to this action so we remove // the reverse mapping completely. _ = self.reverse.remove(leaf.action); } @@ -2101,6 +2117,24 @@ test "set: parseAndPut removed binding" { try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); } +test "set: parseAndPut removed physical binding" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "physical:a=new_window"); + try s.parseAndPut(alloc, "a=unbind"); + + // Creates forward mapping + { + const trigger: Trigger = .{ .key = .{ .physical = .a } }; + try testing.expect(s.get(trigger) == null); + } + try testing.expect(s.getTrigger(.{ .new_window = {} }) == null); +} + test "set: parseAndPut sequence" { const testing = std.testing; const alloc = testing.allocator; From 15f82858b70e7b0c93cf7b11e2ffd65ae210ed79 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 6 Jan 2025 14:00:38 -0500 Subject: [PATCH 064/238] termio: don't leak VTE_VERSION into child processes This variable is used by gnome-terminal and other VTE-based terminals. We don't want our child processes to think we're running under VTE. --- src/termio/Exec.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 423ebfa28..ec5d3ffc0 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -857,6 +857,8 @@ const Subprocess = struct { } // Don't leak these environment variables to child processes. + env.remove("VTE_VERSION"); + if (comptime build_config.app_runtime == .gtk) { env.remove("GDK_DEBUG"); env.remove("GDK_DISABLE"); From 85743aebd5a5884b85b14bb9ac90ef4485125d36 Mon Sep 17 00:00:00 2001 From: Mustaque Ahmed Date: Tue, 7 Jan 2025 01:06:40 +0530 Subject: [PATCH 065/238] feat: add support for file paths starts with `../` `./` and `/` To implement this I extended the existing regex variable in the `src/config` dir. In a few test cases, extra space is added intentionally to verify we don't include space in the URL as it looks & feels odd. --- src/config/url.zig | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/config/url.zig b/src/config/url.zig index 1d0764736..f5709a383 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -24,7 +24,7 @@ const oni = @import("oniguruma"); /// handling them well requires a non-regex approach. pub const regex = "(?:" ++ url_schemes ++ - \\)(?:[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(? Date: Mon, 6 Jan 2025 15:39:21 -0800 Subject: [PATCH 066/238] terminal: ConEmu OSC9 parsing is more robust and correct Related to #4485 This commit matches ConEmu's parsing logic[^1] more faithfully. For any substate that requires a progress, ConEmu parses so long as there is a number and then just ignores the rest. For substates that don't require a progress, ConEmu literally ignores everything after the state. Tests cover both. [^1]: https://github.com/Maximus5/ConEmu/blob/740b09c363cb16fbb730d72c53eaca1c530a016e/src/ConEmuCD/ConAnsiImpl.cpp#L2264 --- src/terminal/osc.zig | 43 +++++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index e9ab5e1e3..33d753c9f 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -274,6 +274,7 @@ pub const Parser = struct { pub const State = enum { empty, invalid, + swallow, // Command prefixes. We could just accumulate and compare (mem.eql) // but the state space is small enough that we just build it up this way. @@ -451,6 +452,8 @@ pub const Parser = struct { else => self.state = .invalid, }, + .swallow => {}, + .@"0" => switch (c) { ';' => { self.command = .{ .change_window_title = undefined }; @@ -822,7 +825,7 @@ pub const Parser = struct { self.buf_start = self.buf_idx; self.complete = true; self.state = .conemu_sleep_value; - }, + }, else => self.state = .invalid, }, @@ -871,7 +874,7 @@ pub const Parser = struct { .conemu_progress_state => switch (c) { '0' => { self.command.progress.state = .remove; - self.state = .conemu_progress_prevalue; + self.state = .swallow; self.complete = true; }, '1' => { @@ -887,7 +890,7 @@ pub const Parser = struct { '3' => { self.command.progress.state = .indeterminate; self.complete = true; - self.state = .conemu_progress_prevalue; + self.state = .swallow; }, '4' => { self.command.progress.state = .pause; @@ -934,7 +937,10 @@ pub const Parser = struct { } }, - else => self.showDesktopNotification(), + else => { + self.state = .swallow; + self.complete = true; + }, }, .query_fg_color => switch (c) { @@ -1965,18 +1971,18 @@ test "OSC: OSC9 progress set double digit" { try testing.expect(cmd.progress.progress == 94); } -test "OSC: OSC9 progress set extra semicolon triggers desktop notification" { +test "OSC: OSC9 progress set extra semicolon ignored" { const testing = std.testing; var p: Parser = .{}; - const input = "9;4;1;100;"; + const input = "9;4;1;100"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); - try testing.expectEqualStrings(cmd.show_desktop_notification.body, "4;1;100;"); + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .set); + try testing.expect(cmd.progress.progress == 100); } test "OSC: OSC9 progress remove with no progress" { @@ -1993,6 +1999,20 @@ test "OSC: OSC9 progress remove with no progress" { try testing.expect(cmd.progress.progress == null); } +test "OSC: OSC9 progress remove with double semicolon" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;4;0;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .remove); + try testing.expect(cmd.progress.progress == null); +} + test "OSC: OSC9 progress remove ignores progress" { const testing = std.testing; @@ -2016,9 +2036,8 @@ test "OSC: OSC9 progress remove extra semicolon" { for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?; - try testing.expect(cmd == .show_desktop_notification); - try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); - try testing.expectEqualStrings(cmd.show_desktop_notification.body, "4;0;100;"); + try testing.expect(cmd == .progress); + try testing.expect(cmd.progress.state == .remove); } test "OSC: OSC9 progress error" { From c71da8338b4b3e85d8f7d31253e0b6773d7c8bce Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Thu, 2 Jan 2025 23:48:44 -0600 Subject: [PATCH 067/238] apprt/gtk: continue cleanup of window-decoration code Remove all window corner artifacting. Now we also respond to changes when the window becomes decorated or not. Signed-off-by: Tristan Partin --- src/apprt/gtk/Window.zig | 32 ++++++++++++++++++++------------ src/apprt/gtk/style.css | 6 +++++- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 63ee57d95..bf80c4a1a 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -210,14 +210,11 @@ pub fn init(self: *Window, app: *App) !void { self.header = header; } + _ = c.g_signal_connect_data(gtk_window, "notify::decorated", c.G_CALLBACK(>kWindowNotifyDecorated), self, null, c.G_CONNECT_DEFAULT); + // If we are disabling decorations then disable them right away. if (!app.config.@"window-decoration") { c.gtk_window_set_decorated(gtk_window, 0); - - // Fix any artifacting that may occur in window corners. - if (app.config.@"gtk-titlebar") { - c.gtk_widget_add_css_class(window, "without-window-decoration-and-with-titlebar"); - } } // If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we @@ -539,13 +536,6 @@ pub fn toggleWindowDecorations(self: *Window) void { const new_decorated = !old_decorated; c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated)); - // Fix any artifacting that may occur in window corners. - if (new_decorated) { - c.gtk_widget_add_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar"); - } else { - c.gtk_widget_remove_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar"); - } - // If we have a titlebar, then we also show/hide it depending on the // decorated state. GTK tends to consider the titlebar part of the frame // and hides it with decorations, but libadwaita doesn't. This makes it @@ -589,6 +579,24 @@ fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { return true; } +fn gtkWindowNotifyDecorated( + object: *c.GObject, + _: *c.GParamSpec, + _: ?*anyopaque, +) callconv(.C) void { + if (c.gtk_window_get_decorated(@ptrCast(object)) == 1) { + c.gtk_widget_remove_css_class(@ptrCast(object), "ssd"); + c.gtk_widget_remove_css_class(@ptrCast(object), "no-border-radius"); + } else { + // 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. + c.gtk_widget_add_css_class(@ptrCast(object), "ssd"); + c.gtk_widget_add_css_class(@ptrCast(object), "no-border-radius"); + } +} + // Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab // sends an undefined value. fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index bf0ee62f6..d1e848ac6 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -33,7 +33,11 @@ label.size-overlay.hidden { opacity: 0; } -window.without-window-decoration-and-with-titlebar { +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; } From 29dd5ae605617a470b9ee95d7b939cd335401013 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 6 Jan 2025 19:11:54 -0500 Subject: [PATCH 068/238] termio: explain why we're removing VTE_VERSION --- src/termio/Exec.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index ec5d3ffc0..1a3b8cad0 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -856,9 +856,11 @@ const Subprocess = struct { env.remove("GHOSTTY_MAC_APP"); } - // Don't leak these environment variables to child processes. + // VTE_VERSION is set by gnome-terminal and other VTE-based terminals. + // We don't want our child processes to think we're running under VTE. env.remove("VTE_VERSION"); + // Don't leak these GTK environment variables to child processes. if (comptime build_config.app_runtime == .gtk) { env.remove("GDK_DEBUG"); env.remove("GDK_DISABLE"); From 540fcc0b690901f185ca00465dafed2e9423b479 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 6 Jan 2025 17:39:53 -0500 Subject: [PATCH 069/238] refactor(font): move `Metrics` out of `face` in preparation to move ownership of metrics from faces to collections --- src/config/Config.zig | 2 +- src/font/Collection.zig | 2 +- src/font/{face => }/Metrics.zig | 4 ++-- src/font/SharedGrid.zig | 2 +- src/font/SharedGridSet.zig | 2 +- src/font/face.zig | 2 +- src/font/face/coretext.zig | 10 +++++----- src/font/face/freetype.zig | 12 ++++++------ src/font/face/web_canvas.zig | 4 ++-- src/font/main.zig | 2 +- src/renderer/Metal.zig | 2 +- src/renderer/OpenGL.zig | 4 ++-- 12 files changed, 24 insertions(+), 24 deletions(-) rename src/font/{face => }/Metrics.zig (99%) diff --git a/src/config/Config.zig b/src/config/Config.zig index e8acb9fea..2f38676c5 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -32,7 +32,7 @@ const url = @import("url.zig"); const Key = @import("key.zig").Key; const KeyValue = @import("key.zig").Value; const ErrorList = @import("ErrorList.zig"); -const MetricModifier = fontpkg.face.Metrics.Modifier; +const MetricModifier = fontpkg.Metrics.Modifier; const help_strings = @import("help_strings"); const log = std.log.scoped(.config); diff --git a/src/font/Collection.zig b/src/font/Collection.zig index f79c80936..629f4e595 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -25,7 +25,7 @@ const DeferredFace = font.DeferredFace; const DesiredSize = font.face.DesiredSize; const Face = font.Face; const Library = font.Library; -const Metrics = font.face.Metrics; +const Metrics = font.Metrics; const Presentation = font.Presentation; const Style = font.Style; diff --git a/src/font/face/Metrics.zig b/src/font/Metrics.zig similarity index 99% rename from src/font/face/Metrics.zig rename to src/font/Metrics.zig index 7bc456629..881c32895 100644 --- a/src/font/face/Metrics.zig +++ b/src/font/Metrics.zig @@ -355,7 +355,7 @@ pub const Modifier = union(enum) { } test "formatConfig percent" { - const configpkg = @import("../../config.zig"); + const configpkg = @import("../config.zig"); const testing = std.testing; var buf = std.ArrayList(u8).init(testing.allocator); defer buf.deinit(); @@ -366,7 +366,7 @@ pub const Modifier = union(enum) { } test "formatConfig absolute" { - const configpkg = @import("../../config.zig"); + const configpkg = @import("../config.zig"); const testing = std.testing; var buf = std.ArrayList(u8).init(testing.allocator); defer buf.deinit(); diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index f907b59ad..25069cde2 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -29,7 +29,7 @@ const Collection = font.Collection; const Face = font.Face; const Glyph = font.Glyph; const Library = font.Library; -const Metrics = font.face.Metrics; +const Metrics = font.Metrics; const Presentation = font.Presentation; const Style = font.Style; const RenderOptions = font.face.RenderOptions; diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index 95ef02495..16572e3f1 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -20,7 +20,7 @@ const Collection = font.Collection; const Discover = font.Discover; const Style = font.Style; const Library = font.Library; -const Metrics = font.face.Metrics; +const Metrics = font.Metrics; const CodepointMap = font.CodepointMap; const DesiredSize = font.face.DesiredSize; const Face = font.Face; diff --git a/src/font/face.zig b/src/font/face.zig index 1c74515e3..dab029f5c 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -1,7 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const options = @import("main.zig").options; -pub const Metrics = @import("face/Metrics.zig"); +const Metrics = @import("main.zig").Metrics; const config = @import("../config.zig"); const freetype = @import("face/freetype.zig"); const coretext = @import("face/coretext.zig"); diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 8da2b6a55..32077b8bb 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -19,7 +19,7 @@ pub const Face = struct { hb_font: if (harfbuzz_shaper) harfbuzz.Font else void, /// Metrics for this font face. These are useful for renderers. - metrics: font.face.Metrics, + metrics: font.Metrics, /// Set quirks.disableDefaultFontFeatures quirks_disable_default_font_features: bool = false, @@ -513,7 +513,7 @@ pub const Face = struct { InvalidHheaTable, }; - fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics { + pub fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.Metrics { // Read the 'head' table out of the font data. const head: opentype.Head = head: { // macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but @@ -731,7 +731,7 @@ pub const Face = struct { break :cell_width max; }; - return font.face.Metrics.calc(.{ + return font.Metrics.calc(.{ .cell_width = cell_width, .ascent = ascent, .descent = descent, @@ -1032,7 +1032,7 @@ test "coretext: metrics" { ); defer ct_font.deinit(); - try std.testing.expectEqual(font.face.Metrics{ + try std.testing.expectEqual(font.Metrics{ .cell_width = 8, // The cell height is 17 px because the calculation is // @@ -1060,7 +1060,7 @@ test "coretext: metrics" { // Resize should change metrics try ct_font.setSize(.{ .size = .{ .points = 24, .xdpi = 96, .ydpi = 96 } }); - try std.testing.expectEqual(font.face.Metrics{ + try std.testing.expectEqual(font.Metrics{ .cell_width = 16, .cell_height = 34, .cell_baseline = 6, diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 630eaee25..d919fd7b3 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -39,7 +39,7 @@ pub const Face = struct { hb_font: harfbuzz.Font, /// Metrics for this font face. These are useful for renderers. - metrics: font.face.Metrics, + metrics: font.Metrics, /// Freetype load flags for this font face. load_flags: font.face.FreetypeLoadFlags, @@ -604,8 +604,8 @@ pub const Face = struct { /// deinitialized anytime and reloaded with the deferred face. fn calcMetrics( face: freetype.Face, - modifiers: ?*const font.face.Metrics.ModifierSet, - ) CalcMetricsError!font.face.Metrics { + modifiers: ?*const font.Metrics.ModifierSet, + ) CalcMetricsError!font.Metrics { const size_metrics = face.handle.*.size.*.metrics; // This code relies on this assumption, and it should always be @@ -793,7 +793,7 @@ pub const Face = struct { }; }; - var result = font.face.Metrics.calc(.{ + var result = font.Metrics.calc(.{ .cell_width = cell_width, .ascent = ascent, @@ -921,7 +921,7 @@ test "metrics" { ); defer ft_font.deinit(); - try testing.expectEqual(font.face.Metrics{ + try testing.expectEqual(font.Metrics{ .cell_width = 8, // The cell height is 17 px because the calculation is // @@ -949,7 +949,7 @@ test "metrics" { // Resize should change metrics try ft_font.setSize(.{ .size = .{ .points = 24, .xdpi = 96, .ydpi = 96 } }); - try testing.expectEqual(font.face.Metrics{ + try testing.expectEqual(font.Metrics{ .cell_width = 16, .cell_height = 34, .cell_baseline = 6, diff --git a/src/font/face/web_canvas.zig b/src/font/face/web_canvas.zig index 60846f350..30540191d 100644 --- a/src/font/face/web_canvas.zig +++ b/src/font/face/web_canvas.zig @@ -27,7 +27,7 @@ pub const Face = struct { presentation: font.Presentation, /// Metrics for this font face. These are useful for renderers. - metrics: font.face.Metrics, + metrics: font.Metrics, /// The canvas element that we will reuse to render glyphs canvas: js.Object, @@ -273,7 +273,7 @@ pub const Face = struct { const underline_position = cell_height - 1; const underline_thickness: f32 = 1; - const result = font.face.Metrics{ + const result = font.Metrics{ .cell_width = @intFromFloat(cell_width), .cell_height = @intFromFloat(cell_height), .cell_baseline = @intFromFloat(cell_baseline), diff --git a/src/font/main.zig b/src/font/main.zig index 60e7593cb..ffeb42f7a 100644 --- a/src/font/main.zig +++ b/src/font/main.zig @@ -14,7 +14,7 @@ pub const Collection = @import("Collection.zig"); pub const DeferredFace = @import("DeferredFace.zig"); pub const Face = face.Face; pub const Glyph = @import("Glyph.zig"); -pub const Metrics = face.Metrics; +pub const Metrics = @import("Metrics.zig"); pub const opentype = @import("opentype.zig"); pub const shape = @import("shape.zig"); pub const Shaper = shape.Shaper; diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 5ad45da8e..79b7343de 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -68,7 +68,7 @@ config: DerivedConfig, surface_mailbox: apprt.surface.Mailbox, /// Current font metrics defining our grid. -grid_metrics: font.face.Metrics, +grid_metrics: font.Metrics, /// The size of everything. size: renderer.Size, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 157354d1d..dda9e9224 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -49,7 +49,7 @@ alloc: std.mem.Allocator, config: DerivedConfig, /// Current font metrics defining our grid. -grid_metrics: font.face.Metrics, +grid_metrics: font.Metrics, /// The size of everything. size: renderer.Size, @@ -231,7 +231,7 @@ const SetScreenSize = struct { }; const SetFontSize = struct { - metrics: font.face.Metrics, + metrics: font.Metrics, fn apply(self: SetFontSize, r: *const OpenGL) !void { const gl_state = r.gl_state orelse return error.OpenGLUninitialized; From 298aeb7536d69b8aef236569ee86ecfddd45d991 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 6 Jan 2025 19:00:13 -0500 Subject: [PATCH 070/238] refactor(font): move ownership of `Metrics` to `Collection` This sets the stage for dynamically adjusting the sizes of fallback fonts based on the primary font's face metrics. It also removes a lot of unnecessary work when loading fallback fonts, since we only actually use the metrics based on the parimary font. --- src/font/Collection.zig | 101 ++++++++++++++++++++++-- src/font/Metrics.zig | 29 ++++--- src/font/SharedGrid.zig | 11 +-- src/font/SharedGridSet.zig | 2 +- src/font/face.zig | 3 +- src/font/face/coretext.zig | 107 ++++++++----------------- src/font/face/freetype.zig | 158 +++++++++++++------------------------ src/font/sprite/Face.zig | 2 +- 8 files changed, 206 insertions(+), 207 deletions(-) diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 629f4e595..cb16528aa 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -35,6 +35,17 @@ const log = std.log.scoped(.font_collection); /// Instead, use the functions available on Collection. faces: StyleArray, +/// The metric modifiers to use for this collection. The memory +/// for this is owned by the user and is not freed by the collection. +/// +/// Call `Collection.updateMetrics` to recompute the +/// collection's metrics after making changes to these. +metric_modifiers: Metrics.ModifierSet = .{}, + +/// Metrics for this collection. Call `Collection.updateMetrics` to (re)compute +/// these after adding a primary font or making changes to `metric_modifiers`. +metrics: ?Metrics = null, + /// The load options for deferred faces in the face list. If this /// is not set, then deferred faces will not be loaded. Attempting to /// add a deferred face will result in an error. @@ -421,6 +432,28 @@ pub fn setSize(self: *Collection, size: DesiredSize) !void { .alias => continue, }; } + + try self.updateMetrics(); +} + +const UpdateMetricsError = font.Face.GetMetricsError || error{ + CannotLoadPrimaryFont, +}; + +/// Update the cell metrics for this collection, based on +/// the primary font and the modifiers in `metric_modifiers`. +/// +/// This requires a primary font (index `0`) to be present. +pub fn updateMetrics(self: *Collection) UpdateMetricsError!void { + const primary_face = self.getFace(.{ .idx = 0 }) catch return error.CannotLoadPrimaryFont; + + const face_metrics = try primary_face.getMetrics(); + + var metrics = Metrics.calc(face_metrics); + + metrics.apply(self.metric_modifiers); + + self.metrics = metrics; } /// Packed array of all Style enum cases mapped to a growable list of faces. @@ -448,10 +481,6 @@ pub const LoadOptions = struct { /// The desired font size for all loaded faces. size: DesiredSize = .{ .points = 12 }, - /// The metric modifiers to use for all loaded faces. The memory - /// for this is owned by the user and is not freed by the collection. - metric_modifiers: Metrics.ModifierSet = .{}, - /// Freetype Load Flags to use when loading glyphs. This is a list of /// bitfield constants that controls operations to perform during glyph /// loading. Only a subset is exposed for configuration, for the whole set @@ -467,7 +496,6 @@ pub const LoadOptions = struct { pub fn faceOptions(self: *const LoadOptions) font.face.Options { return .{ .size = self.size, - .metric_modifiers = &self.metric_modifiers, .freetype_load_flags = self.freetype_load_flags, }; } @@ -864,3 +892,66 @@ test "hasCodepoint emoji default graphical" { try testing.expect(c.hasCodepoint(idx, '🥸', .{ .any = {} })); // TODO(fontmem): test explicit/implicit } + +test "metrics" { + const testing = std.testing; + const alloc = testing.allocator; + const testFont = font.embedded.inconsolata; + + var lib = try Library.init(); + defer lib.deinit(); + + var c = init(); + defer c.deinit(alloc); + c.load_options = .{ .library = lib }; + + _ = try c.add(alloc, .regular, .{ .loaded = try Face.init( + lib, + testFont, + .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, + ) }); + + try c.updateMetrics(); + + try std.testing.expectEqual(font.Metrics{ + .cell_width = 8, + // The cell height is 17 px because the calculation is + // + // ascender - descender + gap + // + // which, for inconsolata is + // + // 859 - -190 + 0 + // + // font units, at 1000 units per em that works out to 1.049 em, + // and 1em should be the point size * dpi scale, so 12 * (96/72) + // which is 16, and 16 * 1.049 = 16.784, which finally is rounded + // to 17. + .cell_height = 17, + .cell_baseline = 3, + .underline_position = 17, + .underline_thickness = 1, + .strikethrough_position = 10, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_height = 17, + }, c.metrics); + + // Resize should change metrics + try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 }); + try std.testing.expectEqual(font.Metrics{ + .cell_width = 16, + .cell_height = 34, + .cell_baseline = 6, + .underline_position = 34, + .underline_thickness = 2, + .strikethrough_position = 19, + .strikethrough_thickness = 2, + .overline_position = 0, + .overline_thickness = 2, + .box_thickness = 2, + .cursor_height = 34, + }, c.metrics); +} diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index 881c32895..c78ac0972 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -52,7 +52,12 @@ const Minimums = struct { const cursor_height = 1; }; -const CalcOpts = struct { +/// Metrics extracted from a font face, based on +/// the metadata tables and glyph measurements. +pub const FaceMetrics = struct { + /// The minimum cell width that can contain any glyph in the ASCII range. + /// + /// Determined by measuring all printable glyphs in the ASCII range. cell_width: f64, /// The typographic ascent metric from the font. @@ -110,45 +115,45 @@ const CalcOpts = struct { /// do not round them before using them for this function. /// /// For any nullable options that are not provided, estimates will be used. -pub fn calc(opts: CalcOpts) Metrics { +pub fn calc(face: FaceMetrics) Metrics { // We use the ceiling of the provided cell width and height to ensure // that the cell is large enough for the provided size, since we cast // it to an integer later. - const cell_width = @ceil(opts.cell_width); - const cell_height = @ceil(opts.ascent - opts.descent + opts.line_gap); + const cell_width = @ceil(face.cell_width); + const cell_height = @ceil(face.ascent - face.descent + face.line_gap); // We split our line gap in two parts, and put half of it on the top // of the cell and the other half on the bottom, so that our text never // bumps up against either edge of the cell vertically. - const half_line_gap = opts.line_gap / 2; + const half_line_gap = face.line_gap / 2; // Unlike all our other metrics, `cell_baseline` is relative to the // BOTTOM of the cell. - const cell_baseline = @round(half_line_gap - opts.descent); + const cell_baseline = @round(half_line_gap - face.descent); // We calculate a top_to_baseline to make following calculations simpler. const top_to_baseline = cell_height - cell_baseline; // If we don't have a provided cap height, // we estimate it as 75% of the ascent. - const cap_height = opts.cap_height orelse opts.ascent * 0.75; + const cap_height = face.cap_height orelse face.ascent * 0.75; // If we don't have a provided ex height, // we estimate it as 75% of the cap height. - const ex_height = opts.ex_height orelse cap_height * 0.75; + const ex_height = face.ex_height orelse cap_height * 0.75; // If we don't have a provided underline thickness, // we estimate it as 15% of the ex height. - const underline_thickness = @max(1, @ceil(opts.underline_thickness orelse 0.15 * ex_height)); + const underline_thickness = @max(1, @ceil(face.underline_thickness orelse 0.15 * ex_height)); // If we don't have a provided strikethrough thickness // then we just use the underline thickness for it. - const strikethrough_thickness = @max(1, @ceil(opts.strikethrough_thickness orelse underline_thickness)); + const strikethrough_thickness = @max(1, @ceil(face.strikethrough_thickness orelse underline_thickness)); // If we don't have a provided underline position then // we place it 1 underline-thickness below the baseline. const underline_position = @round(top_to_baseline - - (opts.underline_position orelse + (face.underline_position orelse -underline_thickness)); // If we don't have a provided strikethrough position @@ -156,7 +161,7 @@ pub fn calc(opts: CalcOpts) Metrics { // ex height, so that it's perfectly centered on lower // case text. const strikethrough_position = @round(top_to_baseline - - (opts.strikethrough_position orelse + (face.strikethrough_position orelse ex_height * 0.5 + strikethrough_thickness * 0.5)); var result: Metrics = .{ diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 25069cde2..65c7ecd87 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -111,15 +111,10 @@ pub fn deinit(self: *SharedGrid, alloc: Allocator) void { } fn reloadMetrics(self: *SharedGrid) !void { - // Get our cell metrics based on a regular font ascii 'M'. Why 'M'? - // Doesn't matter, any normal ASCII will do we're just trying to make - // sure we use the regular font. - // We don't go through our caching layer because we want to minimize - // possible failures. const collection = &self.resolver.collection; - const index = collection.getIndex('M', .regular, .{ .any = {} }).?; - const face = try collection.getFace(index); - self.metrics = face.metrics; + try collection.updateMetrics(); + + self.metrics = collection.metrics.?; // Setup our sprite font. self.resolver.sprite = .{ .metrics = self.metrics }; diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig index 16572e3f1..249a11f75 100644 --- a/src/font/SharedGridSet.zig +++ b/src/font/SharedGridSet.zig @@ -167,13 +167,13 @@ fn collection( const load_options: Collection.LoadOptions = .{ .library = self.font_lib, .size = size, - .metric_modifiers = key.metric_modifiers, .freetype_load_flags = key.freetype_load_flags, }; var c = Collection.init(); errdefer c.deinit(self.alloc); c.load_options = load_options; + c.metric_modifiers = key.metric_modifiers; // Search for fonts if (Discover != void) discover: { diff --git a/src/font/face.zig b/src/font/face.zig index dab029f5c..0102010de 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -38,7 +38,6 @@ pub const freetype_load_flags_default = if (FreetypeLoadFlags != void) .{} else /// Options for initializing a font face. pub const Options = struct { size: DesiredSize, - metric_modifiers: ?*const Metrics.ModifierSet = null, freetype_load_flags: FreetypeLoadFlags = freetype_load_flags_default, }; @@ -89,7 +88,7 @@ pub const RenderOptions = struct { /// the metrics of the primary font face. The grid metrics are used /// by the font face to better layout the glyph in situations where /// the font is not exactly the same size as the grid. - grid_metrics: ?Metrics = null, + grid_metrics: Metrics, /// The number of grid cells this glyph will take up. This can be used /// optionally by the rasterizer to better layout the glyph. diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 32077b8bb..6661295f3 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -18,9 +18,6 @@ pub const Face = struct { /// if we're using Harfbuzz. hb_font: if (harfbuzz_shaper) harfbuzz.Font else void, - /// Metrics for this font face. These are useful for renderers. - metrics: font.Metrics, - /// Set quirks.disableDefaultFontFeatures quirks_disable_default_font_features: bool = false, @@ -87,11 +84,6 @@ pub const Face = struct { /// the CTFont. This does NOT copy or retain the CTFont. pub fn initFont(ct_font: *macos.text.Font, opts: font.face.Options) !Face { const traits = ct_font.getSymbolicTraits(); - const metrics = metrics: { - var metrics = try calcMetrics(ct_font); - if (opts.metric_modifiers) |v| metrics.apply(v.*); - break :metrics metrics; - }; var hb_font = if (comptime harfbuzz_shaper) font: { var hb_font = try harfbuzz.coretext.createFont(ct_font); @@ -109,7 +101,6 @@ pub const Face = struct { var result: Face = .{ .font = ct_font, .hb_font = hb_font, - .metrics = metrics, .color = color, }; result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result); @@ -463,7 +454,7 @@ pub const Face = struct { }; atlas.set(region, buf); - const metrics = opts.grid_metrics orelse self.metrics; + const metrics = opts.grid_metrics; // This should be the distance from the bottom of // the cell to the top of the glyph's bounding box. @@ -506,14 +497,17 @@ pub const Face = struct { }; } - const CalcMetricsError = error{ + pub const GetMetricsError = error{ CopyTableError, InvalidHeadTable, InvalidPostTable, InvalidHheaTable, }; - pub fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.Metrics { + /// Get the `FaceMetrics` for this face. + pub fn getMetrics(self: *Face) GetMetricsError!font.Metrics.FaceMetrics { + const ct_font = self.font; + // Read the 'head' table out of the font data. const head: opentype.Head = head: { // macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but @@ -731,7 +725,7 @@ pub const Face = struct { break :cell_width max; }; - return font.Metrics.calc(.{ + return .{ .cell_width = cell_width, .ascent = ascent, .descent = descent, @@ -742,7 +736,7 @@ pub const Face = struct { .strikethrough_thickness = strikethrough_thickness, .cap_height = cap_height, .ex_height = ex_height, - }); + }; } /// Copy the font table data for the given tag. @@ -866,7 +860,12 @@ test { var i: u8 = 32; while (i < 127) : (i += 1) { try testing.expect(face.glyphIndex(i) != null); - _ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?, .{}); + _ = try face.renderGlyph( + alloc, + &atlas, + face.glyphIndex(i).?, + .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + ); } } @@ -926,7 +925,12 @@ test "in-memory" { var i: u8 = 32; while (i < 127) : (i += 1) { try testing.expect(face.glyphIndex(i) != null); - _ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?, .{}); + _ = try face.renderGlyph( + alloc, + &atlas, + face.glyphIndex(i).?, + .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + ); } } @@ -948,7 +952,12 @@ test "variable" { var i: u8 = 32; while (i < 127) : (i += 1) { try testing.expect(face.glyphIndex(i) != null); - _ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?, .{}); + _ = try face.renderGlyph( + alloc, + &atlas, + face.glyphIndex(i).?, + .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + ); } } @@ -974,7 +983,12 @@ test "variable set variation" { var i: u8 = 32; while (i < 127) : (i += 1) { try testing.expect(face.glyphIndex(i) != null); - _ = try face.renderGlyph(alloc, &atlas, face.glyphIndex(i).?, .{}); + _ = try face.renderGlyph( + alloc, + &atlas, + face.glyphIndex(i).?, + .{ .grid_metrics = font.Metrics.calc(try face.getMetrics()) }, + ); } } @@ -1017,60 +1031,3 @@ test "glyphIndex colored vs text" { try testing.expect(face.isColorGlyph(glyph)); } } - -test "coretext: metrics" { - const testFont = font.embedded.inconsolata; - const alloc = std.testing.allocator; - - var atlas = try font.Atlas.init(alloc, 512, .grayscale); - defer atlas.deinit(alloc); - - var ct_font = try Face.init( - undefined, - testFont, - .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, - ); - defer ct_font.deinit(); - - try std.testing.expectEqual(font.Metrics{ - .cell_width = 8, - // The cell height is 17 px because the calculation is - // - // ascender - descender + gap - // - // which, for inconsolata is - // - // 859 - -190 + 0 - // - // font units, at 1000 units per em that works out to 1.049 em, - // and 1em should be the point size * dpi scale, so 12 * (96/72) - // which is 16, and 16 * 1.049 = 16.784, which finally is rounded - // to 17. - .cell_height = 17, - .cell_baseline = 3, - .underline_position = 17, - .underline_thickness = 1, - .strikethrough_position = 10, - .strikethrough_thickness = 1, - .overline_position = 0, - .overline_thickness = 1, - .box_thickness = 1, - .cursor_height = 17, - }, ct_font.metrics); - - // Resize should change metrics - try ct_font.setSize(.{ .size = .{ .points = 24, .xdpi = 96, .ydpi = 96 } }); - try std.testing.expectEqual(font.Metrics{ - .cell_width = 16, - .cell_height = 34, - .cell_baseline = 6, - .underline_position = 34, - .underline_thickness = 2, - .strikethrough_position = 19, - .strikethrough_thickness = 2, - .overline_position = 0, - .overline_thickness = 2, - .box_thickness = 2, - .cursor_height = 34, - }, ct_font.metrics); -} diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index d919fd7b3..b56e94695 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -38,9 +38,6 @@ pub const Face = struct { /// Harfbuzz font corresponding to this face. hb_font: harfbuzz.Font, - /// Metrics for this font face. These are useful for renderers. - metrics: font.Metrics, - /// Freetype load flags for this font face. load_flags: font.face.FreetypeLoadFlags, @@ -86,7 +83,6 @@ pub const Face = struct { .lib = lib.lib, .face = face, .hb_font = hb_font, - .metrics = try calcMetrics(face, opts.metric_modifiers), .load_flags = opts.freetype_load_flags, }; result.quirks_disable_default_font_features = quirks.disableDefaultFontFeatures(&result); @@ -186,7 +182,6 @@ pub const Face = struct { /// for clearing any glyph caches, font atlas data, etc. pub fn setSize(self: *Face, opts: font.face.Options) !void { try setSize_(self.face, opts.size); - self.metrics = try calcMetrics(self.face, opts.metric_modifiers); } fn setSize_(face: freetype.Face, size: font.face.DesiredSize) !void { @@ -224,6 +219,8 @@ pub const Face = struct { vs: []const font.face.Variation, opts: font.face.Options, ) !void { + _ = opts; + // If this font doesn't support variations, we can't do anything. if (!self.face.hasMultipleMasters() or vs.len == 0) return; @@ -257,9 +254,6 @@ pub const Face = struct { // Set them! try self.face.setVarDesignCoordinates(coords); - - // We need to recalculate font metrics which may have changed. - self.metrics = try calcMetrics(self.face, opts.metric_modifiers); } /// Returns the glyph index for the given Unicode code point. If this @@ -306,7 +300,7 @@ pub const Face = struct { glyph_index: u32, opts: font.face.RenderOptions, ) !Glyph { - const metrics = opts.grid_metrics orelse self.metrics; + const metrics = opts.grid_metrics; // If we have synthetic italic, then we apply a transformation matrix. // We have to undo this because synthetic italic works by increasing @@ -589,23 +583,14 @@ pub const Face = struct { return @as(opentype.sfnt.F26Dot6, @bitCast(@as(u32, @intCast(v)))).to(f64); } - const CalcMetricsError = error{ + pub const GetMetricsError = error{ CopyTableError, }; - /// Calculate the metrics associated with a face. This is not public because - /// the metrics are calculated for every face and cached since they're - /// frequently required for renderers and take up next to little memory space - /// in the grand scheme of things. - /// - /// An aside: the proper way to limit memory usage due to faces is to limit - /// the faces with DeferredFaces and reload on demand. A Face can't be converted - /// into a DeferredFace but a Face that comes from a DeferredFace can be - /// deinitialized anytime and reloaded with the deferred face. - fn calcMetrics( - face: freetype.Face, - modifiers: ?*const font.Metrics.ModifierSet, - ) CalcMetricsError!font.Metrics { + /// Get the `FaceMetrics` for this face. + pub fn getMetrics(self: *Face) GetMetricsError!font.Metrics.FaceMetrics { + const face = self.face; + const size_metrics = face.handle.*.size.*.metrics; // This code relies on this assumption, and it should always be @@ -793,7 +778,7 @@ pub const Face = struct { }; }; - var result = font.Metrics.calc(.{ + return .{ .cell_width = cell_width, .ascent = ascent, @@ -808,13 +793,7 @@ pub const Face = struct { .cap_height = cap_height, .ex_height = ex_height, - }); - - if (modifiers) |m| result.apply(m.*); - - // std.log.warn("font metrics={}", .{result}); - - return result; + }; } /// Copy the font table data for the given tag. @@ -843,16 +822,31 @@ test { // Generate all visible ASCII var i: u8 = 32; while (i < 127) : (i += 1) { - _ = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex(i).?, .{}); + _ = try ft_font.renderGlyph( + alloc, + &atlas, + ft_font.glyphIndex(i).?, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); } // Test resizing { - const g1 = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('A').?, .{}); + const g1 = try ft_font.renderGlyph( + alloc, + &atlas, + ft_font.glyphIndex('A').?, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); try testing.expectEqual(@as(u32, 11), g1.height); try ft_font.setSize(.{ .size = .{ .points = 24, .xdpi = 96, .ydpi = 96 } }); - const g2 = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('A').?, .{}); + const g2 = try ft_font.renderGlyph( + alloc, + &atlas, + ft_font.glyphIndex('A').?, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); try testing.expectEqual(@as(u32, 20), g2.height); } } @@ -874,7 +868,12 @@ test "color emoji" { ); defer ft_font.deinit(); - _ = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('🥸').?, .{}); + _ = try ft_font.renderGlyph( + alloc, + &atlas, + ft_font.glyphIndex('🥸').?, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); // Make sure this glyph has color { @@ -885,8 +884,11 @@ test "color emoji" { // resize { - const glyph = try ft_font.renderGlyph(alloc, &atlas, ft_font.glyphIndex('🥸').?, .{ - .grid_metrics = .{ + const glyph = try ft_font.renderGlyph( + alloc, + &atlas, + ft_font.glyphIndex('🥸').?, + .{ .grid_metrics = .{ .cell_width = 10, .cell_height = 24, .cell_baseline = 0, @@ -898,72 +900,12 @@ test "color emoji" { .overline_thickness = 0, .box_thickness = 0, .cursor_height = 0, - }, - }); + } }, + ); try testing.expectEqual(@as(u32, 24), glyph.height); } } -test "metrics" { - const testFont = font.embedded.inconsolata; - const alloc = testing.allocator; - - var lib = try Library.init(); - defer lib.deinit(); - - var atlas = try font.Atlas.init(alloc, 512, .grayscale); - defer atlas.deinit(alloc); - - var ft_font = try Face.init( - lib, - testFont, - .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } }, - ); - defer ft_font.deinit(); - - try testing.expectEqual(font.Metrics{ - .cell_width = 8, - // The cell height is 17 px because the calculation is - // - // ascender - descender + gap - // - // which, for inconsolata is - // - // 859 - -190 + 0 - // - // font units, at 1000 units per em that works out to 1.049 em, - // and 1em should be the point size * dpi scale, so 12 * (96/72) - // which is 16, and 16 * 1.049 = 16.784, which finally is rounded - // to 17. - .cell_height = 17, - .cell_baseline = 3, - .underline_position = 17, - .underline_thickness = 1, - .strikethrough_position = 10, - .strikethrough_thickness = 1, - .overline_position = 0, - .overline_thickness = 1, - .box_thickness = 1, - .cursor_height = 17, - }, ft_font.metrics); - - // Resize should change metrics - try ft_font.setSize(.{ .size = .{ .points = 24, .xdpi = 96, .ydpi = 96 } }); - try testing.expectEqual(font.Metrics{ - .cell_width = 16, - .cell_height = 34, - .cell_baseline = 6, - .underline_position = 34, - .underline_thickness = 2, - .strikethrough_position = 19, - .strikethrough_thickness = 2, - .overline_position = 0, - .overline_thickness = 2, - .box_thickness = 2, - .cursor_height = 34, - }, ft_font.metrics); -} - test "mono to rgba" { const alloc = testing.allocator; const testFont = font.embedded.emoji; @@ -974,11 +916,16 @@ test "mono to rgba" { var atlas = try font.Atlas.init(alloc, 512, .rgba); defer atlas.deinit(alloc); - var ft_font = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); + var ft_font = try Face.init(lib, testFont, .{ .size = .{ .points = 12, .xdpi = 72, .ydpi = 72 } }); defer ft_font.deinit(); // glyph 3 is mono in Noto - _ = try ft_font.renderGlyph(alloc, &atlas, 3, .{}); + _ = try ft_font.renderGlyph( + alloc, + &atlas, + 3, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); } test "svg font table" { @@ -988,7 +935,7 @@ test "svg font table" { var lib = try font.Library.init(); defer lib.deinit(); - var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); + var face = try Face.init(lib, testFont, .{ .size = .{ .points = 12, .xdpi = 72, .ydpi = 72 } }); defer face.deinit(); const table = (try face.copyTable(alloc, "SVG ")).?; @@ -1037,7 +984,12 @@ test "bitmap glyph" { defer ft_font.deinit(); // glyph 77 = 'i' - const glyph = try ft_font.renderGlyph(alloc, &atlas, 77, .{}); + const glyph = try ft_font.renderGlyph( + alloc, + &atlas, + 77, + .{ .grid_metrics = font.Metrics.calc(try ft_font.getMetrics()) }, + ); // should render crisp try testing.expectEqual(8, glyph.width); diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index 7c42fb394..cebf44429 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -52,7 +52,7 @@ pub fn renderGlyph( } } - const metrics = opts.grid_metrics orelse self.metrics; + const metrics = self.metrics; // We adjust our sprite width based on the cell width. const width = switch (opts.cell_width orelse 1) { From a52f469e1600bd4861ac97ad277d0ed4baca96de Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Mon, 6 Jan 2025 22:56:41 -0600 Subject: [PATCH 071/238] apprt/gtk: fix window colors when window-theme=ghostty Signed-off-by: Tristan Partin --- src/apprt/gtk/App.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 3cc1782c8..2420e42dc 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1041,6 +1041,8 @@ fn loadRuntimeCss( \\ --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); From c8d5b2da454cc1926b4369d9719081d6f3b1b683 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Tue, 7 Jan 2025 14:14:51 +0800 Subject: [PATCH 072/238] Add IPv6 URL pattern support with comprehensive test cases - Add IPv6 URL pattern matching to support URLs like http://[::]:8000/ - Separate IPv6 URL pattern from main regex for better maintainability - Add extensive test cases covering: - Basic IPv6 URLs with ports - URLs with paths and query parameters - Compressed IPv6 forms - Link-local and multicast addresses - Mixed scenarios and markdown contexts --- src/config/url.zig | 57 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/config/url.zig b/src/config/url.zig index f5709a383..78f9816fd 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -24,12 +24,18 @@ const oni = @import("oniguruma"); /// handling them well requires a non-regex approach. pub const regex = "(?:" ++ url_schemes ++ - \\)(?:[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(? Date: Tue, 7 Jan 2025 08:45:43 -0500 Subject: [PATCH 073/238] bash: set the title command in preexec PS0 is evaluated after a command is read but before it is executed. The 'preexec' hook (from bash-preexec) is equivalent for our title-updating purposes and conveniently provides the current command as an argument (from its own `history 1` call). --- src/shell-integration/bash/ghostty.bash | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 1e27545b6..e779eef28 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -105,15 +105,6 @@ builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh" _ghostty_executing="" _ghostty_last_reported_cwd="" -function __ghostty_get_current_command() { - builtin local last_cmd - # shellcheck disable=SC1007 - last_cmd=$(HISTTIMEFORMAT= builtin history 1) - last_cmd="${last_cmd#*[[:digit:]]*[[:space:]]}" # remove leading history number - last_cmd="${last_cmd#"${last_cmd%%[![:space:]]*}"}" # remove remaining leading whitespace - builtin printf "\e]2;%s\a" "${last_cmd//[[:cntrl:]]}" # remove any control characters -} - function __ghostty_precmd() { local ret="$?" if test "$_ghostty_executing" != "0"; then @@ -138,10 +129,8 @@ function __ghostty_precmd() { PS0=$PS0'\[\e[0 q\]' fi + # Title (working directory) if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then - # Command and working directory - # shellcheck disable=SC2016 - PS0=$PS0'$(__ghostty_get_current_command)' PS1=$PS1'\[\e]2;\w\a\]' fi fi @@ -165,9 +154,18 @@ function __ghostty_precmd() { } function __ghostty_preexec() { + builtin local cmd="$1" + PS0="$_GHOSTTY_SAVE_PS0" PS1="$_GHOSTTY_SAVE_PS1" PS2="$_GHOSTTY_SAVE_PS2" + + # Title (current command) + if [[ -n $cmd && "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then + builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]}" + fi + + # End of input, start of output. builtin printf "\e]133;C;\a" _ghostty_executing=1 } From 41201068eff9f85660a40170e8e73483dc4cd263 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 7 Jan 2025 11:34:39 -0500 Subject: [PATCH 074/238] bash: add license declaration for kitty-derived code --- src/shell-integration/bash/ghostty.bash | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 1e27545b6..3127b7bdc 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -1,6 +1,19 @@ -# This is originally based on the recommended bash integration from -# the semantic prompts proposal as well as some logic from Kitty's -# bash integration. +# Parts of this script are based on Kitty's bash integration. Kitty is +# distributed under GPLv3, so this file is also distributed under GPLv3. +# The license header is reproduced below: +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . # We need to be in interactive mode and we need to have the Ghostty # resources dir set which also tells us we're running in Ghostty. From b2716375acd399655b56629991c548b68ae6c903 Mon Sep 17 00:00:00 2001 From: Gregory Anders Date: Tue, 7 Jan 2025 11:50:46 -0600 Subject: [PATCH 075/238] renderer: respect reverse with cursor-invert-fg-bg --- src/renderer/Metal.zig | 14 ++++++++++++-- src/renderer/OpenGL.zig | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 5ad45da8e..02b91a831 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -2650,8 +2650,13 @@ fn rebuildCells( const style = cursor_style_ orelse break :cursor; const cursor_color = self.cursor_color orelse self.default_cursor_color orelse color: { if (self.cursor_invert) { + // Use the foreground color from the cell under the cursor, if any. const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :color sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color; + break :color if (sty.flags.inverse) + // If the cell is reversed, use background color instead. + (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color) + else + (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color); } else { break :color self.foreground_color orelse self.default_foreground_color; } @@ -2680,8 +2685,13 @@ fn rebuildCells( }; const uniform_color = if (self.cursor_invert) blk: { + // Use the background color from the cell under the cursor, if any. const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :blk sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color; + break :blk if (sty.flags.inverse) + // If the cell is reversed, use foreground color instead. + (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color) + else + (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color); } else if (self.config.cursor_text) |txt| txt else diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 157354d1d..086f5aaa0 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -1737,8 +1737,13 @@ pub fn rebuildCells( const cursor_color = self.cursor_color orelse self.default_cursor_color orelse color: { if (self.cursor_invert) { + // Use the foreground color from the cell under the cursor, if any. const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :color sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color; + break :color if (sty.flags.inverse) + // If the cell is reversed, use background color instead. + (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color) + else + (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color); } else { break :color self.foreground_color orelse self.default_foreground_color; } @@ -1748,8 +1753,13 @@ pub fn rebuildCells( for (cursor_cells.items) |*cell| { if (cell.mode.isFg() and cell.mode != .fg_color) { const cell_color = if (self.cursor_invert) blk: { + // Use the background color from the cell under the cursor, if any. const sty = screen.cursor.page_pin.style(screen.cursor.page_cell); - break :blk sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color; + break :blk if (sty.flags.inverse) + // If the cell is reversed, use foreground color instead. + (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color) + else + (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color); } else if (self.config.cursor_text) |txt| txt else From a115e848c63ddd202def0482199d65b5935b73db Mon Sep 17 00:00:00 2001 From: sin-ack Date: Tue, 7 Jan 2025 19:12:53 +0000 Subject: [PATCH 076/238] apprt/gtk: Add version.runtimeAtLeast This will be used for version checks that are independent of the version of GTK we built against. --- src/apprt/gtk/version.zig | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/version.zig b/src/apprt/gtk/version.zig index af7ad12ea..d8686fa28 100644 --- a/src/apprt/gtk/version.zig +++ b/src/apprt/gtk/version.zig @@ -7,6 +7,11 @@ const c = @import("c.zig").c; /// 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( @@ -26,6 +31,20 @@ pub inline fn atLeast( // 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. @@ -44,15 +63,18 @@ test "atLeast" { const std = @import("std"); const testing = std.testing; - try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + 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(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(!atLeast(c.GTK_MAJOR_VERSION + 1, 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(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + 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(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + } } From 093bdf640a434ea1f22e50236e2f44f727c83e75 Mon Sep 17 00:00:00 2001 From: sin-ack Date: Tue, 7 Jan 2025 19:53:27 +0000 Subject: [PATCH 077/238] apprt/gtk: Move most version checks to runtime Unless we are guarding against symbols added in new versions we now check against the runtime version of GTK to handle them even when we didn't build against that version. --- src/apprt/gtk/App.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 3cc1782c8..993dfcc32 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -111,12 +111,12 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // Disabling Vulkan can improve startup times by hundreds of // milliseconds on some systems. We don't use Vulkan so we can just // disable it. - if (version.atLeast(4, 16, 0)) { + if (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. _ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan"); _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-no-fractional"); - } else if (version.atLeast(4, 14, 0)) { + } else if (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 @@ -138,7 +138,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { _ = internal_os.setenv("GDK_DEBUG", "vulkan-disable"); } - if (version.atLeast(4, 14, 0)) { + if (version.runtimeAtLeast(4, 14, 0)) { // We need to export GSK_RENDERER to opengl because GTK uses ngl by // default after 4.14 _ = internal_os.setenv("GSK_RENDERER", "opengl"); @@ -1028,7 +1028,7 @@ fn loadRuntimeCss( , .{ .font_family = font_family }); } - if (version.atLeast(4, 16, 0)) { + if (version.runtimeAtLeast(4, 16, 0)) { switch (window_theme) { .ghostty => try writer.print( \\:root {{ From 1b91a667fba5ec70c96f6d938e466e9631d53770 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 7 Jan 2025 15:51:02 -0500 Subject: [PATCH 078/238] bash: drop automatic shell integration with --posix '--posix' starts bash in POSIX mode (like /bin/sh). This is rarely used for interactive shells, and removing automatic shell integration support for this option allows us to simply/remove some exceptional code paths. Users are still able to manually source the shell integration script. Also fix an issue where we would still inject GHOSTTY_BASH_RCFILE if we aborted the automatic shell integration path _after_ seeing an --rcfile or --init-file argument. --- src/shell-integration/bash/ghostty.bash | 77 ++++++-------- src/termio/shell_integration.zig | 133 +++++++----------------- 2 files changed, 69 insertions(+), 141 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 3127b7bdc..7a4159304 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -29,52 +29,41 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then # At this point, we're in POSIX mode and rely on the injected # flags to guide is through the rest of the startup sequence. - # POSIX mode was requested by the user so there's nothing - # more to do that optionally source their original $ENV. - # No other startup files are read, per the standard. - if [[ "$ghostty_bash_inject" == *"--posix"* ]]; then - if [ -n "$GHOSTTY_BASH_ENV" ]; then - builtin source "$GHOSTTY_BASH_ENV" - builtin export ENV="$GHOSTTY_BASH_ENV" + # Restore bash's default 'posix' behavior. Also reset 'inherit_errexit', + # which doesn't happen as part of the 'posix' reset. + builtin set +o posix + builtin shopt -u inherit_errexit 2>/dev/null + + # Unexport HISTFILE if it was set by the shell integration code. + if [[ -n "$GHOSTTY_BASH_UNEXPORT_HISTFILE" ]]; then + builtin export -n HISTFILE + builtin unset GHOSTTY_BASH_UNEXPORT_HISTFILE + fi + + # Manually source the startup files, respecting the injected flags like + # --norc and --noprofile that we parsed with the shell integration code. + # + # See also: run_startup_files() in shell.c in the Bash source code + if builtin shopt -q login_shell; then + if [[ $ghostty_bash_inject != *"--noprofile"* ]]; then + [ -r /etc/profile ] && builtin source "/etc/profile" + for rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } + done fi - builtin unset GHOSTTY_BASH_ENV else - # Restore bash's default 'posix' behavior. Also reset 'inherit_errexit', - # which doesn't happen as part of the 'posix' reset. - builtin set +o posix - builtin shopt -u inherit_errexit 2>/dev/null - - # Unexport HISTFILE if it was set by the shell integration code. - if [[ -n "$GHOSTTY_BASH_UNEXPORT_HISTFILE" ]]; then - builtin export -n HISTFILE - builtin unset GHOSTTY_BASH_UNEXPORT_HISTFILE - fi - - # Manually source the startup files, respecting the injected flags like - # --norc and --noprofile that we parsed with the shell integration code. - # - # See also: run_startup_files() in shell.c in the Bash source code - if builtin shopt -q login_shell; then - if [[ $ghostty_bash_inject != *"--noprofile"* ]]; then - [ -r /etc/profile ] && builtin source "/etc/profile" - for rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do - [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } - done - fi - else - if [[ $ghostty_bash_inject != *"--norc"* ]]; then - # The location of the system bashrc is determined at bash build - # time via -DSYS_BASHRC and can therefore vary across distros: - # Arch, Debian, Ubuntu use /etc/bash.bashrc - # Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC - # Void Linux uses /etc/bash/bashrc - # Nixos uses /etc/bashrc - for rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do - [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } - done - if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi - [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" - fi + if [[ $ghostty_bash_inject != *"--norc"* ]]; then + # The location of the system bashrc is determined at bash build + # time via -DSYS_BASHRC and can therefore vary across distros: + # Arch, Debian, Ubuntu use /etc/bash.bashrc + # Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC + # Void Linux uses /etc/bash/bashrc + # Nixos uses /etc/bashrc + for rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do + [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } + done + if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi + [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" fi fi diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 634f6e960..8cd2a92ae 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -174,31 +174,36 @@ fn setupBash( try args.append("--posix"); // Stores the list of intercepted command line flags that will be passed - // to our shell integration script: --posix --norc --noprofile + // to our shell integration script: --norc --noprofile // We always include at least "1" so the script can differentiate between // being manually sourced or automatically injected (from here). var inject = try std.BoundedArray(u8, 32).init(0); try inject.appendSlice("1"); - var posix = false; - + // Walk through the rest of the given arguments. If we see an option that + // would require complex or unsupported integration behavior, we bail out + // and skip loading our shell integration. Users can still manually source + // the shell integration script. + // + // Unsupported options: + // -c -c is always non-interactive + // --posix POSIX mode (a la /bin/sh) + // // Some additional cases we don't yet cover: // // - If additional file arguments are provided (after a `-` or `--` flag), // and the `i` shell option isn't being explicitly set, we can assume a // non-interactive shell session and skip loading our shell integration. + var rcfile: ?[]const u8 = null; while (iter.next()) |arg| { if (std.mem.eql(u8, arg, "--posix")) { - try inject.appendSlice(" --posix"); - posix = true; + return null; } else if (std.mem.eql(u8, arg, "--norc")) { try inject.appendSlice(" --norc"); } else if (std.mem.eql(u8, arg, "--noprofile")) { try inject.appendSlice(" --noprofile"); } else if (std.mem.eql(u8, arg, "--rcfile") or std.mem.eql(u8, arg, "--init-file")) { - if (iter.next()) |rcfile| { - try env.put("GHOSTTY_BASH_RCFILE", rcfile); - } + rcfile = iter.next(); } else if (arg.len > 1 and arg[0] == '-' and arg[1] != '-') { // '-c command' is always non-interactive if (std.mem.indexOfScalar(u8, arg, 'c') != null) { @@ -210,10 +215,13 @@ fn setupBash( } } try env.put("GHOSTTY_BASH_INJECT", inject.slice()); + if (rcfile) |v| { + try env.put("GHOSTTY_BASH_RCFILE", v); + } // In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're // staying in POSIX mode (--posix), change it back to ~/.bash_history. - if (!posix and env.get("HISTFILE") == null) { + if (env.get("HISTFILE") == null) { var home_buf: [1024]u8 = undefined; if (try homedir.home(&home_buf)) |home| { var histfile_buf: [std.fs.max_path_bytes]u8 = undefined; @@ -227,13 +235,6 @@ fn setupBash( } } - // Preserve the existing ENV value when staying in POSIX mode (--posix). - if (env.get("ENV")) |old| { - if (posix) { - try env.put("GHOSTTY_BASH_ENV", old); - } - } - // Set our new ENV to point to our integration script. var path_buf: [std.fs.max_path_bytes]u8 = undefined; const integ_dir = try std.fmt.bufPrint( @@ -262,21 +263,32 @@ test "bash" { try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?); } -test "bash: inject flags" { +test "bash: unsupported options" { const testing = std.testing; const alloc = testing.allocator; - // bash --posix - { + const cmdlines = [_][]const u8{ + "bash --posix", + "bash --rcfile script.sh --posix", + "bash --init-file script.sh --posix", + "bash -c script.sh", + "bash -ic script.sh", + }; + + for (cmdlines) |cmdline| { var env = EnvMap.init(alloc); defer env.deinit(); - const command = try setupBash(alloc, "bash --posix", ".", &env); - defer if (command) |c| alloc.free(c); - - try testing.expectEqualStrings("bash --posix", command.?); - try testing.expectEqualStrings("1 --posix", env.get("GHOSTTY_BASH_INJECT").?); + try testing.expect(try setupBash(alloc, cmdline, ".", &env) == null); + try testing.expect(env.get("GHOSTTY_BASH_INJECT") == null); + try testing.expect(env.get("GHOSTTY_BASH_RCFILE") == null); + try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); } +} + +test "bash: inject flags" { + const testing = std.testing; + const alloc = testing.allocator; // bash --norc { @@ -329,17 +341,6 @@ test "bash: rcfile" { } } -test "bash: -c command" { - const testing = std.testing; - const alloc = testing.allocator; - - var env = EnvMap.init(alloc); - defer env.deinit(); - - try testing.expect(try setupBash(alloc, "bash -c script.sh", ".", &env) == null); - try testing.expect(try setupBash(alloc, "bash -ic script.sh", ".", &env) == null); -} - test "bash: HISTFILE" { const testing = std.testing; const alloc = testing.allocator; @@ -369,68 +370,6 @@ test "bash: HISTFILE" { try testing.expectEqualStrings("my_history", env.get("HISTFILE").?); try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); } - - // HISTFILE unset (POSIX mode) - { - var env = EnvMap.init(alloc); - defer env.deinit(); - - const command = try setupBash(alloc, "bash --posix", ".", &env); - defer if (command) |c| alloc.free(c); - - try testing.expect(env.get("HISTFILE") == null); - try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); - } - - // HISTFILE set (POSIX mode) - { - var env = EnvMap.init(alloc); - defer env.deinit(); - - try env.put("HISTFILE", "my_history"); - - const command = try setupBash(alloc, "bash --posix", ".", &env); - defer if (command) |c| alloc.free(c); - - try testing.expectEqualStrings("my_history", env.get("HISTFILE").?); - try testing.expect(env.get("GHOSTTY_BASH_UNEXPORT_HISTFILE") == null); - } -} - -test "bash: preserve ENV" { - const testing = std.testing; - const alloc = testing.allocator; - - var env = EnvMap.init(alloc); - defer env.deinit(); - - const original_env = "original-env.bash"; - - // POSIX mode - { - try env.put("ENV", original_env); - const command = try setupBash(alloc, "bash --posix", ".", &env); - defer if (command) |c| alloc.free(c); - - try testing.expect(std.mem.indexOf(u8, command.?, "--posix") != null); - try testing.expect(std.mem.indexOf(u8, env.get("GHOSTTY_BASH_INJECT").?, "posix") != null); - try testing.expectEqualStrings(original_env, env.get("GHOSTTY_BASH_ENV").?); - try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); - } - - env.remove("GHOSTTY_BASH_ENV"); - - // Not POSIX mode - { - try env.put("ENV", original_env); - const command = try setupBash(alloc, "bash", ".", &env); - defer if (command) |c| alloc.free(c); - - try testing.expect(std.mem.indexOf(u8, command.?, "--posix") != null); - try testing.expect(std.mem.indexOf(u8, env.get("GHOSTTY_BASH_INJECT").?, "posix") == null); - try testing.expect(env.get("GHOSTTY_BASH_ENV") == null); - try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); - } } /// Setup automatic shell integration for shells that include From 8bf5c4ed7f8e39ca6dcadd036c8c72924590b200 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 7 Jan 2025 07:14:32 -0800 Subject: [PATCH 079/238] This is a major refactor of `build.zig`. The major idea behind the refactor is to split the `build.zig` file up into distinct `src/build/*.zig` files. By doing so, we can improve readability of the primary `build.zig` while also enabling better reuse of steps. Our `build.zig` is now less than 150 lines of code (of course, it calls into a lot more lines but they're neatly organized now). Improvements: * `build.zig` is less than 150 lines of readable code. * Help strings and unicode table generators are only run once when multiple artifacts are built since the results are the same regardless of target. * Metal lib is only built once per architecture (rather than once per artifact) * Resources (shell integration, terminfo, etc.) and docs are only built/installed for artifacts that need them Breaking changes: * Removed broken wasm build (@gabydd will re-add) * Removed conformance files, shell scripts are better and we don't run these anymore * Removed macOS app bundle creation, we don't use this anymore since we use Xcode ## Some History Our `build.zig` hasn't been significantly refactored since the project started, when Zig was _version 0.10_. Since then, the build system has changed significantly. We've only ever duct taped the `build.zig` as we needed to support new Zig versions, new features, etc. It was a mess. The major improvement is adapting the entire Ghostty `build.zig` to the Step and LazyPath changes introduced way back in Zig 0.12. This lets us better take advantage of parallelism and the dependency graph so that steps are only executed as they're needed. As such, you can see in the build.zig that we initialize a lot of things, but unless a final target (i.e. install, run) references those steps, _they'll never be executed_. This lets us clean up a lot. --- build.zig | 1895 +-------------------- conformance/ansi_ri.zig | 12 - conformance/ansi_ri_top.zig | 23 - conformance/blocks.zig | 99 -- conformance/csi_decstbm.zig | 15 - conformance/csi_dl.zig | 14 - conformance/csi_il.zig | 17 - conformance/esc_decaln.zig | 10 - nix/package.nix | 1 - src/build/Config.zig | 503 ++++++ src/build/GhosttyBench.zig | 69 + src/build/GhosttyDocs.zig | 92 + src/build/GhosttyExe.zig | 120 ++ src/build/GhosttyLib.zig | 110 ++ src/build/GhosttyResources.zig | 257 +++ src/build/GhosttyWebdata.zig | 82 + src/build/GhosttyXCFramework.zig | 68 + src/build/{Version.zig => GitVersion.zig} | 0 src/build/HelpStrings.zig | 46 + src/build/MetallibStep.zig | 4 +- src/build/SharedDeps.zig | 501 ++++++ src/build/UnicodeTables.zig | 43 + src/build/bash_completions.zig | 4 +- src/build/fish_completions.zig | 2 +- src/build/gtk.zig | 24 + src/build/main.zig | 30 + src/build/zsh_completions.zig | 2 +- src/build_config.zig | 117 +- 28 files changed, 2018 insertions(+), 2142 deletions(-) delete mode 100644 conformance/ansi_ri.zig delete mode 100644 conformance/ansi_ri_top.zig delete mode 100644 conformance/blocks.zig delete mode 100644 conformance/csi_decstbm.zig delete mode 100644 conformance/csi_dl.zig delete mode 100644 conformance/csi_il.zig delete mode 100644 conformance/esc_decaln.zig create mode 100644 src/build/Config.zig create mode 100644 src/build/GhosttyBench.zig create mode 100644 src/build/GhosttyDocs.zig create mode 100644 src/build/GhosttyExe.zig create mode 100644 src/build/GhosttyLib.zig create mode 100644 src/build/GhosttyResources.zig create mode 100644 src/build/GhosttyWebdata.zig create mode 100644 src/build/GhosttyXCFramework.zig rename src/build/{Version.zig => GitVersion.zig} (100%) create mode 100644 src/build/HelpStrings.zig create mode 100644 src/build/SharedDeps.zig create mode 100644 src/build/UnicodeTables.zig create mode 100644 src/build/gtk.zig create mode 100644 src/build/main.zig diff --git a/build.zig b/build.zig index 7f0bf84c5..1364745ce 100644 --- a/build.zig +++ b/build.zig @@ -1,30 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const fs = std.fs; -const CompileStep = std.Build.Step.Compile; -const RunStep = std.Build.Step.Run; -const ResolvedTarget = std.Build.ResolvedTarget; - -const apprt = @import("src/apprt.zig"); -const font = @import("src/font/main.zig"); -const renderer = @import("src/renderer.zig"); -const terminfo = @import("src/terminfo/main.zig"); -const config_vim = @import("src/config/vim.zig"); -const config_sublime_syntax = @import("src/config/sublime_syntax.zig"); -const fish_completions = @import("src/build/fish_completions.zig"); -const zsh_completions = @import("src/build/zsh_completions.zig"); -const bash_completions = @import("src/build/bash_completions.zig"); -const build_config = @import("src/build_config.zig"); -const BuildConfig = build_config.BuildConfig; -const WasmTarget = @import("src/os/wasm/target.zig").Target; -const LibtoolStep = @import("src/build/LibtoolStep.zig"); -const LipoStep = @import("src/build/LipoStep.zig"); -const MetallibStep = @import("src/build/MetallibStep.zig"); -const XCFrameworkStep = @import("src/build/XCFrameworkStep.zig"); -const Version = @import("src/build/Version.zig"); -const Command = @import("src/Command.zig"); - -const Scanner = @import("zig_wayland").Scanner; +const buildpkg = @import("src/build/main.zig"); comptime { // This is the required Zig version for building this project. We allow @@ -44,898 +20,80 @@ comptime { } } -/// The version of the next release. -const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 2 }; - pub fn build(b: *std.Build) !void { - const optimize = b.standardOptimizeOption(.{}); - const target = target: { - var result = b.standardTargetOptions(.{}); + const config = try buildpkg.Config.init(b); - // If we have no minimum OS version, we set the default based on - // our tag. Not all tags have a minimum so this may be null. - if (result.query.os_version_min == null) { - result.query.os_version_min = osVersionMin(result.result.os.tag); - } + // Ghostty resources like terminfo, shell integration, themes, etc. + const resources = try buildpkg.GhosttyResources.init(b, &config); - break :target result; - }; + // Ghostty dependencies used by many artifacts. + const deps = try buildpkg.SharedDeps.init(b, &config); + const exe = try buildpkg.GhosttyExe.init(b, &config, &deps); + if (config.emit_helpgen) deps.help_strings.install(); - // This is set to true when we're building a system package. For now - // this is trivially detected using the "system_package_mode" bool - // but we may want to make this more sophisticated in the future. - const system_package: bool = b.graph.system_package_mode; - - const wasm_target: WasmTarget = .browser; - - // We use env vars throughout the build so we grab them immediately here. - var env = try std.process.getEnvMap(b.allocator); - defer env.deinit(); - - // Our build configuration. This is all on a struct so that we can easily - // modify it for specific build types (for example, wasm we strictly - // control our backends). - var config: BuildConfig = .{}; - - config.flatpak = b.option( - bool, - "flatpak", - "Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.", - ) orelse false; - - config.font_backend = b.option( - font.Backend, - "font-backend", - "The font backend to use for discovery and rasterization.", - ) orelse font.Backend.default(target.result, wasm_target); - - config.app_runtime = b.option( - apprt.Runtime, - "app-runtime", - "The app runtime to use. Not all values supported on all platforms.", - ) orelse apprt.Runtime.default(target.result); - - config.renderer = b.option( - renderer.Impl, - "renderer", - "The app runtime to use. Not all values supported on all platforms.", - ) orelse renderer.Impl.default(target.result, wasm_target); - - config.adwaita = b.option( - bool, - "gtk-adwaita", - "Enables the use of Adwaita when using the GTK rendering backend.", - ) orelse true; - - var x11 = false; - var wayland = false; - - if (target.result.os.tag == .linux) pkgconfig: { - var pkgconfig = std.process.Child.init(&.{ "pkg-config", "--variable=targets", "gtk4" }, b.allocator); - - pkgconfig.stdout_behavior = .Pipe; - pkgconfig.stderr_behavior = .Pipe; - - pkgconfig.spawn() catch |err| { - std.log.warn("failed to spawn pkg-config - disabling X11 and Wayland integrations: {}", .{err}); - break :pkgconfig; - }; - - const output_max_size = 50 * 1024; - - var stdout = std.ArrayList(u8).init(b.allocator); - var stderr = std.ArrayList(u8).init(b.allocator); - defer { - stdout.deinit(); - stderr.deinit(); - } - - try pkgconfig.collectOutput(&stdout, &stderr, output_max_size); - - const term = try pkgconfig.wait(); - - if (stderr.items.len > 0) { - std.log.warn("pkg-config had errors:\n{s}", .{stderr.items}); - } - - switch (term) { - .Exited => |code| { - if (code == 0) { - if (std.mem.indexOf(u8, stdout.items, "x11")) |_| x11 = true; - if (std.mem.indexOf(u8, stdout.items, "wayland")) |_| wayland = true; - } else { - std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); - } - }, - inline else => |code| { - std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); - return error.Unexpected; - }, - } + // Ghostty docs + if (config.emit_docs) { + const docs = try buildpkg.GhosttyDocs.init(b, &deps); + docs.install(); } - config.x11 = b.option( - bool, - "gtk-x11", - "Enables linking against X11 libraries when using the GTK rendering backend.", - ) orelse x11; + // Ghostty webdata + if (config.emit_webdata) { + const webdata = try buildpkg.GhosttyWebdata.init(b, &deps); + webdata.install(); + } - config.wayland = b.option( - bool, - "gtk-wayland", - "Enables linking against Wayland libraries when using the GTK rendering backend.", - ) orelse wayland; + // Ghostty bench tools + if (config.emit_bench) { + const bench = try buildpkg.GhosttyBench.init(b, &deps); + bench.install(); + } - config.sentry = b.option( - bool, - "sentry", - "Build with Sentry crash reporting. Default for macOS is true, false for any other system.", - ) orelse sentry: { - switch (target.result.os.tag) { - .macos, .ios => break :sentry true, + // If we're not building libghostty, then install the exe and resources. + if (config.app_runtime != .none) { + exe.install(); + resources.install(); + } - // Note its false for linux because the crash reports on Linux - // don't have much useful information. - else => break :sentry false, - } - }; + // Libghostty + // + // Note: libghostty is not stable for general purpose use. It is used + // heavily by Ghostty on macOS but it isn't built to be reusable yet. + // As such, these build steps are lacking. For example, the Darwin + // build only produces an xcframework. + if (config.app_runtime == .none) { + if (config.target.result.isDarwin()) darwin: { + if (!config.emit_xcframework) break :darwin; - const pie = b.option( - bool, - "pie", - "Build a Position Independent Executable. Default true for system packages.", - ) orelse system_package; + // Build the xcframework + const xcframework = try buildpkg.GhosttyXCFramework.init(b, &deps); + xcframework.install(); - const strip = b.option( - bool, - "strip", - "Strip the final executable. Default true for fast and small releases", - ) orelse switch (optimize) { - .Debug => false, - .ReleaseSafe => false, - .ReleaseFast, .ReleaseSmall => true, - }; + // The xcframework build always installs resources because our + // macOS xcode project contains references to them. + resources.install(); - const conformance = b.option( - []const u8, - "conformance", - "Name of the conformance app to run with 'run' option.", - ); - - const emit_test_exe = b.option( - bool, - "emit-test-exe", - "Build and install test executables with 'build'", - ) orelse false; - - const emit_bench = b.option( - bool, - "emit-bench", - "Build and install the benchmark executables.", - ) orelse false; - - const emit_helpgen = b.option( - bool, - "emit-helpgen", - "Build and install the helpgen executable.", - ) orelse false; - - const emit_docs = b.option( - bool, - "emit-docs", - "Build and install auto-generated documentation (requires pandoc)", - ) orelse emit_docs: { - // If we are emitting any other artifacts then we default to false. - if (emit_bench or emit_test_exe or emit_helpgen) break :emit_docs false; - - // We always emit docs in system package mode. - if (system_package) break :emit_docs true; - - // We only default to true if we can find pandoc. - const path = Command.expandPath(b.allocator, "pandoc") catch - break :emit_docs false; - defer if (path) |p| b.allocator.free(p); - break :emit_docs path != null; - }; - - const emit_webdata = b.option( - bool, - "emit-webdata", - "Build the website data for the website.", - ) orelse false; - - const emit_xcframework = b.option( - bool, - "emit-xcframework", - "Build and install the xcframework for the macOS library.", - ) orelse builtin.target.isDarwin() and - target.result.os.tag == .macos and - config.app_runtime == .none and - (!emit_bench and !emit_test_exe and !emit_helpgen); - - // On NixOS, the built binary from `zig build` needs to patch the rpath - // into the built binary for it to be portable across the NixOS system - // it was built for. We default this to true if we can detect we're in - // a Nix shell and have LD_LIBRARY_PATH set. - const patch_rpath: ?[]const u8 = b.option( - []const u8, - "patch-rpath", - "Inject the LD_LIBRARY_PATH as the rpath in the built binary. " ++ - "This defaults to LD_LIBRARY_PATH if we're in a Nix shell environment on NixOS.", - ) orelse patch_rpath: { - // We only do the patching if we're targeting our own CPU and its Linux. - if (!(target.result.os.tag == .linux) or !target.query.isNativeCpu()) break :patch_rpath null; - - // If we're in a nix shell we default to doing this. - // Note: we purposely never deinit envmap because we leak the strings - if (env.get("IN_NIX_SHELL") == null) break :patch_rpath null; - break :patch_rpath env.get("LD_LIBRARY_PATH"); - }; - - const version_string = b.option( - []const u8, - "version-string", - "A specific version string to use for the build. " ++ - "If not specified, git will be used. This must be a semantic version.", - ); - - config.version = if (version_string) |v| - try std.SemanticVersion.parse(v) - else version: { - const vsn = Version.detect(b) catch |err| switch (err) { - // If Git isn't available we just make an unknown dev version. - error.GitNotFound, - error.GitNotRepository, - => break :version .{ - .major = app_version.major, - .minor = app_version.minor, - .patch = app_version.patch, - .pre = "dev", - .build = "0000000", - }, - - else => return err, - }; - if (vsn.tag) |tag| { - // Tip releases behave just like any other pre-release so we skip. - if (!std.mem.eql(u8, tag, "tip")) { - const expected = b.fmt("v{d}.{d}.{d}", .{ - app_version.major, - app_version.minor, - app_version.patch, - }); - - if (!std.mem.eql(u8, tag, expected)) { - @panic("tagged releases must be in vX.Y.Z format matching build.zig"); - } - - break :version .{ - .major = app_version.major, - .minor = app_version.minor, - .patch = app_version.patch, - }; + // If we aren't emitting docs we need to emit a placeholder so + // our macOS xcodeproject builds. + if (!config.emit_docs) { + var wf = b.addWriteFiles(); + const path = "share/man/.placeholder"; + const placeholder = wf.add(path, "emit-docs not true so no man pages"); + b.getInstallStep().dependOn(&b.addInstallFile(placeholder, path).step); } + } else { + const libghostty_shared = try buildpkg.GhosttyLib.initShared(b, &deps); + const libghostty_static = try buildpkg.GhosttyLib.initStatic(b, &deps); + libghostty_shared.installHeader(); // Only need one header + libghostty_shared.install("libghostty.so"); + libghostty_static.install("libghostty.a"); } + } - break :version .{ - .major = app_version.major, - .minor = app_version.minor, - .patch = app_version.patch, - .pre = vsn.branch, - .build = vsn.short_hash, - }; - }; - - // These are all our dependencies that can be used with system - // packages if they exist. We set them up here so that we can set - // their defaults early. The first call configures the integration and - // subsequent calls just return the configured value. + // Run runs the Ghostty exe { - // These dependencies we want to default false if we're on macOS. - // On macOS we don't want to use system libraries because we - // generally want a fat binary. This can be overridden with the - // `-fsys` flag. - for (&[_][]const u8{ - "freetype", - "harfbuzz", - "fontconfig", - "libpng", - "zlib", - "oniguruma", - }) |dep| { - _ = b.systemIntegrationOption( - dep, - .{ - // If we're not on darwin we want to use whatever the - // default is via the system package mode - .default = if (target.result.isDarwin()) false else null, - }, - ); - } - - // These default to false because they're rarely available as - // system packages so we usually want to statically link them. - for (&[_][]const u8{ - "glslang", - "spirv-cross", - "simdutf", - }) |dep| { - _ = b.systemIntegrationOption(dep, .{ .default = false }); - } - } - - // We can use wasmtime to test wasm - b.enable_wasmtime = true; - - // Help exe. This must be run before any dependent executables because - // otherwise the build will be cached without emit. That's clunky but meh. - if (emit_helpgen) try addHelp(b, null, config); - - // Add our benchmarks - try benchSteps(b, target, config, emit_bench); - - // We only build an exe if we have a runtime set. - const exe_: ?*std.Build.Step.Compile = if (config.app_runtime != .none) b.addExecutable(.{ - .name = "ghostty", - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, - .strip = strip, - }) else null; - - // Exe - if (exe_) |exe| { - // Set PIE if requested - if (pie) exe.pie = true; - - // Add the shared dependencies - _ = try addDeps(b, exe, config); - - // If we're in NixOS but not in the shell environment then we issue - // a warning because the rpath may not be setup properly. - const is_nixos = is_nixos: { - if (target.result.os.tag != .linux) break :is_nixos false; - if (!target.query.isNativeCpu()) break :is_nixos false; - if (!target.query.isNativeOs()) break :is_nixos false; - break :is_nixos if (std.fs.accessAbsolute("/etc/NIXOS", .{})) true else |_| false; - }; - if (is_nixos and env.get("IN_NIX_SHELL") == null) { - try exe.step.addError( - "\x1b[" ++ color_map.get("yellow").? ++ - "\x1b[" ++ color_map.get("d").? ++ - \\Detected building on and for NixOS outside of the Nix shell environment. - \\ - \\The resulting ghostty binary will likely fail on launch because it is - \\unable to dynamically load the windowing libs (X11, Wayland, etc.). - \\We highly recommend running only within the Nix build environment - \\and the resulting binary will be portable across your system. - \\ - \\To run in the Nix build environment, use the following command. - \\Append any additional options like (`-Doptimize` flags). The resulting - \\binary will be in zig-out as usual. - \\ - \\ nix develop -c zig build - \\ - ++ - "\x1b[0m", - .{}, - ); - } - - if (target.result.os.tag == .windows) { - exe.subsystem = .Windows; - exe.addWin32ResourceFile(.{ - .file = b.path("dist/windows/ghostty.rc"), - }); - } - - // If we're installing, we get the install step so we can add - // additional dependencies to it. - const install_step = if (config.app_runtime != .none) step: { - const step = b.addInstallArtifact(exe, .{}); - b.getInstallStep().dependOn(&step.step); - break :step step; - } else null; - - // Patch our rpath if that option is specified. - if (patch_rpath) |rpath| { - if (rpath.len > 0) { - const run = RunStep.create(b, "patchelf rpath"); - run.addArgs(&.{ "patchelf", "--set-rpath", rpath }); - run.addArtifactArg(exe); - - if (install_step) |step| { - step.step.dependOn(&run.step); - } - } - } - - // App (Mac) - if (target.result.os.tag == .macos) { - const bin_install = b.addInstallFile( - exe.getEmittedBin(), - "Ghostty.app/Contents/MacOS/ghostty", - ); - b.getInstallStep().dependOn(&bin_install.step); - b.installFile("dist/macos/Info.plist", "Ghostty.app/Contents/Info.plist"); - b.installFile("dist/macos/Ghostty.icns", "Ghostty.app/Contents/Resources/Ghostty.icns"); - } - } - - // Shell-integration - { - const install = b.addInstallDirectory(.{ - .source_dir = b.path("src/shell-integration"), - .install_dir = .{ .custom = "share" }, - .install_subdir = b.pathJoin(&.{ "ghostty", "shell-integration" }), - .exclude_extensions = &.{".md"}, - }); - b.getInstallStep().dependOn(&install.step); - - if (target.result.os.tag == .macos and exe_ != null) { - const mac_install = b.addInstallDirectory(options: { - var copy = install.options; - copy.install_dir = .{ - .custom = "Ghostty.app/Contents/Resources", - }; - break :options copy; - }); - b.getInstallStep().dependOn(&mac_install.step); - } - } - - // Themes - { - const upstream = b.dependency("iterm2_themes", .{}); - const install = b.addInstallDirectory(.{ - .source_dir = upstream.path("ghostty"), - .install_dir = .{ .custom = "share" }, - .install_subdir = b.pathJoin(&.{ "ghostty", "themes" }), - .exclude_extensions = &.{".md"}, - }); - b.getInstallStep().dependOn(&install.step); - - if (target.result.os.tag == .macos and exe_ != null) { - const mac_install = b.addInstallDirectory(options: { - var copy = install.options; - copy.install_dir = .{ - .custom = "Ghostty.app/Contents/Resources", - }; - break :options copy; - }); - b.getInstallStep().dependOn(&mac_install.step); - } - } - - // Terminfo - { - // Encode our terminfo - var str = std.ArrayList(u8).init(b.allocator); - defer str.deinit(); - try terminfo.ghostty.encode(str.writer()); - - // Write it - var wf = b.addWriteFiles(); - const src_source = wf.add("share/terminfo/ghostty.terminfo", str.items); - const src_install = b.addInstallFile(src_source, "share/terminfo/ghostty.terminfo"); - b.getInstallStep().dependOn(&src_install.step); - if (target.result.os.tag == .macos and exe_ != null) { - const mac_src_install = b.addInstallFile( - src_source, - "Ghostty.app/Contents/Resources/terminfo/ghostty.terminfo", - ); - b.getInstallStep().dependOn(&mac_src_install.step); - } - - // Convert to termcap source format if thats helpful to people and - // install it. The resulting value here is the termcap source in case - // that is used for other commands. - if (target.result.os.tag != .windows) { - const run_step = RunStep.create(b, "infotocap"); - run_step.addArg("infotocap"); - run_step.addFileArg(src_source); - const out_source = run_step.captureStdOut(); - _ = run_step.captureStdErr(); // so we don't see stderr - - const cap_install = b.addInstallFile(out_source, "share/terminfo/ghostty.termcap"); - b.getInstallStep().dependOn(&cap_install.step); - - if (target.result.os.tag == .macos and exe_ != null) { - const mac_cap_install = b.addInstallFile( - out_source, - "Ghostty.app/Contents/Resources/terminfo/ghostty.termcap", - ); - b.getInstallStep().dependOn(&mac_cap_install.step); - } - } - - // Compile the terminfo source into a terminfo database - if (target.result.os.tag != .windows) { - const run_step = RunStep.create(b, "tic"); - run_step.addArgs(&.{ "tic", "-x", "-o" }); - const path = run_step.addOutputFileArg("terminfo"); - run_step.addFileArg(src_source); - _ = run_step.captureStdErr(); // so we don't see stderr - - // Depend on the terminfo source install step so that Zig build - // creates the "share" directory for us. - run_step.step.dependOn(&src_install.step); - - { - // Use cp -R instead of Step.InstallDir because we need to preserve - // symlinks in the terminfo database. Zig's InstallDir step doesn't - // handle symlinks correctly yet. - const copy_step = RunStep.create(b, "copy terminfo db"); - copy_step.addArgs(&.{ "cp", "-R" }); - copy_step.addFileArg(path); - copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); - b.getInstallStep().dependOn(©_step.step); - } - - if (target.result.os.tag == .macos and exe_ != null) { - // Use cp -R instead of Step.InstallDir because we need to preserve - // symlinks in the terminfo database. Zig's InstallDir step doesn't - // handle symlinks correctly yet. - const copy_step = RunStep.create(b, "copy terminfo db"); - copy_step.addArgs(&.{ "cp", "-R" }); - copy_step.addFileArg(path); - copy_step.addArg( - b.fmt("{s}/Ghostty.app/Contents/Resources", .{b.install_path}), - ); - b.getInstallStep().dependOn(©_step.step); - } - } - } - - // Fish shell completions - { - const wf = b.addWriteFiles(); - _ = wf.add("ghostty.fish", fish_completions.fish_completions); - - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/fish/vendor_completions.d", - }); - } - - // zsh shell completions - { - const wf = b.addWriteFiles(); - _ = wf.add("_ghostty", zsh_completions.zsh_completions); - - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/zsh/site-functions", - }); - } - - // bash shell completions - { - const wf = b.addWriteFiles(); - _ = wf.add("ghostty.bash", bash_completions.bash_completions); - - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/bash-completion/completions", - }); - } - - // Vim plugin - { - const wf = b.addWriteFiles(); - _ = wf.add("syntax/ghostty.vim", config_vim.syntax); - _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); - _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); - _ = wf.add("compiler/ghostty.vim", config_vim.compiler); - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/vim/vimfiles", - }); - } - - // Neovim plugin - // This is just a copy-paste of the Vim plugin, but using a Neovim subdir. - // By default, Neovim doesn't look inside share/vim/vimfiles. Some distros - // configure it to do that however. Fedora, does not as a counterexample. - { - const wf = b.addWriteFiles(); - _ = wf.add("syntax/ghostty.vim", config_vim.syntax); - _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); - _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); - _ = wf.add("compiler/ghostty.vim", config_vim.compiler); - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/nvim/site", - }); - } - - // Sublime syntax highlighting for bat cli tool - // NOTE: The current implementation requires symlinking the generated - // 'ghostty.sublime-syntax' file from zig-out to the '~.config/bat/syntaxes' - // directory. The syntax then needs to be mapped to the correct language in - // the config file within the '~.config/bat' directory - // (ex: --map-syntax "/Users/user/.config/ghostty/config:Ghostty Config"). - { - const wf = b.addWriteFiles(); - _ = wf.add("ghostty.sublime-syntax", config_sublime_syntax.syntax); - b.installDirectory(.{ - .source_dir = wf.getDirectory(), - .install_dir = .prefix, - .install_subdir = "share/bat/syntaxes", - }); - } - - // Documentation - if (emit_docs) { - try buildDocumentation(b, config); - } else { - // We need to create the zig-out/share/man directory so that - // macOS builds continue to work even if emit-docs doesn't - // work. - var wf = b.addWriteFiles(); - const path = "share/man/.placeholder"; - const placeholder = wf.add(path, "emit-docs not true so no man pages"); - b.getInstallStep().dependOn(&b.addInstallFile(placeholder, path).step); - } - - // Web data - if (emit_webdata) { - try buildWebData(b, config); - } - - // App (Linux) - if (target.result.os.tag == .linux and config.app_runtime != .none) { - // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html - - // Desktop file so that we have an icon and other metadata - b.installFile("dist/linux/app.desktop", "share/applications/com.mitchellh.ghostty.desktop"); - - // Right click menu action for Plasma desktop - b.installFile("dist/linux/ghostty_dolphin.desktop", "share/kio/servicemenus/com.mitchellh.ghostty.desktop"); - - // Various icons that our application can use, including the icon - // that will be used for the desktop. - b.installFile("images/icons/icon_16.png", "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_32.png", "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_128.png", "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_256.png", "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_512.png", "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"); - - // Flatpaks only support icons up to 512x512. - if (!config.flatpak) { - b.installFile("images/icons/icon_1024.png", "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png"); - } - - b.installFile("images/icons/icon_16@2x.png", "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_32@2x.png", "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_128@2x.png", "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png"); - b.installFile("images/icons/icon_256@2x.png", "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png"); - } - - // libghostty (non-Darwin) - if (!builtin.target.isDarwin() and config.app_runtime == .none) { - // Shared - { - const lib = b.addSharedLibrary(.{ - .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .optimize = optimize, - .target = target, - .strip = strip, - }); - _ = try addDeps(b, lib, config); - - const lib_install = b.addInstallLibFile( - lib.getEmittedBin(), - "libghostty.so", - ); - b.getInstallStep().dependOn(&lib_install.step); - } - - // Static - { - const lib = b.addStaticLibrary(.{ - .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .optimize = optimize, - .target = target, - .strip = strip, - }); - _ = try addDeps(b, lib, config); - - const lib_install = b.addInstallLibFile( - lib.getEmittedBin(), - "libghostty.a", - ); - b.getInstallStep().dependOn(&lib_install.step); - } - - // Copy our ghostty.h to include. - const header_install = b.addInstallHeaderFile( - b.path("include/ghostty.h"), - "ghostty.h", - ); - b.getInstallStep().dependOn(&header_install.step); - } - - // On Mac we can build the embedding library. This only handles the macOS lib. - if (emit_xcframework) { - // Create the universal macOS lib. - const macos_lib_step, const macos_lib_path = try createMacOSLib( - b, - optimize, - config, - ); - - // Add our library to zig-out - const lib_install = b.addInstallLibFile( - macos_lib_path, - "libghostty-macos.a", - ); - b.getInstallStep().dependOn(&lib_install.step); - - // Create the universal iOS lib. - const ios_lib_step, const ios_lib_path = try createIOSLib( - b, - null, - optimize, - config, - ); - - // Add our library to zig-out - const ios_lib_install = b.addInstallLibFile( - ios_lib_path, - "libghostty-ios.a", - ); - b.getInstallStep().dependOn(&ios_lib_install.step); - - // Create the iOS simulator lib. - const ios_sim_lib_step, const ios_sim_lib_path = try createIOSLib( - b, - .simulator, - optimize, - config, - ); - - // Add our library to zig-out - const ios_sim_lib_install = b.addInstallLibFile( - ios_sim_lib_path, - "libghostty-ios-simulator.a", - ); - b.getInstallStep().dependOn(&ios_sim_lib_install.step); - - // Copy our ghostty.h to include. The header file is shared by - // all embedded targets. - const header_install = b.addInstallHeaderFile( - b.path("include/ghostty.h"), - "ghostty.h", - ); - b.getInstallStep().dependOn(&header_install.step); - - // The xcframework wraps our ghostty library so that we can link - // it to the final app built with Swift. - const xcframework = XCFrameworkStep.create(b, .{ - .name = "GhosttyKit", - .out_path = "macos/GhosttyKit.xcframework", - .libraries = &.{ - .{ - .library = macos_lib_path, - .headers = b.path("include"), - }, - .{ - .library = ios_lib_path, - .headers = b.path("include"), - }, - .{ - .library = ios_sim_lib_path, - .headers = b.path("include"), - }, - }, - }); - xcframework.step.dependOn(ios_lib_step); - xcframework.step.dependOn(ios_sim_lib_step); - xcframework.step.dependOn(macos_lib_step); - xcframework.step.dependOn(&header_install.step); - b.default_step.dependOn(xcframework.step); - } - - // wasm - { - // Build our Wasm target. - const wasm_crosstarget: std.Target.Query = .{ - .cpu_arch = .wasm32, - .os_tag = .freestanding, - .cpu_model = .{ .explicit = &std.Target.wasm.cpu.mvp }, - .cpu_features_add = std.Target.wasm.featureSet(&.{ - // We use this to explicitly request shared memory. - .atomics, - - // Not explicitly used but compiler could use them if they want. - .bulk_memory, - .reference_types, - .sign_ext, - }), - }; - - // Whether we're using wasm shared memory. Some behaviors change. - // For now we require this but I wanted to make the code handle both - // up front. - const wasm_shared: bool = true; - - // Modify our build configuration for wasm builds. - const wasm_config: BuildConfig = config: { - var copy = config; - - // Backends that are fixed for wasm - copy.font_backend = .web_canvas; - - // Wasm-specific options - copy.wasm_shared = wasm_shared; - copy.wasm_target = wasm_target; - - break :config copy; - }; - - const wasm = b.addSharedLibrary(.{ - .name = "ghostty-wasm", - .root_source_file = b.path("src/main_wasm.zig"), - .target = b.resolveTargetQuery(wasm_crosstarget), - .optimize = optimize, - }); - - // So that we can use web workers with our wasm binary - wasm.import_memory = true; - wasm.initial_memory = 65536 * 25; - wasm.max_memory = 65536 * 65536; // Maximum number of pages in wasm32 - wasm.shared_memory = wasm_shared; - - // Stack protector adds extern requirements that we don't satisfy. - wasm.root_module.stack_protector = false; - - // Wasm-specific deps - _ = try addDeps(b, wasm, wasm_config); - - // Install - const wasm_install = b.addInstallArtifact(wasm, .{}); - wasm_install.dest_dir = .{ .prefix = {} }; - - const step = b.step("wasm", "Build the wasm library"); - step.dependOn(&wasm_install.step); - - // We support tests via wasmtime. wasmtime uses WASI so this - // isn't an exact match to our freestanding target above but - // it lets us test some basic functionality. - const test_step = b.step("test-wasm", "Run all tests for wasm"); - const main_test = b.addTest(.{ - .name = "wasm-test", - .root_source_file = b.path("src/main_wasm.zig"), - .target = b.resolveTargetQuery(wasm_crosstarget), - }); - - _ = try addDeps(b, main_test, wasm_config); - test_step.dependOn(&main_test.step); - } - - // Run - run: { - // Build our run step, which runs the main app by default, but will - // run a conformance app if `-Dconformance` is set. - const run_exe = if (conformance) |name| blk: { - var conformance_exes = try conformanceSteps(b, target, optimize); - defer conformance_exes.deinit(); - break :blk conformance_exes.get(name) orelse return error.InvalidConformance; - } else exe_ orelse break :run; - - const run_cmd = b.addRunArtifact(run_exe); - if (b.args) |args| { - run_cmd.addArgs(args); - } - + const run_cmd = b.addRunArtifact(exe.exe); + if (b.args) |args| run_cmd.addArgs(args); const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); } @@ -945,943 +103,18 @@ pub fn build(b: *std.Build) !void { const test_step = b.step("test", "Run all tests"); const test_filter = b.option([]const u8, "test-filter", "Filter for test"); - // Force all Mac builds to use a `generic` CPU. This avoids - // potential issues with `highway` compile errors due to missing - // `arm_neon` features (see for example https://github.com/mitchellh/ghostty/issues/1640). - const test_target = if (target.result.os.tag == .macos and builtin.target.isDarwin()) - genericMacOSTarget(b, null) - else - target; - - const main_test = b.addTest(.{ + const test_exe = b.addTest(.{ .name = "ghostty-test", .root_source_file = b.path("src/main.zig"), - .target = test_target, + .target = config.target, .filter = test_filter, }); { - if (emit_test_exe) b.installArtifact(main_test); - _ = try addDeps(b, main_test, config); - const test_run = b.addRunArtifact(main_test); + if (config.emit_test_exe) b.installArtifact(test_exe); + _ = try deps.add(test_exe); + const test_run = b.addRunArtifact(test_exe); test_step.dependOn(&test_run.step); } } } - -/// Returns the minimum OS version for the given OS tag. This shouldn't -/// be used generally, it should only be used for Darwin-based OS currently. -fn osVersionMin(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion { - return switch (tag) { - // We support back to the earliest officially supported version - // of macOS by Apple. EOL versions are not supported. - .macos => .{ .semver = .{ - .major = 13, - .minor = 0, - .patch = 0, - } }, - - // iOS 17 picked arbitrarily - .ios => .{ .semver = .{ - .major = 17, - .minor = 0, - .patch = 0, - } }, - - // This should never happen currently. If we add a new target then - // we should add a new case here. - else => null, - }; -} - -// Returns a ResolvedTarget for a mac with a `target.result.cpu.model.name` of `generic`. -// `b.standardTargetOptions()` returns a more specific cpu like `apple_a15`. -fn genericMacOSTarget(b: *std.Build, arch: ?std.Target.Cpu.Arch) ResolvedTarget { - return b.resolveTargetQuery(.{ - .cpu_arch = arch orelse builtin.target.cpu.arch, - .os_tag = .macos, - .os_version_min = osVersionMin(.macos), - }); -} - -/// Creates a universal macOS libghostty library and returns the path -/// to the final library. -/// -/// The library is always a fat static library currently because this is -/// expected to be used directly with Xcode and Swift. In the future, we -/// probably want to change this because it makes it harder to use the -/// library in other contexts. -fn createMacOSLib( - b: *std.Build, - optimize: std.builtin.OptimizeMode, - config: BuildConfig, -) !struct { *std.Build.Step, std.Build.LazyPath } { - const static_lib_aarch64 = lib: { - const lib = b.addStaticLibrary(.{ - .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .target = genericMacOSTarget(b, .aarch64), - .optimize = optimize, - }); - lib.bundle_compiler_rt = true; - lib.linkLibC(); - - // Create a single static lib with all our dependencies merged - var lib_list = try addDeps(b, lib, config); - try lib_list.append(lib.getEmittedBin()); - const libtool = LibtoolStep.create(b, .{ - .name = "ghostty", - .out_name = "libghostty-aarch64-fat.a", - .sources = lib_list.items, - }); - libtool.step.dependOn(&lib.step); - b.default_step.dependOn(libtool.step); - - break :lib libtool; - }; - - const static_lib_x86_64 = lib: { - const lib = b.addStaticLibrary(.{ - .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .target = genericMacOSTarget(b, .x86_64), - .optimize = optimize, - }); - lib.bundle_compiler_rt = true; - lib.linkLibC(); - - // Create a single static lib with all our dependencies merged - var lib_list = try addDeps(b, lib, config); - try lib_list.append(lib.getEmittedBin()); - const libtool = LibtoolStep.create(b, .{ - .name = "ghostty", - .out_name = "libghostty-x86_64-fat.a", - .sources = lib_list.items, - }); - libtool.step.dependOn(&lib.step); - b.default_step.dependOn(libtool.step); - - break :lib libtool; - }; - - const static_lib_universal = LipoStep.create(b, .{ - .name = "ghostty", - .out_name = "libghostty.a", - .input_a = static_lib_aarch64.output, - .input_b = static_lib_x86_64.output, - }); - static_lib_universal.step.dependOn(static_lib_aarch64.step); - static_lib_universal.step.dependOn(static_lib_x86_64.step); - - return .{ - static_lib_universal.step, - static_lib_universal.output, - }; -} - -/// Create an Apple iOS/iPadOS build. -fn createIOSLib( - b: *std.Build, - abi: ?std.Target.Abi, - optimize: std.builtin.OptimizeMode, - config: BuildConfig, -) !struct { *std.Build.Step, std.Build.LazyPath } { - const lib = b.addStaticLibrary(.{ - .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .optimize = optimize, - .target = b.resolveTargetQuery(.{ - .cpu_arch = .aarch64, - .os_tag = .ios, - .os_version_min = osVersionMin(.ios), - .abi = abi, - }), - }); - lib.bundle_compiler_rt = true; - lib.linkLibC(); - - // Create a single static lib with all our dependencies merged - var lib_list = try addDeps(b, lib, config); - try lib_list.append(lib.getEmittedBin()); - const libtool = LibtoolStep.create(b, .{ - .name = "ghostty", - .out_name = "libghostty-ios-fat.a", - .sources = lib_list.items, - }); - libtool.step.dependOn(&lib.step); - - return .{ - libtool.step, - libtool.output, - }; -} - -/// Used to keep track of a list of file sources. -const LazyPathList = std.ArrayList(std.Build.LazyPath); - -/// Adds and links all of the primary dependencies for the exe. -fn addDeps( - b: *std.Build, - step: *std.Build.Step.Compile, - config: BuildConfig, -) !LazyPathList { - // All object targets get access to a standard build_options module - const exe_options = b.addOptions(); - try config.addOptions(exe_options); - step.root_module.addOptions("build_options", exe_options); - - // We maintain a list of our static libraries and return it so that - // we can build a single fat static library for the final app. - var static_libs = LazyPathList.init(b.allocator); - errdefer static_libs.deinit(); - - const target = step.root_module.resolved_target.?; - const optimize = step.root_module.optimize.?; - - // 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. - const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ - .preferred_link_mode = .dynamic, - .search_strategy = .mode_first, - }; - - // Freetype - _ = b.systemIntegrationOption("freetype", .{}); // Shows it in help - if (config.font_backend.hasFreetype()) { - const freetype_dep = b.dependency("freetype", .{ - .target = target, - .optimize = optimize, - .@"enable-libpng" = true, - }); - step.root_module.addImport("freetype", freetype_dep.module("freetype")); - - if (b.systemIntegrationOption("freetype", .{})) { - step.linkSystemLibrary2("bzip2", dynamic_link_opts); - step.linkSystemLibrary2("freetype2", dynamic_link_opts); - } else { - step.linkLibrary(freetype_dep.artifact("freetype")); - try static_libs.append(freetype_dep.artifact("freetype").getEmittedBin()); - } - } - - // Harfbuzz - _ = b.systemIntegrationOption("harfbuzz", .{}); // Shows it in help - if (config.font_backend.hasHarfbuzz()) { - const harfbuzz_dep = b.dependency("harfbuzz", .{ - .target = target, - .optimize = optimize, - .@"enable-freetype" = true, - .@"enable-coretext" = config.font_backend.hasCoretext(), - }); - - step.root_module.addImport( - "harfbuzz", - harfbuzz_dep.module("harfbuzz"), - ); - if (b.systemIntegrationOption("harfbuzz", .{})) { - step.linkSystemLibrary2("harfbuzz", dynamic_link_opts); - } else { - step.linkLibrary(harfbuzz_dep.artifact("harfbuzz")); - try static_libs.append(harfbuzz_dep.artifact("harfbuzz").getEmittedBin()); - } - } - - // Fontconfig - _ = b.systemIntegrationOption("fontconfig", .{}); // Shows it in help - if (config.font_backend.hasFontconfig()) { - const fontconfig_dep = b.dependency("fontconfig", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport( - "fontconfig", - fontconfig_dep.module("fontconfig"), - ); - - if (b.systemIntegrationOption("fontconfig", .{})) { - step.linkSystemLibrary2("fontconfig", dynamic_link_opts); - } else { - step.linkLibrary(fontconfig_dep.artifact("fontconfig")); - try static_libs.append(fontconfig_dep.artifact("fontconfig").getEmittedBin()); - } - } - - // Libpng - Ghostty doesn't actually use this directly, its only used - // through dependencies, so we only need to add it to our static - // libs list if we're not using system integration. The dependencies - // will handle linking it. - if (!b.systemIntegrationOption("libpng", .{})) { - const libpng_dep = b.dependency("libpng", .{ - .target = target, - .optimize = optimize, - }); - step.linkLibrary(libpng_dep.artifact("png")); - try static_libs.append(libpng_dep.artifact("png").getEmittedBin()); - } - - // Zlib - same as libpng, only used through dependencies. - if (!b.systemIntegrationOption("zlib", .{})) { - const zlib_dep = b.dependency("zlib", .{ - .target = target, - .optimize = optimize, - }); - step.linkLibrary(zlib_dep.artifact("z")); - try static_libs.append(zlib_dep.artifact("z").getEmittedBin()); - } - - // Oniguruma - const oniguruma_dep = b.dependency("oniguruma", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport("oniguruma", oniguruma_dep.module("oniguruma")); - if (b.systemIntegrationOption("oniguruma", .{})) { - step.linkSystemLibrary2("oniguruma", dynamic_link_opts); - } else { - step.linkLibrary(oniguruma_dep.artifact("oniguruma")); - try static_libs.append(oniguruma_dep.artifact("oniguruma").getEmittedBin()); - } - - // Glslang - const glslang_dep = b.dependency("glslang", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport("glslang", glslang_dep.module("glslang")); - if (b.systemIntegrationOption("glslang", .{})) { - step.linkSystemLibrary2("glslang", dynamic_link_opts); - step.linkSystemLibrary2("glslang-default-resource-limits", dynamic_link_opts); - } else { - step.linkLibrary(glslang_dep.artifact("glslang")); - try static_libs.append(glslang_dep.artifact("glslang").getEmittedBin()); - } - - // Spirv-cross - const spirv_cross_dep = b.dependency("spirv_cross", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport("spirv_cross", spirv_cross_dep.module("spirv_cross")); - if (b.systemIntegrationOption("spirv-cross", .{})) { - step.linkSystemLibrary2("spirv-cross", dynamic_link_opts); - } else { - step.linkLibrary(spirv_cross_dep.artifact("spirv_cross")); - try static_libs.append(spirv_cross_dep.artifact("spirv_cross").getEmittedBin()); - } - - // Simdutf - if (b.systemIntegrationOption("simdutf", .{})) { - step.linkSystemLibrary2("simdutf", dynamic_link_opts); - } else { - const simdutf_dep = b.dependency("simdutf", .{ - .target = target, - .optimize = optimize, - }); - step.linkLibrary(simdutf_dep.artifact("simdutf")); - try static_libs.append(simdutf_dep.artifact("simdutf").getEmittedBin()); - } - - // Sentry - if (config.sentry) { - const sentry_dep = b.dependency("sentry", .{ - .target = target, - .optimize = optimize, - .backend = .breakpad, - }); - - step.root_module.addImport("sentry", sentry_dep.module("sentry")); - - // Sentry - step.linkLibrary(sentry_dep.artifact("sentry")); - try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin()); - - // We also need to include breakpad in the static libs. - const breakpad_dep = sentry_dep.builder.dependency("breakpad", .{ - .target = target, - .optimize = optimize, - }); - try static_libs.append(breakpad_dep.artifact("breakpad").getEmittedBin()); - } - - // Wasm we do manually since it is such a different build. - if (step.rootModuleTarget().cpu.arch == .wasm32) { - const js_dep = b.dependency("zig_js", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport("zig-js", js_dep.module("zig-js")); - - return static_libs; - } - - // On Linux, we need to add a couple common library paths that aren't - // on the standard search list. i.e. GTK is often in /usr/lib/x86_64-linux-gnu - // on x86_64. - if (step.rootModuleTarget().os.tag == .linux) { - const triple = try step.rootModuleTarget().linuxTriple(b.allocator); - step.addLibraryPath(.{ .cwd_relative = b.fmt("/usr/lib/{s}", .{triple}) }); - } - - // C files - step.linkLibC(); - step.addIncludePath(b.path("src/stb")); - step.addCSourceFiles(.{ .files = &.{"src/stb/stb.c"} }); - if (step.rootModuleTarget().os.tag == .linux) { - step.addIncludePath(b.path("src/apprt/gtk")); - } - - // C++ files - step.linkLibCpp(); - step.addIncludePath(b.path("src")); - { - // From hwy/detect_targets.h - const HWY_AVX3_SPR: c_int = 1 << 4; - const HWY_AVX3_ZEN4: c_int = 1 << 6; - const HWY_AVX3_DL: c_int = 1 << 7; - const HWY_AVX3: c_int = 1 << 8; - - // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414 - // To workaround this we just disable AVX512 support completely. - // The performance difference between AVX2 and AVX512 is not - // significant for our use case and AVX512 is very rare on consumer - // hardware anyways. - const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; - - step.addCSourceFiles(.{ - .files = &.{ - "src/simd/base64.cpp", - "src/simd/codepoint_width.cpp", - "src/simd/index_of.cpp", - "src/simd/vt.cpp", - }, - .flags = if (step.rootModuleTarget().cpu.arch == .x86_64) &.{ - b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}), - } else &.{}, - }); - } - - // We always require the system SDK so that our system headers are available. - // This makes things like `os/log.h` available for cross-compiling. - if (step.rootModuleTarget().isDarwin()) { - try @import("apple_sdk").addPaths(b, &step.root_module); - try addMetallib(b, step); - } - - // Other dependencies, mostly pure Zig - step.root_module.addImport("opengl", b.dependency( - "opengl", - .{}, - ).module("opengl")); - step.root_module.addImport("vaxis", b.dependency("vaxis", .{ - .target = target, - .optimize = optimize, - }).module("vaxis")); - step.root_module.addImport("wuffs", b.dependency("wuffs", .{ - .target = target, - .optimize = optimize, - }).module("wuffs")); - step.root_module.addImport("xev", b.dependency("libxev", .{ - .target = target, - .optimize = optimize, - }).module("xev")); - step.root_module.addImport("z2d", b.addModule("z2d", .{ - .root_source_file = b.dependency("z2d", .{}).path("src/z2d.zig"), - .target = target, - .optimize = optimize, - })); - step.root_module.addImport("ziglyph", b.dependency("ziglyph", .{ - .target = target, - .optimize = optimize, - }).module("ziglyph")); - step.root_module.addImport("zf", b.dependency("zf", .{ - .target = target, - .optimize = optimize, - .with_tui = false, - }).module("zf")); - - // Mac Stuff - if (step.rootModuleTarget().isDarwin()) { - const objc_dep = b.dependency("zig_objc", .{ - .target = target, - .optimize = optimize, - }); - const macos_dep = b.dependency("macos", .{ - .target = target, - .optimize = optimize, - }); - - step.root_module.addImport("objc", objc_dep.module("objc")); - step.root_module.addImport("macos", macos_dep.module("macos")); - step.linkLibrary(macos_dep.artifact("macos")); - try static_libs.append(macos_dep.artifact("macos").getEmittedBin()); - - if (config.renderer == .opengl) { - step.linkFramework("OpenGL"); - } - } - - // cimgui - const cimgui_dep = b.dependency("cimgui", .{ - .target = target, - .optimize = optimize, - }); - step.root_module.addImport("cimgui", cimgui_dep.module("cimgui")); - step.linkLibrary(cimgui_dep.artifact("cimgui")); - try static_libs.append(cimgui_dep.artifact("cimgui").getEmittedBin()); - - // Highway - const highway_dep = b.dependency("highway", .{ - .target = target, - .optimize = optimize, - }); - step.linkLibrary(highway_dep.artifact("highway")); - try static_libs.append(highway_dep.artifact("highway").getEmittedBin()); - - // utfcpp - This is used as a dependency on our hand-written C++ code - const utfcpp_dep = b.dependency("utfcpp", .{ - .target = target, - .optimize = optimize, - }); - step.linkLibrary(utfcpp_dep.artifact("utfcpp")); - try static_libs.append(utfcpp_dep.artifact("utfcpp").getEmittedBin()); - - // If we're building an exe then we have additional dependencies. - if (step.kind != .lib) { - // We always statically compile glad - step.addIncludePath(b.path("vendor/glad/include/")); - step.addCSourceFile(.{ - .file = b.path("vendor/glad/src/gl.c"), - .flags = &.{}, - }); - - // When we're targeting flatpak we ALWAYS link GTK so we - // get access to glib for dbus. - if (config.flatpak) step.linkSystemLibrary2("gtk4", dynamic_link_opts); - - switch (config.app_runtime) { - .none => {}, - - .glfw => glfw: { - const mach_glfw_dep = b.lazyDependency("mach_glfw", .{ - .target = target, - .optimize = optimize, - }) orelse break :glfw; - step.root_module.addImport("glfw", mach_glfw_dep.module("mach-glfw")); - }, - - .gtk => { - step.linkSystemLibrary2("gtk4", dynamic_link_opts); - if (config.adwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts); - if (config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts); - - if (config.wayland) { - const scanner = Scanner.create(b, .{}); - - const wayland = b.createModule(.{ .root_source_file = scanner.result }); - - const plasma_wayland_protocols = b.dependency("plasma_wayland_protocols", .{ - .target = target, - .optimize = optimize, - }); - scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/blur.xml")); - - scanner.generate("wl_compositor", 1); - scanner.generate("org_kde_kwin_blur_manager", 1); - - step.root_module.addImport("wayland", wayland); - step.linkSystemLibrary2("wayland-client", dynamic_link_opts); - } - - { - const gresource = @import("src/apprt/gtk/gresource.zig"); - - const wf = b.addWriteFiles(); - const gresource_xml = wf.add("gresource.xml", gresource.gresource_xml); - - const generate_resources_c = b.addSystemCommand(&.{ - "glib-compile-resources", - "--c-name", - "ghostty", - "--generate-source", - "--target", - }); - const ghostty_resources_c = generate_resources_c.addOutputFileArg("ghostty_resources.c"); - generate_resources_c.addFileArg(gresource_xml); - generate_resources_c.extra_file_dependencies = &gresource.dependencies; - step.addCSourceFile(.{ .file = ghostty_resources_c, .flags = &.{} }); - - const generate_resources_h = b.addSystemCommand(&.{ - "glib-compile-resources", - "--c-name", - "ghostty", - "--generate-header", - "--target", - }); - const ghostty_resources_h = generate_resources_h.addOutputFileArg("ghostty_resources.h"); - generate_resources_h.addFileArg(gresource_xml); - generate_resources_h.extra_file_dependencies = &gresource.dependencies; - step.addIncludePath(ghostty_resources_h.dirname()); - } - }, - } - } - - try addHelp(b, step, config); - try addUnicodeTables(b, step); - - return static_libs; -} - -/// Generate Metal shader library -fn addMetallib( - b: *std.Build, - step: *std.Build.Step.Compile, -) !void { - const metal_step = MetallibStep.create(b, .{ - .name = "Ghostty", - .target = step.root_module.resolved_target.?, - .sources = &.{b.path("src/renderer/shaders/cell.metal")}, - }); - - metal_step.output.addStepDependencies(&step.step); - step.root_module.addAnonymousImport("ghostty_metallib", .{ - .root_source_file = metal_step.output, - }); -} - -/// Generate help files -fn addHelp( - b: *std.Build, - step_: ?*std.Build.Step.Compile, - config: BuildConfig, -) !void { - // Our static state between runs. We memoize our help strings - // so that we only execute the help generation once. - const HelpState = struct { - var generated: ?std.Build.LazyPath = null; - }; - - const help_output = HelpState.generated orelse strings: { - const help_exe = b.addExecutable(.{ - .name = "helpgen", - .root_source_file = b.path("src/helpgen.zig"), - .target = b.host, - }); - if (step_ == null) b.installArtifact(help_exe); - - const help_config = config: { - var copy = config; - copy.exe_entrypoint = .helpgen; - break :config copy; - }; - const options = b.addOptions(); - try help_config.addOptions(options); - help_exe.root_module.addOptions("build_options", options); - - const help_run = b.addRunArtifact(help_exe); - HelpState.generated = help_run.captureStdOut(); - break :strings HelpState.generated.?; - }; - - if (step_) |step| { - help_output.addStepDependencies(&step.step); - step.root_module.addAnonymousImport("help_strings", .{ - .root_source_file = help_output, - }); - } -} - -/// Generate unicode fast lookup tables -fn addUnicodeTables( - b: *std.Build, - step_: ?*std.Build.Step.Compile, -) !void { - // Our static state between runs. We memoize our output to gen once - const State = struct { - var generated: ?std.Build.LazyPath = null; - }; - - const output = State.generated orelse strings: { - const exe = b.addExecutable(.{ - .name = "unigen", - .root_source_file = b.path("src/unicode/props.zig"), - .target = b.host, - }); - exe.linkLibC(); - if (step_ == null) b.installArtifact(exe); - - const ziglyph_dep = b.dependency("ziglyph", .{ - .target = b.host, - }); - exe.root_module.addImport("ziglyph", ziglyph_dep.module("ziglyph")); - - const help_run = b.addRunArtifact(exe); - State.generated = help_run.captureStdOut(); - break :strings State.generated.?; - }; - - if (step_) |step| { - output.addStepDependencies(&step.step); - step.root_module.addAnonymousImport("unicode_tables", .{ - .root_source_file = output, - }); - } -} - -/// Generate documentation (manpages, etc.) from help strings -fn buildDocumentation( - b: *std.Build, - config: BuildConfig, -) !void { - const manpages = [_]struct { - name: []const u8, - section: []const u8, - }{ - .{ .name = "ghostty", .section = "1" }, - .{ .name = "ghostty", .section = "5" }, - }; - - inline for (manpages) |manpage| { - const generate_markdown = b.addExecutable(.{ - .name = "mdgen_" ++ manpage.name ++ "_" ++ manpage.section, - .root_source_file = b.path("src/main.zig"), - .target = b.host, - }); - try addHelp(b, generate_markdown, config); - - const gen_config = config: { - var copy = config; - copy.exe_entrypoint = @field( - build_config.ExeEntrypoint, - "mdgen_" ++ manpage.name ++ "_" ++ manpage.section, - ); - break :config copy; - }; - - const generate_markdown_options = b.addOptions(); - try gen_config.addOptions(generate_markdown_options); - generate_markdown.root_module.addOptions("build_options", generate_markdown_options); - - const generate_markdown_step = b.addRunArtifact(generate_markdown); - const markdown_output = generate_markdown_step.captureStdOut(); - - b.getInstallStep().dependOn(&b.addInstallFile( - markdown_output, - "share/ghostty/doc/" ++ manpage.name ++ "." ++ manpage.section ++ ".md", - ).step); - - const generate_html = b.addSystemCommand(&.{"pandoc"}); - generate_html.addArgs(&.{ - "--standalone", - "--from", - "markdown", - "--to", - "html", - }); - generate_html.addFileArg(markdown_output); - - b.getInstallStep().dependOn(&b.addInstallFile( - generate_html.captureStdOut(), - "share/ghostty/doc/" ++ manpage.name ++ "." ++ manpage.section ++ ".html", - ).step); - - const generate_manpage = b.addSystemCommand(&.{"pandoc"}); - generate_manpage.addArgs(&.{ - "--standalone", - "--from", - "markdown", - "--to", - "man", - }); - generate_manpage.addFileArg(markdown_output); - - b.getInstallStep().dependOn(&b.addInstallFile( - generate_manpage.captureStdOut(), - "share/man/man" ++ manpage.section ++ "/" ++ manpage.name ++ "." ++ manpage.section, - ).step); - } -} - -/// Generate the website reference data that we merge into the -/// official Ghostty website. This isn't meant to be part of any -/// actual build. -fn buildWebData( - b: *std.Build, - config: BuildConfig, -) !void { - { - const webgen_config = b.addExecutable(.{ - .name = "webgen_config", - .root_source_file = b.path("src/main.zig"), - .target = b.host, - }); - try addHelp(b, webgen_config, config); - - { - const buildconfig = config: { - var copy = config; - copy.exe_entrypoint = .webgen_config; - break :config copy; - }; - - const options = b.addOptions(); - try buildconfig.addOptions(options); - webgen_config.root_module.addOptions("build_options", options); - } - - const webgen_config_step = b.addRunArtifact(webgen_config); - const webgen_config_out = webgen_config_step.captureStdOut(); - - b.getInstallStep().dependOn(&b.addInstallFile( - webgen_config_out, - "share/ghostty/webdata/config.mdx", - ).step); - } - - { - const webgen_actions = b.addExecutable(.{ - .name = "webgen_actions", - .root_source_file = b.path("src/main.zig"), - .target = b.host, - }); - try addHelp(b, webgen_actions, config); - - { - const buildconfig = config: { - var copy = config; - copy.exe_entrypoint = .webgen_actions; - break :config copy; - }; - - const options = b.addOptions(); - try buildconfig.addOptions(options); - webgen_actions.root_module.addOptions("build_options", options); - } - - const webgen_actions_step = b.addRunArtifact(webgen_actions); - const webgen_actions_out = webgen_actions_step.captureStdOut(); - - b.getInstallStep().dependOn(&b.addInstallFile( - webgen_actions_out, - "share/ghostty/webdata/actions.mdx", - ).step); - } -} - -fn benchSteps( - b: *std.Build, - target: std.Build.ResolvedTarget, - config: BuildConfig, - install: bool, -) !void { - // Open the directory ./src/bench - const c_dir_path = (comptime root()) ++ "/src/bench"; - var c_dir = try fs.cwd().openDir(c_dir_path, .{ .iterate = true }); - defer c_dir.close(); - - // Go through and add each as a step - var c_dir_it = c_dir.iterate(); - while (try c_dir_it.next()) |entry| { - // Get the index of the last '.' so we can strip the extension. - const index = std.mem.lastIndexOfScalar(u8, entry.name, '.') orelse continue; - if (index == 0) continue; - - // If it doesn't end in 'zig' then ignore - if (!std.mem.eql(u8, entry.name[index + 1 ..], "zig")) continue; - - // Name of the conformance app and full path to the entrypoint. - const name = entry.name[0..index]; - - // Executable builder. - const bin_name = try std.fmt.allocPrint(b.allocator, "bench-{s}", .{name}); - const c_exe = b.addExecutable(.{ - .name = bin_name, - .root_source_file = b.path("src/main.zig"), - .target = target, - - // We always want our benchmarks to be in release mode. - .optimize = .ReleaseFast, - }); - c_exe.linkLibC(); - if (install) b.installArtifact(c_exe); - _ = try addDeps(b, c_exe, config: { - var copy = config; - var enum_name: [64]u8 = undefined; - @memcpy(enum_name[0..name.len], name); - std.mem.replaceScalar(u8, enum_name[0..name.len], '-', '_'); - - var buf: [64]u8 = undefined; - copy.exe_entrypoint = std.meta.stringToEnum( - build_config.ExeEntrypoint, - try std.fmt.bufPrint(&buf, "bench_{s}", .{enum_name[0..name.len]}), - ).?; - - break :config copy; - }); - } -} - -fn conformanceSteps( - b: *std.Build, - target: std.Build.ResolvedTarget, - optimize: std.builtin.Mode, -) !std.StringHashMap(*CompileStep) { - var map = std.StringHashMap(*CompileStep).init(b.allocator); - - // Open the directory ./conformance - const c_dir_path = (comptime root()) ++ "/conformance"; - var c_dir = try fs.cwd().openDir(c_dir_path, .{ .iterate = true }); - defer c_dir.close(); - - // Go through and add each as a step - var c_dir_it = c_dir.iterate(); - while (try c_dir_it.next()) |entry| { - // Get the index of the last '.' so we can strip the extension. - const index = std.mem.lastIndexOfScalar(u8, entry.name, '.') orelse continue; - if (index == 0) continue; - - // Name of the conformance app and full path to the entrypoint. - const name = try b.allocator.dupe(u8, entry.name[0..index]); - const path = try fs.path.join(b.allocator, &[_][]const u8{ - c_dir_path, - entry.name, - }); - - // Executable builder. - const c_exe = b.addExecutable(.{ - .name = name, - .root_source_file = b.path(path), - .target = target, - .optimize = optimize, - }); - - const install = b.addInstallArtifact(c_exe, .{}); - install.dest_sub_path = "conformance"; - b.getInstallStep().dependOn(&install.step); - - // Store the mapping - try map.put(name, c_exe); - } - - return map; -} - -/// Path to the directory with the build.zig. -fn root() []const u8 { - return std.fs.path.dirname(@src().file) orelse unreachable; -} - -/// ANSI escape codes for colored log output -const color_map = std.StaticStringMap([]const u8).initComptime(.{ - &.{ "black", "30m" }, - &.{ "blue", "34m" }, - &.{ "b", "1m" }, - &.{ "d", "2m" }, - &.{ "cyan", "36m" }, - &.{ "green", "32m" }, - &.{ "magenta", "35m" }, - &.{ "red", "31m" }, - &.{ "white", "37m" }, - &.{ "yellow", "33m" }, -}); diff --git a/conformance/ansi_ri.zig b/conformance/ansi_ri.zig deleted file mode 100644 index 991804295..000000000 --- a/conformance/ansi_ri.zig +++ /dev/null @@ -1,12 +0,0 @@ -//! Reverse Index (RI) - ESC M -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("A\nB\nC", .{}); - try stdout.print("\x1BM", .{}); - try stdout.print("D\n\n", .{}); - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/conformance/ansi_ri_top.zig b/conformance/ansi_ri_top.zig deleted file mode 100644 index b2258f069..000000000 --- a/conformance/ansi_ri_top.zig +++ /dev/null @@ -1,23 +0,0 @@ -//! Reverse Index (RI) - ESC M -//! Case: test that if the cursor is at the top, it scrolls down. -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("A\nB\n\n", .{}); - - try stdout.print("\x1B[H", .{}); // Top-left - try stdout.print("\x1BM", .{}); // Reverse-Index - try stdout.print("D", .{}); - - try stdout.print("\x0D", .{}); // CR - try stdout.print("\x0A", .{}); // LF - try stdout.print("\x1B[H", .{}); // Top-left - try stdout.print("\x1BM", .{}); // Reverse-Index - try stdout.print("E", .{}); - - try stdout.print("\n", .{}); - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/conformance/blocks.zig b/conformance/blocks.zig deleted file mode 100644 index a977ca4ea..000000000 --- a/conformance/blocks.zig +++ /dev/null @@ -1,99 +0,0 @@ -//! Outputs various box glyphs for testing. -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - - // Box Drawing - { - try stdout.print("\x1b[4mBox Drawing\x1b[0m\n", .{}); - var i: usize = 0x2500; - const step: usize = 32; - while (i <= 0x257F) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - try stdout.print("{u} ", .{@as(u21, @intCast(i + j))}); - } - - try stdout.print("\n\n", .{}); - } - } - - // Block Elements - { - try stdout.print("\x1b[4mBlock Elements\x1b[0m\n", .{}); - var i: usize = 0x2580; - const step: usize = 32; - while (i <= 0x259f) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - try stdout.print("{u} ", .{@as(u21, @intCast(i + j))}); - } - - try stdout.print("\n\n", .{}); - } - } - - // Braille Elements - { - try stdout.print("\x1b[4mBraille\x1b[0m\n", .{}); - var i: usize = 0x2800; - const step: usize = 32; - while (i <= 0x28FF) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - try stdout.print("{u} ", .{@as(u21, @intCast(i + j))}); - } - - try stdout.print("\n\n", .{}); - } - } - - { - try stdout.print("\x1b[4mSextants\x1b[0m\n", .{}); - var i: usize = 0x1FB00; - const step: usize = 32; - const end = 0x1FB3B; - while (i <= end) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - const v = i + j; - if (v <= end) try stdout.print("{u} ", .{@as(u21, @intCast(v))}); - } - - try stdout.print("\n\n", .{}); - } - } - - { - try stdout.print("\x1b[4mWedge Triangles\x1b[0m\n", .{}); - var i: usize = 0x1FB3C; - const step: usize = 32; - const end = 0x1FB6B; - while (i <= end) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - const v = i + j; - if (v <= end) try stdout.print("{u} ", .{@as(u21, @intCast(v))}); - } - - try stdout.print("\n\n", .{}); - } - } - - { - try stdout.print("\x1b[4mOther\x1b[0m\n", .{}); - var i: usize = 0x1FB70; - const step: usize = 32; - const end = 0x1FB8B; - while (i <= end) : (i += step) { - var j: usize = 0; - while (j < step) : (j += 1) { - const v = i + j; - if (v <= end) try stdout.print("{u} ", .{@as(u21, @intCast(v))}); - } - - try stdout.print("\n\n", .{}); - } - } -} diff --git a/conformance/csi_decstbm.zig b/conformance/csi_decstbm.zig deleted file mode 100644 index f8b652427..000000000 --- a/conformance/csi_decstbm.zig +++ /dev/null @@ -1,15 +0,0 @@ -//! Set Top and Bottom Margins (DECSTBM) - ESC [ r -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("A\nB\nC\nD", .{}); - try stdout.print("\x1B[1;3r", .{}); // cursor up - try stdout.print("\x1B[1;1H", .{}); // top-left - try stdout.print("\x1B[M", .{}); // delete line - try stdout.print("E\n", .{}); - try stdout.print("\x1B[7;1H", .{}); // cursor up - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/conformance/csi_dl.zig b/conformance/csi_dl.zig deleted file mode 100644 index 175637d9b..000000000 --- a/conformance/csi_dl.zig +++ /dev/null @@ -1,14 +0,0 @@ -//! Delete Line (DL) - Esc [ M -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("A\nB\nC\nD", .{}); - try stdout.print("\x1B[2A", .{}); // cursor up - try stdout.print("\x1B[M", .{}); - try stdout.print("E\n", .{}); - try stdout.print("\x1B[B", .{}); - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/conformance/csi_il.zig b/conformance/csi_il.zig deleted file mode 100644 index 52d2c392f..000000000 --- a/conformance/csi_il.zig +++ /dev/null @@ -1,17 +0,0 @@ -//! Insert Line (IL) - Esc [ L -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("\x1B[2J", .{}); // clear screen - try stdout.print("\x1B[1;1H", .{}); // set cursor position - try stdout.print("A\nB\nC\nD\nE", .{}); - try stdout.print("\x1B[1;2r", .{}); // set scroll region - try stdout.print("\x1B[1;1H", .{}); // set cursor position - try stdout.print("\x1B[1L", .{}); // insert lines - try stdout.print("X", .{}); - try stdout.print("\x1B[7;1H", .{}); // set cursor position - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/conformance/esc_decaln.zig b/conformance/esc_decaln.zig deleted file mode 100644 index aeb1887a4..000000000 --- a/conformance/esc_decaln.zig +++ /dev/null @@ -1,10 +0,0 @@ -//! DECALN - ESC # 8 -const std = @import("std"); - -pub fn main() !void { - const stdout = std.io.getStdOut().writer(); - try stdout.print("\x1B#8", .{}); - - // const stdin = std.io.getStdIn().reader(); - // _ = try stdin.readByte(); -} diff --git a/nix/package.nix b/nix/package.nix index 166a3c4fb..2f7825a56 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -53,7 +53,6 @@ fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ../.)) ( lib.fileset.unions [ ../dist/linux - ../conformance ../images ../include ../pkg diff --git a/src/build/Config.zig b/src/build/Config.zig new file mode 100644 index 000000000..71dffce4a --- /dev/null +++ b/src/build/Config.zig @@ -0,0 +1,503 @@ +/// Build configuration. This is the configuration that is populated +/// during `zig build` to control the rest of the build process. +const Config = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); + +const apprt = @import("../apprt.zig"); +const font = @import("../font/main.zig"); +const renderer = @import("../renderer.zig"); +const Command = @import("../Command.zig"); +const WasmTarget = @import("../os/wasm/target.zig").Target; + +const gtk = @import("gtk.zig"); +const GitVersion = @import("GitVersion.zig"); + +/// The version of the next release. +/// +/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly. +/// Until then this MUST match build.zig.zon and should always be the +/// _next_ version to release. +const app_version: std.SemanticVersion = .{ .major = 1, .minor = 0, .patch = 2 }; + +/// Standard build configuration options. +optimize: std.builtin.OptimizeMode, +target: std.Build.ResolvedTarget, +wasm_target: WasmTarget, + +/// Comptime interfaces +app_runtime: apprt.Runtime = .none, +renderer: renderer.Impl = .opengl, +font_backend: font.Backend = .freetype, + +/// Feature flags +adwaita: bool = false, +x11: bool = false, +wayland: bool = false, +sentry: bool = true, +wasm_shared: bool = true, + +/// Ghostty exe properties +exe_entrypoint: ExeEntrypoint = .ghostty, +version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + +/// Binary properties +pie: bool = false, +strip: bool = false, +patch_rpath: ?[]const u8 = null, + +/// Artifacts +flatpak: bool = false, +emit_test_exe: bool = false, +emit_bench: bool = false, +emit_helpgen: bool = false, +emit_docs: bool = false, +emit_webdata: bool = false, +emit_xcframework: bool = false, + +/// Environmental properties +env: std.process.EnvMap, + +pub fn init(b: *std.Build) !Config { + // Setup our standard Zig target and optimize options, i.e. + // `-Doptimize` and `-Dtarget`. + const optimize = b.standardOptimizeOption(.{}); + const target = target: { + var result = b.standardTargetOptions(.{}); + + // If we're building for macOS and we're on macOS, we need to + // use a generic target to workaround compilation issues. + if (result.result.os.tag == .macos and builtin.target.isDarwin()) { + result = genericMacOSTarget(b, null); + } + + // If we have no minimum OS version, we set the default based on + // our tag. Not all tags have a minimum so this may be null. + if (result.query.os_version_min == null) { + result.query.os_version_min = osVersionMin(result.result.os.tag); + } + + break :target result; + }; + + // This is set to true when we're building a system package. For now + // this is trivially detected using the "system_package_mode" bool + // but we may want to make this more sophisticated in the future. + const system_package: bool = b.graph.system_package_mode; + + // This specifies our target wasm runtime. For now only one semi-usable + // one exists so this is hardcoded. + const wasm_target: WasmTarget = .browser; + + // Determine whether GTK supports X11 and Wayland. This is always safe + // to run even on non-Linux platforms because any failures result in + // defaults. + const gtk_targets = gtk.targets(b); + + // We use env vars throughout the build so we grab them immediately here. + var env = try std.process.getEnvMap(b.allocator); + errdefer env.deinit(); + + var config: Config = .{ + .optimize = optimize, + .target = target, + .wasm_target = wasm_target, + .env = env, + }; + + //--------------------------------------------------------------- + // Comptime Interfaces + + config.font_backend = b.option( + font.Backend, + "font-backend", + "The font backend to use for discovery and rasterization.", + ) orelse font.Backend.default(target.result, wasm_target); + + config.app_runtime = b.option( + apprt.Runtime, + "app-runtime", + "The app runtime to use. Not all values supported on all platforms.", + ) orelse apprt.Runtime.default(target.result); + + config.renderer = b.option( + renderer.Impl, + "renderer", + "The app runtime to use. Not all values supported on all platforms.", + ) orelse renderer.Impl.default(target.result, wasm_target); + + //--------------------------------------------------------------- + // Feature Flags + + config.adwaita = b.option( + bool, + "gtk-adwaita", + "Enables the use of Adwaita when using the GTK rendering backend.", + ) orelse true; + + config.flatpak = b.option( + bool, + "flatpak", + "Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.", + ) orelse false; + + config.sentry = b.option( + bool, + "sentry", + "Build with Sentry crash reporting. Default for macOS is true, false for any other system.", + ) orelse sentry: { + switch (target.result.os.tag) { + .macos, .ios => break :sentry true, + + // Note its false for linux because the crash reports on Linux + // don't have much useful information. + else => break :sentry false, + } + }; + + config.wayland = b.option( + bool, + "gtk-wayland", + "Enables linking against Wayland libraries when using the GTK rendering backend.", + ) orelse gtk_targets.wayland; + + config.x11 = b.option( + bool, + "gtk-x11", + "Enables linking against X11 libraries when using the GTK rendering backend.", + ) orelse gtk_targets.x11; + + //--------------------------------------------------------------- + // Ghostty Exe Properties + + const version_string = b.option( + []const u8, + "version-string", + "A specific version string to use for the build. " ++ + "If not specified, git will be used. This must be a semantic version.", + ); + + config.version = if (version_string) |v| + // If an explicit version is given, we always use it. + try std.SemanticVersion.parse(v) + else version: { + // If no explicit version is given, we try to detect it from git. + const vsn = GitVersion.detect(b) catch |err| switch (err) { + // If Git isn't available we just make an unknown dev version. + error.GitNotFound, + error.GitNotRepository, + => break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + .pre = "dev", + .build = "0000000", + }, + + else => return err, + }; + if (vsn.tag) |tag| { + // Tip releases behave just like any other pre-release so we skip. + if (!std.mem.eql(u8, tag, "tip")) { + const expected = b.fmt("v{d}.{d}.{d}", .{ + app_version.major, + app_version.minor, + app_version.patch, + }); + + if (!std.mem.eql(u8, tag, expected)) { + @panic("tagged releases must be in vX.Y.Z format matching build.zig"); + } + + break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + }; + } + } + + break :version .{ + .major = app_version.major, + .minor = app_version.minor, + .patch = app_version.patch, + .pre = vsn.branch, + .build = vsn.short_hash, + }; + }; + + //--------------------------------------------------------------- + // Binary Properties + + // On NixOS, the built binary from `zig build` needs to patch the rpath + // into the built binary for it to be portable across the NixOS system + // it was built for. We default this to true if we can detect we're in + // a Nix shell and have LD_LIBRARY_PATH set. + config.patch_rpath = b.option( + []const u8, + "patch-rpath", + "Inject the LD_LIBRARY_PATH as the rpath in the built binary. " ++ + "This defaults to LD_LIBRARY_PATH if we're in a Nix shell environment on NixOS.", + ) orelse patch_rpath: { + // We only do the patching if we're targeting our own CPU and its Linux. + if (!(target.result.os.tag == .linux) or !target.query.isNativeCpu()) break :patch_rpath null; + + // If we're in a nix shell we default to doing this. + // Note: we purposely never deinit envmap because we leak the strings + if (env.get("IN_NIX_SHELL") == null) break :patch_rpath null; + break :patch_rpath env.get("LD_LIBRARY_PATH"); + }; + + config.pie = b.option( + bool, + "pie", + "Build a Position Independent Executable. Default true for system packages.", + ) orelse system_package; + + config.strip = b.option( + bool, + "strip", + "Strip the final executable. Default true for fast and small releases", + ) orelse switch (optimize) { + .Debug => false, + .ReleaseSafe => false, + .ReleaseFast, .ReleaseSmall => true, + }; + + //--------------------------------------------------------------- + // Artifacts to Emit + + config.emit_test_exe = b.option( + bool, + "emit-test-exe", + "Build and install test executables with 'build'", + ) orelse false; + + config.emit_bench = b.option( + bool, + "emit-bench", + "Build and install the benchmark executables.", + ) orelse false; + + config.emit_helpgen = b.option( + bool, + "emit-helpgen", + "Build and install the helpgen executable.", + ) orelse false; + + config.emit_docs = b.option( + bool, + "emit-docs", + "Build and install auto-generated documentation (requires pandoc)", + ) orelse emit_docs: { + // If we are emitting any other artifacts then we default to false. + if (config.emit_bench or + config.emit_test_exe or + config.emit_helpgen) break :emit_docs false; + + // We always emit docs in system package mode. + if (system_package) break :emit_docs true; + + // We only default to true if we can find pandoc. + const path = Command.expandPath(b.allocator, "pandoc") catch + break :emit_docs false; + defer if (path) |p| b.allocator.free(p); + break :emit_docs path != null; + }; + + config.emit_webdata = b.option( + bool, + "emit-webdata", + "Build the website data for the website.", + ) orelse false; + + config.emit_xcframework = b.option( + bool, + "emit-xcframework", + "Build and install the xcframework for the macOS library.", + ) orelse builtin.target.isDarwin() and + target.result.os.tag == .macos and + config.app_runtime == .none and + (!config.emit_bench and + !config.emit_test_exe and + !config.emit_helpgen); + + //--------------------------------------------------------------- + // System Packages + + // These are all our dependencies that can be used with system + // packages if they exist. We set them up here so that we can set + // their defaults early. The first call configures the integration and + // subsequent calls just return the configured value. This lets them + // show up properly in `--help`. + + { + // These dependencies we want to default false if we're on macOS. + // On macOS we don't want to use system libraries because we + // generally want a fat binary. This can be overridden with the + // `-fsys` flag. + for (&[_][]const u8{ + "freetype", + "harfbuzz", + "fontconfig", + "libpng", + "zlib", + "oniguruma", + }) |dep| { + _ = b.systemIntegrationOption( + dep, + .{ + // If we're not on darwin we want to use whatever the + // default is via the system package mode + .default = if (target.result.isDarwin()) false else null, + }, + ); + } + + // These default to false because they're rarely available as + // system packages so we usually want to statically link them. + for (&[_][]const u8{ + "glslang", + "spirv-cross", + "simdutf", + }) |dep| { + _ = b.systemIntegrationOption(dep, .{ .default = false }); + } + } + + return config; +} + +/// Configure the build options with our values. +pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void { + // We need to break these down individual because addOption doesn't + // support all types. + step.addOption(bool, "flatpak", self.flatpak); + step.addOption(bool, "adwaita", self.adwaita); + step.addOption(bool, "x11", self.x11); + step.addOption(bool, "wayland", self.wayland); + step.addOption(bool, "sentry", self.sentry); + step.addOption(apprt.Runtime, "app_runtime", self.app_runtime); + step.addOption(font.Backend, "font_backend", self.font_backend); + step.addOption(renderer.Impl, "renderer", self.renderer); + step.addOption(ExeEntrypoint, "exe_entrypoint", self.exe_entrypoint); + step.addOption(WasmTarget, "wasm_target", self.wasm_target); + step.addOption(bool, "wasm_shared", self.wasm_shared); + + // Our version. We also add the string version so we don't need + // to do any allocations at runtime. This has to be long enough to + // accommodate realistic large branch names for dev versions. + var buf: [1024]u8 = undefined; + step.addOption(std.SemanticVersion, "app_version", self.version); + step.addOption([:0]const u8, "app_version_string", try std.fmt.bufPrintZ( + &buf, + "{}", + .{self.version}, + )); + step.addOption( + ReleaseChannel, + "release_channel", + channel: { + const pre = self.version.pre orelse break :channel .stable; + if (pre.len == 0) break :channel .stable; + break :channel .tip; + }, + ); +} + +/// Rehydrate our Config from the comptime options. Note that not all +/// options are available at comptime, so look closely at this implementation +/// to see what is and isn't available. +pub fn fromOptions() Config { + const options = @import("build_options"); + return .{ + // Unused at runtime. + .optimize = undefined, + .target = undefined, + .env = undefined, + + .version = options.app_version, + .flatpak = options.flatpak, + .adwaita = options.adwaita, + .app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?, + .font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?, + .renderer = std.meta.stringToEnum(renderer.Impl, @tagName(options.renderer)).?, + .exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?, + .wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?, + .wasm_shared = options.wasm_shared, + }; +} + +/// Returns the minimum OS version for the given OS tag. This shouldn't +/// be used generally, it should only be used for Darwin-based OS currently. +pub fn osVersionMin(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion { + return switch (tag) { + // We support back to the earliest officially supported version + // of macOS by Apple. EOL versions are not supported. + .macos => .{ .semver = .{ + .major = 13, + .minor = 0, + .patch = 0, + } }, + + // iOS 17 picked arbitrarily + .ios => .{ .semver = .{ + .major = 17, + .minor = 0, + .patch = 0, + } }, + + // This should never happen currently. If we add a new target then + // we should add a new case here. + else => null, + }; +} + +// Returns a ResolvedTarget for a mac with a `target.result.cpu.model.name` of `generic`. +// `b.standardTargetOptions()` returns a more specific cpu like `apple_a15`. +// +// This is used to workaround compilation issues on macOS. +// (see for example https://github.com/mitchellh/ghostty/issues/1640). +pub fn genericMacOSTarget( + b: *std.Build, + arch: ?std.Target.Cpu.Arch, +) std.Build.ResolvedTarget { + return b.resolveTargetQuery(.{ + .cpu_arch = arch orelse builtin.target.cpu.arch, + .os_tag = .macos, + .os_version_min = osVersionMin(.macos), + }); +} + +/// The possible entrypoints for the exe artifact. This has no effect on +/// other artifact types (i.e. lib, wasm_module). +/// +/// The whole existence of this enum is to workaround the fact that Zig +/// doesn't allow the main function to be in a file in a subdirctory +/// from the "root" of the module, and I don't want to pollute our root +/// directory with a bunch of individual zig files for each entrypoint. +/// +/// Therefore, main.zig uses this to switch between the different entrypoints. +pub const ExeEntrypoint = enum { + ghostty, + helpgen, + mdgen_ghostty_1, + mdgen_ghostty_5, + webgen_config, + webgen_actions, + bench_parser, + bench_stream, + bench_codepoint_width, + bench_grapheme_break, + bench_page_init, +}; + +/// The release channel for the build. +pub const ReleaseChannel = enum { + /// Unstable builds on every commit. + tip, + + /// Stable tagged releases. + stable, +}; diff --git a/src/build/GhosttyBench.zig b/src/build/GhosttyBench.zig new file mode 100644 index 000000000..27f40abff --- /dev/null +++ b/src/build/GhosttyBench.zig @@ -0,0 +1,69 @@ +//! GhosttyBench generates all the Ghostty benchmark helper binaries. +const GhosttyBench = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); + +steps: []*std.Build.Step.Compile, + +pub fn init( + b: *std.Build, + deps: *const SharedDeps, +) !GhosttyBench { + var steps = std.ArrayList(*std.Build.Step.Compile).init(b.allocator); + errdefer steps.deinit(); + + // Open the directory ./src/bench + const c_dir_path = b.pathFromRoot("src/bench"); + var c_dir = try std.fs.cwd().openDir(c_dir_path, .{ .iterate = true }); + defer c_dir.close(); + + // Go through and add each as a step + var c_dir_it = c_dir.iterate(); + while (try c_dir_it.next()) |entry| { + // Get the index of the last '.' so we can strip the extension. + const index = std.mem.lastIndexOfScalar(u8, entry.name, '.') orelse continue; + if (index == 0) continue; + + // If it doesn't end in 'zig' then ignore + if (!std.mem.eql(u8, entry.name[index + 1 ..], "zig")) continue; + + // Name of the conformance app and full path to the entrypoint. + const name = entry.name[0..index]; + + // Executable builder. + const bin_name = try std.fmt.allocPrint(b.allocator, "bench-{s}", .{name}); + const c_exe = b.addExecutable(.{ + .name = bin_name, + .root_source_file = b.path("src/main.zig"), + .target = deps.config.target, + + // We always want our benchmarks to be in release mode. + .optimize = .ReleaseFast, + }); + c_exe.linkLibC(); + + // Update our entrypoint + var enum_name: [64]u8 = undefined; + @memcpy(enum_name[0..name.len], name); + std.mem.replaceScalar(u8, enum_name[0..name.len], '-', '_'); + + var buf: [64]u8 = undefined; + const new_deps = try deps.changeEntrypoint(b, std.meta.stringToEnum( + Config.ExeEntrypoint, + try std.fmt.bufPrint(&buf, "bench_{s}", .{enum_name[0..name.len]}), + ).?); + + _ = try new_deps.add(c_exe); + + try steps.append(c_exe); + } + + return .{ .steps = steps.items }; +} + +pub fn install(self: *const GhosttyBench) void { + const b = self.steps[0].step.owner; + for (self.steps) |step| b.installArtifact(step); +} diff --git a/src/build/GhosttyDocs.zig b/src/build/GhosttyDocs.zig new file mode 100644 index 000000000..28cbea2c9 --- /dev/null +++ b/src/build/GhosttyDocs.zig @@ -0,0 +1,92 @@ +//! GhosttyDocs generates all the on-disk documentation that Ghostty is +//! installed with (man pages, html, markdown, etc.) +const GhosttyDocs = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); + +steps: []*std.Build.Step, + +pub fn init( + b: *std.Build, + deps: *const SharedDeps, +) !GhosttyDocs { + var steps = std.ArrayList(*std.Build.Step).init(b.allocator); + errdefer steps.deinit(); + + const manpages = [_]struct { + name: []const u8, + section: []const u8, + }{ + .{ .name = "ghostty", .section = "1" }, + .{ .name = "ghostty", .section = "5" }, + }; + + inline for (manpages) |manpage| { + const generate_markdown = b.addExecutable(.{ + .name = "mdgen_" ++ manpage.name ++ "_" ++ manpage.section, + .root_source_file = b.path("src/main.zig"), + .target = b.host, + }); + deps.help_strings.addImport(generate_markdown); + + const gen_config = config: { + var copy = deps.config.*; + copy.exe_entrypoint = @field( + Config.ExeEntrypoint, + "mdgen_" ++ manpage.name ++ "_" ++ manpage.section, + ); + break :config copy; + }; + + const generate_markdown_options = b.addOptions(); + try gen_config.addOptions(generate_markdown_options); + generate_markdown.root_module.addOptions("build_options", generate_markdown_options); + + const generate_markdown_step = b.addRunArtifact(generate_markdown); + const markdown_output = generate_markdown_step.captureStdOut(); + + try steps.append(&b.addInstallFile( + markdown_output, + "share/ghostty/doc/" ++ manpage.name ++ "." ++ manpage.section ++ ".md", + ).step); + + const generate_html = b.addSystemCommand(&.{"pandoc"}); + generate_html.addArgs(&.{ + "--standalone", + "--from", + "markdown", + "--to", + "html", + }); + generate_html.addFileArg(markdown_output); + + try steps.append(&b.addInstallFile( + generate_html.captureStdOut(), + "share/ghostty/doc/" ++ manpage.name ++ "." ++ manpage.section ++ ".html", + ).step); + + const generate_manpage = b.addSystemCommand(&.{"pandoc"}); + generate_manpage.addArgs(&.{ + "--standalone", + "--from", + "markdown", + "--to", + "man", + }); + generate_manpage.addFileArg(markdown_output); + + try steps.append(&b.addInstallFile( + generate_manpage.captureStdOut(), + "share/man/man" ++ manpage.section ++ "/" ++ manpage.name ++ "." ++ manpage.section, + ).step); + } + + return .{ .steps = steps.items }; +} + +pub fn install(self: *const GhosttyDocs) void { + const b = self.steps[0].owner; + for (self.steps) |step| b.getInstallStep().dependOn(step); +} diff --git a/src/build/GhosttyExe.zig b/src/build/GhosttyExe.zig new file mode 100644 index 000000000..ef5303baa --- /dev/null +++ b/src/build/GhosttyExe.zig @@ -0,0 +1,120 @@ +const Ghostty = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); + +/// The primary Ghostty executable. +exe: *std.Build.Step.Compile, + +/// The install step for the executable. +install_step: *std.Build.Step.InstallArtifact, + +pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty { + const exe: *std.Build.Step.Compile = b.addExecutable(.{ + .name = "ghostty", + .root_source_file = b.path("src/main.zig"), + .target = cfg.target, + .optimize = cfg.optimize, + .strip = cfg.strip, + }); + const install_step = b.addInstallArtifact(exe, .{}); + + // Set PIE if requested + if (cfg.pie) exe.pie = true; + + // Add the shared dependencies + _ = try deps.add(exe); + + // Check for possible issues + try checkNixShell(exe, cfg); + + // Patch our rpath if that option is specified. + if (cfg.patch_rpath) |rpath| { + if (rpath.len > 0) { + const run = std.Build.Step.Run.create(b, "patchelf rpath"); + run.addArgs(&.{ "patchelf", "--set-rpath", rpath }); + run.addArtifactArg(exe); + install_step.step.dependOn(&run.step); + } + } + + // OS-specific + switch (cfg.target.result.os.tag) { + .windows => { + exe.subsystem = .Windows; + exe.addWin32ResourceFile(.{ + .file = b.path("dist/windows/ghostty.rc"), + }); + }, + + else => {}, + } + + return .{ + .exe = exe, + .install_step = install_step, + }; +} + +/// Add the ghostty exe to the install target. +pub fn install(self: *const Ghostty) void { + const b = self.install_step.step.owner; + b.getInstallStep().dependOn(&self.install_step.step); +} + +/// If we're in NixOS but not in the shell environment then we issue +/// a warning because the rpath may not be setup properly. This doesn't modify +/// our build in any way but addresses a common build-from-source issue +/// for a subset of users. +fn checkNixShell(exe: *std.Build.Step.Compile, cfg: *const Config) !void { + // Non-Linux doesn't have rpath issues. + if (cfg.target.result.os.tag != .linux) return; + + // When cross-compiling, we don't need to worry about matching our + // Nix shell rpath since the resulting binary will be run on a + // separate system. + if (!cfg.target.query.isNativeCpu()) return; + if (!cfg.target.query.isNativeOs()) return; + + // Verify we're in NixOS + std.fs.accessAbsolute("/etc/NIXOS", .{}) catch return; + + // If we're in a nix shell, not a problem + if (cfg.env.get("IN_NIX_SHELL") != null) return; + + try exe.step.addError( + "\x1b[" ++ color_map.get("yellow").? ++ + "\x1b[" ++ color_map.get("d").? ++ + \\Detected building on and for NixOS outside of the Nix shell environment. + \\ + \\The resulting ghostty binary will likely fail on launch because it is + \\unable to dynamically load the windowing libs (X11, Wayland, etc.). + \\We highly recommend running only within the Nix build environment + \\and the resulting binary will be portable across your system. + \\ + \\To run in the Nix build environment, use the following command. + \\Append any additional options like (`-Doptimize` flags). The resulting + \\binary will be in zig-out as usual. + \\ + \\ nix develop -c zig build + \\ + ++ + "\x1b[0m", + .{}, + ); +} + +/// ANSI escape codes for colored log output +const color_map = std.StaticStringMap([]const u8).initComptime(.{ + &.{ "black", "30m" }, + &.{ "blue", "34m" }, + &.{ "b", "1m" }, + &.{ "d", "2m" }, + &.{ "cyan", "36m" }, + &.{ "green", "32m" }, + &.{ "magenta", "35m" }, + &.{ "red", "31m" }, + &.{ "white", "37m" }, + &.{ "yellow", "33m" }, +}); diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig new file mode 100644 index 000000000..53aee0e24 --- /dev/null +++ b/src/build/GhosttyLib.zig @@ -0,0 +1,110 @@ +const GhosttyLib = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); +const LibtoolStep = @import("LibtoolStep.zig"); +const LipoStep = @import("LipoStep.zig"); + +/// The step that generates the file. +step: *std.Build.Step, + +/// The final static library file +output: std.Build.LazyPath, + +pub fn initStatic( + b: *std.Build, + deps: *const SharedDeps, +) !GhosttyLib { + const lib = b.addStaticLibrary(.{ + .name = "ghostty", + .root_source_file = b.path("src/main_c.zig"), + .target = deps.config.target, + .optimize = deps.config.optimize, + }); + lib.bundle_compiler_rt = true; + lib.linkLibC(); + + // Add our dependencies. Get the list of all static deps so we can + // build a combined archive if necessary. + var lib_list = try deps.add(lib); + try lib_list.append(lib.getEmittedBin()); + + if (!deps.config.target.result.isDarwin()) return .{ + .step = &lib.step, + .output = lib.getEmittedBin(), + }; + + // Create a static lib that contains all our dependencies. + const libtool = LibtoolStep.create(b, .{ + .name = "ghostty", + .out_name = "libghostty-fat.a", + .sources = lib_list.items, + }); + libtool.step.dependOn(&lib.step); + + return .{ + .step = libtool.step, + .output = libtool.output, + }; +} + +pub fn initShared( + b: *std.Build, + deps: *const SharedDeps, +) !GhosttyLib { + const lib = b.addSharedLibrary(.{ + .name = "ghostty", + .root_source_file = b.path("src/main_c.zig"), + .target = deps.config.target, + .optimize = deps.config.optimize, + .strip = deps.config.strip, + }); + _ = try deps.add(lib); + + return .{ + .step = &lib.step, + .output = lib.getEmittedBin(), + }; +} + +pub fn initMacOSUniversal( + b: *std.Build, + original_deps: *const SharedDeps, +) !GhosttyLib { + const aarch64 = try initStatic(b, &try original_deps.retarget( + b, + Config.genericMacOSTarget(b, .aarch64), + )); + const x86_64 = try initStatic(b, &try original_deps.retarget( + b, + Config.genericMacOSTarget(b, .x86_64), + )); + + const universal = LipoStep.create(b, .{ + .name = "ghostty", + .out_name = "libghostty.a", + .input_a = aarch64.output, + .input_b = x86_64.output, + }); + + return .{ + .step = universal.step, + .output = universal.output, + }; +} + +pub fn install(self: *const GhosttyLib, name: []const u8) void { + const b = self.step.owner; + const lib_install = b.addInstallLibFile(self.output, name); + b.getInstallStep().dependOn(&lib_install.step); +} + +pub fn installHeader(self: *const GhosttyLib) void { + const b = self.step.owner; + const header_install = b.addInstallHeaderFile( + b.path("include/ghostty.h"), + "ghostty.h", + ); + b.getInstallStep().dependOn(&header_install.step); +} diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig new file mode 100644 index 000000000..9c5f7f809 --- /dev/null +++ b/src/build/GhosttyResources.zig @@ -0,0 +1,257 @@ +const GhosttyResources = @This(); + +const std = @import("std"); +const buildpkg = @import("main.zig"); +const Config = @import("Config.zig"); +const config_vim = @import("../config/vim.zig"); +const config_sublime_syntax = @import("../config/sublime_syntax.zig"); +const terminfo = @import("../terminfo/main.zig"); +const RunStep = std.Build.Step.Run; + +steps: []*std.Build.Step, + +pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { + var steps = std.ArrayList(*std.Build.Step).init(b.allocator); + errdefer steps.deinit(); + + // Terminfo + terminfo: { + // Encode our terminfo + var str = std.ArrayList(u8).init(b.allocator); + defer str.deinit(); + try terminfo.ghostty.encode(str.writer()); + + // Write it + var wf = b.addWriteFiles(); + const src_source = wf.add("share/terminfo/ghostty.terminfo", str.items); + const src_install = b.addInstallFile(src_source, "share/terminfo/ghostty.terminfo"); + try steps.append(&src_install.step); + + // Windows doesn't have the binaries below. + if (cfg.target.result.os.tag == .windows) break :terminfo; + + // Convert to termcap source format if thats helpful to people and + // install it. The resulting value here is the termcap source in case + // that is used for other commands. + { + const run_step = RunStep.create(b, "infotocap"); + run_step.addArg("infotocap"); + run_step.addFileArg(src_source); + const out_source = run_step.captureStdOut(); + _ = run_step.captureStdErr(); // so we don't see stderr + + const cap_install = b.addInstallFile(out_source, "share/terminfo/ghostty.termcap"); + try steps.append(&cap_install.step); + } + + // Compile the terminfo source into a terminfo database + { + const run_step = RunStep.create(b, "tic"); + run_step.addArgs(&.{ "tic", "-x", "-o" }); + const path = run_step.addOutputFileArg("terminfo"); + run_step.addFileArg(src_source); + _ = run_step.captureStdErr(); // so we don't see stderr + + // Depend on the terminfo source install step so that Zig build + // creates the "share" directory for us. + run_step.step.dependOn(&src_install.step); + + { + // Use cp -R instead of Step.InstallDir because we need to preserve + // symlinks in the terminfo database. Zig's InstallDir step doesn't + // handle symlinks correctly yet. + const copy_step = RunStep.create(b, "copy terminfo db"); + copy_step.addArgs(&.{ "cp", "-R" }); + copy_step.addFileArg(path); + copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); + try steps.append(©_step.step); + } + } + } + + // Shell-integration + { + const install_step = b.addInstallDirectory(.{ + .source_dir = b.path("src/shell-integration"), + .install_dir = .{ .custom = "share" }, + .install_subdir = b.pathJoin(&.{ "ghostty", "shell-integration" }), + .exclude_extensions = &.{".md"}, + }); + try steps.append(&install_step.step); + } + + // Themes + { + const upstream = b.dependency("iterm2_themes", .{}); + const install_step = b.addInstallDirectory(.{ + .source_dir = upstream.path("ghostty"), + .install_dir = .{ .custom = "share" }, + .install_subdir = b.pathJoin(&.{ "ghostty", "themes" }), + .exclude_extensions = &.{".md"}, + }); + try steps.append(&install_step.step); + } + + // Fish shell completions + { + const wf = b.addWriteFiles(); + _ = wf.add("ghostty.fish", buildpkg.fish_completions); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/fish/vendor_completions.d", + }); + try steps.append(&install_step.step); + } + + // zsh shell completions + { + const wf = b.addWriteFiles(); + _ = wf.add("_ghostty", buildpkg.zsh_completions); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/zsh/site-functions", + }); + try steps.append(&install_step.step); + } + + // bash shell completions + { + const wf = b.addWriteFiles(); + _ = wf.add("ghostty.bash", buildpkg.bash_completions); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/bash-completion/completions", + }); + try steps.append(&install_step.step); + } + + // Vim plugin + { + const wf = b.addWriteFiles(); + _ = wf.add("syntax/ghostty.vim", config_vim.syntax); + _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); + _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); + _ = wf.add("compiler/ghostty.vim", config_vim.compiler); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/vim/vimfiles", + }); + try steps.append(&install_step.step); + } + + // Neovim plugin + // This is just a copy-paste of the Vim plugin, but using a Neovim subdir. + // By default, Neovim doesn't look inside share/vim/vimfiles. Some distros + // configure it to do that however. Fedora, does not as a counterexample. + { + const wf = b.addWriteFiles(); + _ = wf.add("syntax/ghostty.vim", config_vim.syntax); + _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); + _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); + _ = wf.add("compiler/ghostty.vim", config_vim.compiler); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/nvim/site", + }); + try steps.append(&install_step.step); + } + + // Sublime syntax highlighting for bat cli tool + // NOTE: The current implementation requires symlinking the generated + // 'ghostty.sublime-syntax' file from zig-out to the '~.config/bat/syntaxes' + // directory. The syntax then needs to be mapped to the correct language in + // the config file within the '~.config/bat' directory + // (ex: --map-syntax "/Users/user/.config/ghostty/config:Ghostty Config"). + { + const wf = b.addWriteFiles(); + _ = wf.add("ghostty.sublime-syntax", config_sublime_syntax.syntax); + + const install_step = b.addInstallDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/bat/syntaxes", + }); + try steps.append(&install_step.step); + } + + // App (Linux) + if (cfg.target.result.os.tag == .linux) { + // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html + + // Desktop file so that we have an icon and other metadata + try steps.append(&b.addInstallFile( + b.path("dist/linux/app.desktop"), + "share/applications/com.mitchellh.ghostty.desktop", + ).step); + + // Right click menu action for Plasma desktop + try steps.append(&b.addInstallFile( + b.path("dist/linux/ghostty_dolphin.desktop"), + "share/kio/servicemenus/com.mitchellh.ghostty.desktop", + ).step); + + // Various icons that our application can use, including the icon + // that will be used for the desktop. + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_16.png"), + "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_32.png"), + "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_128.png"), + "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_256.png"), + "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_512.png"), + "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png", + ).step); + // Flatpaks only support icons up to 512x512. + if (!cfg.flatpak) { + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_1024.png"), + "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png", + ).step); + } + + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_16@2x.png"), + "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_32@2x.png"), + "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_128@2x.png"), + "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_256@2x.png"), + "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png", + ).step); + } + + return .{ .steps = steps.items }; +} + +pub fn install(self: *const GhosttyResources) void { + const b = self.steps[0].owner; + for (self.steps) |step| b.getInstallStep().dependOn(step); +} diff --git a/src/build/GhosttyWebdata.zig b/src/build/GhosttyWebdata.zig new file mode 100644 index 000000000..6e0acaf17 --- /dev/null +++ b/src/build/GhosttyWebdata.zig @@ -0,0 +1,82 @@ +//! GhosttyWebdata generates all the Ghostty website data that is +//! merged with the website for things like config references. +const GhosttyWebdata = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); + +steps: []*std.Build.Step, + +pub fn init( + b: *std.Build, + deps: *const SharedDeps, +) !GhosttyWebdata { + var steps = std.ArrayList(*std.Build.Step).init(b.allocator); + errdefer steps.deinit(); + + { + const webgen_config = b.addExecutable(.{ + .name = "webgen_config", + .root_source_file = b.path("src/main.zig"), + .target = b.host, + }); + deps.help_strings.addImport(webgen_config); + + { + const buildconfig = config: { + var copy = deps.config.*; + copy.exe_entrypoint = .webgen_config; + break :config copy; + }; + + const options = b.addOptions(); + try buildconfig.addOptions(options); + webgen_config.root_module.addOptions("build_options", options); + } + + const webgen_config_step = b.addRunArtifact(webgen_config); + const webgen_config_out = webgen_config_step.captureStdOut(); + + try steps.append(&b.addInstallFile( + webgen_config_out, + "share/ghostty/webdata/config.mdx", + ).step); + } + + { + const webgen_actions = b.addExecutable(.{ + .name = "webgen_actions", + .root_source_file = b.path("src/main.zig"), + .target = b.host, + }); + deps.help_strings.addImport(webgen_actions); + + { + const buildconfig = config: { + var copy = deps.config.*; + copy.exe_entrypoint = .webgen_actions; + break :config copy; + }; + + const options = b.addOptions(); + try buildconfig.addOptions(options); + webgen_actions.root_module.addOptions("build_options", options); + } + + const webgen_actions_step = b.addRunArtifact(webgen_actions); + const webgen_actions_out = webgen_actions_step.captureStdOut(); + + try steps.append(&b.addInstallFile( + webgen_actions_out, + "share/ghostty/webdata/actions.mdx", + ).step); + } + + return .{ .steps = steps.items }; +} + +pub fn install(self: *const GhosttyWebdata) void { + const b = self.steps[0].owner; + for (self.steps) |step| b.getInstallStep().dependOn(step); +} diff --git a/src/build/GhosttyXCFramework.zig b/src/build/GhosttyXCFramework.zig new file mode 100644 index 000000000..38bc2c43f --- /dev/null +++ b/src/build/GhosttyXCFramework.zig @@ -0,0 +1,68 @@ +const GhosttyXCFramework = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); +const SharedDeps = @import("SharedDeps.zig"); +const GhosttyLib = @import("GhosttyLib.zig"); +const XCFrameworkStep = @import("XCFrameworkStep.zig"); + +xcframework: *XCFrameworkStep, +macos: GhosttyLib, + +pub fn init(b: *std.Build, deps: *const SharedDeps) !GhosttyXCFramework { + // Create our universal macOS static library. + const macos = try GhosttyLib.initMacOSUniversal(b, deps); + + // iOS + const ios = try GhosttyLib.initStatic(b, &try deps.retarget( + b, + b.resolveTargetQuery(.{ + .cpu_arch = .aarch64, + .os_tag = .ios, + .os_version_min = Config.osVersionMin(.ios), + .abi = null, + }), + )); + + // iOS Simulator + const ios_sim = try GhosttyLib.initStatic(b, &try deps.retarget( + b, + b.resolveTargetQuery(.{ + .cpu_arch = .aarch64, + .os_tag = .ios, + .os_version_min = Config.osVersionMin(.ios), + .abi = .simulator, + }), + )); + + // The xcframework wraps our ghostty library so that we can link + // it to the final app built with Swift. + const xcframework = XCFrameworkStep.create(b, .{ + .name = "GhosttyKit", + .out_path = "macos/GhosttyKit.xcframework", + .libraries = &.{ + .{ + .library = macos.output, + .headers = b.path("include"), + }, + .{ + .library = ios.output, + .headers = b.path("include"), + }, + .{ + .library = ios_sim.output, + .headers = b.path("include"), + }, + }, + }); + + return .{ + .xcframework = xcframework, + .macos = macos, + }; +} + +pub fn install(self: *const GhosttyXCFramework) void { + const b = self.xcframework.step.owner; + b.getInstallStep().dependOn(self.xcframework.step); +} diff --git a/src/build/Version.zig b/src/build/GitVersion.zig similarity index 100% rename from src/build/Version.zig rename to src/build/GitVersion.zig diff --git a/src/build/HelpStrings.zig b/src/build/HelpStrings.zig new file mode 100644 index 000000000..0244670cc --- /dev/null +++ b/src/build/HelpStrings.zig @@ -0,0 +1,46 @@ +const HelpStrings = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); + +/// The "helpgen" exe. +exe: *std.Build.Step.Compile, + +/// The output path for the help strings. +output: std.Build.LazyPath, + +pub fn init(b: *std.Build, cfg: *const Config) !HelpStrings { + const exe = b.addExecutable(.{ + .name = "helpgen", + .root_source_file = b.path("src/helpgen.zig"), + .target = b.host, + }); + + const help_config = config: { + var copy = cfg.*; + copy.exe_entrypoint = .helpgen; + break :config copy; + }; + const options = b.addOptions(); + try help_config.addOptions(options); + exe.root_module.addOptions("build_options", options); + + const help_run = b.addRunArtifact(exe); + return .{ + .exe = exe, + .output = help_run.captureStdOut(), + }; +} + +/// Add the "help_strings" import. +pub fn addImport(self: *const HelpStrings, step: *std.Build.Step.Compile) void { + self.output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("help_strings", .{ + .root_source_file = self.output, + }); +} + +/// Install the help exe +pub fn install(self: *const HelpStrings) void { + self.exe.step.owner.installArtifact(self.exe); +} diff --git a/src/build/MetallibStep.zig b/src/build/MetallibStep.zig index 587d276c1..12adf3edb 100644 --- a/src/build/MetallibStep.zig +++ b/src/build/MetallibStep.zig @@ -21,13 +21,13 @@ pub const Options = struct { step: *Step, output: LazyPath, -pub fn create(b: *std.Build, opts: Options) *MetallibStep { +pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { const self = b.allocator.create(MetallibStep) catch @panic("OOM"); const sdk = switch (opts.target.result.os.tag) { .macos => "macosx", .ios => "iphoneos", - else => @panic("unsupported metallib OS"), + else => return null, }; const min_version = if (opts.target.query.os_version_min) |v| diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig new file mode 100644 index 000000000..792199212 --- /dev/null +++ b/src/build/SharedDeps.zig @@ -0,0 +1,501 @@ +const SharedDeps = @This(); + +const std = @import("std"); +const Scanner = @import("zig_wayland").Scanner; +const Config = @import("Config.zig"); +const HelpStrings = @import("HelpStrings.zig"); +const MetallibStep = @import("MetallibStep.zig"); +const UnicodeTables = @import("UnicodeTables.zig"); + +config: *const Config, + +options: *std.Build.Step.Options, +help_strings: HelpStrings, +metallib: ?*MetallibStep, +unicode_tables: UnicodeTables, + +/// Used to keep track of a list of file sources. +pub const LazyPathList = std.ArrayList(std.Build.LazyPath); + +pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps { + var result: SharedDeps = .{ + .config = cfg, + .help_strings = try HelpStrings.init(b, cfg), + .unicode_tables = try UnicodeTables.init(b), + + // Setup by retarget + .options = undefined, + .metallib = undefined, + }; + try result.initTarget(b, cfg.target); + return result; +} + +/// Retarget our dependencies for another build target. Modifies in-place. +pub fn retarget( + self: *const SharedDeps, + b: *std.Build, + target: std.Build.ResolvedTarget, +) !SharedDeps { + var result = self.*; + try result.initTarget(b, target); + return result; +} + +/// Change the exe entrypoint. +pub fn changeEntrypoint( + self: *const SharedDeps, + b: *std.Build, + entrypoint: Config.ExeEntrypoint, +) !SharedDeps { + // Change our config + const config = try b.allocator.create(Config); + config.* = self.config.*; + config.exe_entrypoint = entrypoint; + + var result = self.*; + result.config = config; + return result; +} + +fn initTarget( + self: *SharedDeps, + b: *std.Build, + target: std.Build.ResolvedTarget, +) !void { + // Update our metallib + self.metallib = MetallibStep.create(b, .{ + .name = "Ghostty", + .target = target, + .sources = &.{b.path("src/renderer/shaders/cell.metal")}, + }); + + // Change our config + const config = try b.allocator.create(Config); + config.* = self.config.*; + config.target = target; + self.config = config; + + // Setup our shared build options + self.options = b.addOptions(); + try self.config.addOptions(self.options); +} + +pub fn add( + self: *const SharedDeps, + step: *std.Build.Step.Compile, +) !LazyPathList { + const b = step.step.owner; + + // We could use our config.target/optimize fields here but its more + // correct to always match our step. + const target = step.root_module.resolved_target.?; + const optimize = step.root_module.optimize.?; + + // We maintain a list of our static libraries and return it so that + // we can build a single fat static library for the final app. + var static_libs = LazyPathList.init(b.allocator); + errdefer static_libs.deinit(); + + // Every exe gets build options populated + step.root_module.addOptions("build_options", self.options); + + // Freetype + _ = b.systemIntegrationOption("freetype", .{}); // Shows it in help + if (self.config.font_backend.hasFreetype()) { + const freetype_dep = b.dependency("freetype", .{ + .target = target, + .optimize = optimize, + .@"enable-libpng" = true, + }); + step.root_module.addImport("freetype", freetype_dep.module("freetype")); + + if (b.systemIntegrationOption("freetype", .{})) { + step.linkSystemLibrary2("bzip2", dynamic_link_opts); + step.linkSystemLibrary2("freetype2", dynamic_link_opts); + } else { + step.linkLibrary(freetype_dep.artifact("freetype")); + try static_libs.append(freetype_dep.artifact("freetype").getEmittedBin()); + } + } + + // Harfbuzz + _ = b.systemIntegrationOption("harfbuzz", .{}); // Shows it in help + if (self.config.font_backend.hasHarfbuzz()) { + const harfbuzz_dep = b.dependency("harfbuzz", .{ + .target = target, + .optimize = optimize, + .@"enable-freetype" = true, + .@"enable-coretext" = self.config.font_backend.hasCoretext(), + }); + + step.root_module.addImport( + "harfbuzz", + harfbuzz_dep.module("harfbuzz"), + ); + if (b.systemIntegrationOption("harfbuzz", .{})) { + step.linkSystemLibrary2("harfbuzz", dynamic_link_opts); + } else { + step.linkLibrary(harfbuzz_dep.artifact("harfbuzz")); + try static_libs.append(harfbuzz_dep.artifact("harfbuzz").getEmittedBin()); + } + } + + // Fontconfig + _ = b.systemIntegrationOption("fontconfig", .{}); // Shows it in help + if (self.config.font_backend.hasFontconfig()) { + const fontconfig_dep = b.dependency("fontconfig", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport( + "fontconfig", + fontconfig_dep.module("fontconfig"), + ); + + if (b.systemIntegrationOption("fontconfig", .{})) { + step.linkSystemLibrary2("fontconfig", dynamic_link_opts); + } else { + step.linkLibrary(fontconfig_dep.artifact("fontconfig")); + try static_libs.append(fontconfig_dep.artifact("fontconfig").getEmittedBin()); + } + } + + // Libpng - Ghostty doesn't actually use this directly, its only used + // through dependencies, so we only need to add it to our static + // libs list if we're not using system integration. The dependencies + // will handle linking it. + if (!b.systemIntegrationOption("libpng", .{})) { + const libpng_dep = b.dependency("libpng", .{ + .target = target, + .optimize = optimize, + }); + step.linkLibrary(libpng_dep.artifact("png")); + try static_libs.append(libpng_dep.artifact("png").getEmittedBin()); + } + + // Zlib - same as libpng, only used through dependencies. + if (!b.systemIntegrationOption("zlib", .{})) { + const zlib_dep = b.dependency("zlib", .{ + .target = target, + .optimize = optimize, + }); + step.linkLibrary(zlib_dep.artifact("z")); + try static_libs.append(zlib_dep.artifact("z").getEmittedBin()); + } + + // Oniguruma + const oniguruma_dep = b.dependency("oniguruma", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport("oniguruma", oniguruma_dep.module("oniguruma")); + if (b.systemIntegrationOption("oniguruma", .{})) { + step.linkSystemLibrary2("oniguruma", dynamic_link_opts); + } else { + step.linkLibrary(oniguruma_dep.artifact("oniguruma")); + try static_libs.append(oniguruma_dep.artifact("oniguruma").getEmittedBin()); + } + + // Glslang + const glslang_dep = b.dependency("glslang", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport("glslang", glslang_dep.module("glslang")); + if (b.systemIntegrationOption("glslang", .{})) { + step.linkSystemLibrary2("glslang", dynamic_link_opts); + step.linkSystemLibrary2("glslang-default-resource-limits", dynamic_link_opts); + } else { + step.linkLibrary(glslang_dep.artifact("glslang")); + try static_libs.append(glslang_dep.artifact("glslang").getEmittedBin()); + } + + // Spirv-cross + const spirv_cross_dep = b.dependency("spirv_cross", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport("spirv_cross", spirv_cross_dep.module("spirv_cross")); + if (b.systemIntegrationOption("spirv-cross", .{})) { + step.linkSystemLibrary2("spirv-cross", dynamic_link_opts); + } else { + step.linkLibrary(spirv_cross_dep.artifact("spirv_cross")); + try static_libs.append(spirv_cross_dep.artifact("spirv_cross").getEmittedBin()); + } + + // Simdutf + if (b.systemIntegrationOption("simdutf", .{})) { + step.linkSystemLibrary2("simdutf", dynamic_link_opts); + } else { + const simdutf_dep = b.dependency("simdutf", .{ + .target = target, + .optimize = optimize, + }); + step.linkLibrary(simdutf_dep.artifact("simdutf")); + try static_libs.append(simdutf_dep.artifact("simdutf").getEmittedBin()); + } + + // Sentry + if (self.config.sentry) { + const sentry_dep = b.dependency("sentry", .{ + .target = target, + .optimize = optimize, + .backend = .breakpad, + }); + + step.root_module.addImport("sentry", sentry_dep.module("sentry")); + + // Sentry + step.linkLibrary(sentry_dep.artifact("sentry")); + try static_libs.append(sentry_dep.artifact("sentry").getEmittedBin()); + + // We also need to include breakpad in the static libs. + const breakpad_dep = sentry_dep.builder.dependency("breakpad", .{ + .target = target, + .optimize = optimize, + }); + try static_libs.append(breakpad_dep.artifact("breakpad").getEmittedBin()); + } + + // Wasm we do manually since it is such a different build. + if (step.rootModuleTarget().cpu.arch == .wasm32) { + const js_dep = b.dependency("zig_js", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport("zig-js", js_dep.module("zig-js")); + + return static_libs; + } + + // On Linux, we need to add a couple common library paths that aren't + // on the standard search list. i.e. GTK is often in /usr/lib/x86_64-linux-gnu + // on x86_64. + if (step.rootModuleTarget().os.tag == .linux) { + const triple = try step.rootModuleTarget().linuxTriple(b.allocator); + step.addLibraryPath(.{ .cwd_relative = b.fmt("/usr/lib/{s}", .{triple}) }); + } + + // C files + step.linkLibC(); + step.addIncludePath(b.path("src/stb")); + step.addCSourceFiles(.{ .files = &.{"src/stb/stb.c"} }); + if (step.rootModuleTarget().os.tag == .linux) { + step.addIncludePath(b.path("src/apprt/gtk")); + } + + // C++ files + step.linkLibCpp(); + step.addIncludePath(b.path("src")); + { + // From hwy/detect_targets.h + const HWY_AVX3_SPR: c_int = 1 << 4; + const HWY_AVX3_ZEN4: c_int = 1 << 6; + const HWY_AVX3_DL: c_int = 1 << 7; + const HWY_AVX3: c_int = 1 << 8; + + // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414 + // To workaround this we just disable AVX512 support completely. + // The performance difference between AVX2 and AVX512 is not + // significant for our use case and AVX512 is very rare on consumer + // hardware anyways. + const HWY_DISABLED_TARGETS: c_int = HWY_AVX3_SPR | HWY_AVX3_ZEN4 | HWY_AVX3_DL | HWY_AVX3; + + step.addCSourceFiles(.{ + .files = &.{ + "src/simd/base64.cpp", + "src/simd/codepoint_width.cpp", + "src/simd/index_of.cpp", + "src/simd/vt.cpp", + }, + .flags = if (step.rootModuleTarget().cpu.arch == .x86_64) &.{ + b.fmt("-DHWY_DISABLED_TARGETS={}", .{HWY_DISABLED_TARGETS}), + } else &.{}, + }); + } + + // We always require the system SDK so that our system headers are available. + // This makes things like `os/log.h` available for cross-compiling. + if (step.rootModuleTarget().isDarwin()) { + try @import("apple_sdk").addPaths(b, &step.root_module); + + const metallib = self.metallib.?; + metallib.output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("ghostty_metallib", .{ + .root_source_file = metallib.output, + }); + } + + // Other dependencies, mostly pure Zig + step.root_module.addImport("opengl", b.dependency( + "opengl", + .{}, + ).module("opengl")); + step.root_module.addImport("vaxis", b.dependency("vaxis", .{ + .target = target, + .optimize = optimize, + }).module("vaxis")); + step.root_module.addImport("wuffs", b.dependency("wuffs", .{ + .target = target, + .optimize = optimize, + }).module("wuffs")); + step.root_module.addImport("xev", b.dependency("libxev", .{ + .target = target, + .optimize = optimize, + }).module("xev")); + step.root_module.addImport("z2d", b.addModule("z2d", .{ + .root_source_file = b.dependency("z2d", .{}).path("src/z2d.zig"), + .target = target, + .optimize = optimize, + })); + step.root_module.addImport("ziglyph", b.dependency("ziglyph", .{ + .target = target, + .optimize = optimize, + }).module("ziglyph")); + step.root_module.addImport("zf", b.dependency("zf", .{ + .target = target, + .optimize = optimize, + .with_tui = false, + }).module("zf")); + + // Mac Stuff + if (step.rootModuleTarget().isDarwin()) { + const objc_dep = b.dependency("zig_objc", .{ + .target = target, + .optimize = optimize, + }); + const macos_dep = b.dependency("macos", .{ + .target = target, + .optimize = optimize, + }); + + step.root_module.addImport("objc", objc_dep.module("objc")); + step.root_module.addImport("macos", macos_dep.module("macos")); + step.linkLibrary(macos_dep.artifact("macos")); + try static_libs.append(macos_dep.artifact("macos").getEmittedBin()); + + if (self.config.renderer == .opengl) { + step.linkFramework("OpenGL"); + } + } + + // cimgui + const cimgui_dep = b.dependency("cimgui", .{ + .target = target, + .optimize = optimize, + }); + step.root_module.addImport("cimgui", cimgui_dep.module("cimgui")); + step.linkLibrary(cimgui_dep.artifact("cimgui")); + try static_libs.append(cimgui_dep.artifact("cimgui").getEmittedBin()); + + // Highway + const highway_dep = b.dependency("highway", .{ + .target = target, + .optimize = optimize, + }); + step.linkLibrary(highway_dep.artifact("highway")); + try static_libs.append(highway_dep.artifact("highway").getEmittedBin()); + + // utfcpp - This is used as a dependency on our hand-written C++ code + const utfcpp_dep = b.dependency("utfcpp", .{ + .target = target, + .optimize = optimize, + }); + step.linkLibrary(utfcpp_dep.artifact("utfcpp")); + try static_libs.append(utfcpp_dep.artifact("utfcpp").getEmittedBin()); + + // If we're building an exe then we have additional dependencies. + if (step.kind != .lib) { + // We always statically compile glad + step.addIncludePath(b.path("vendor/glad/include/")); + step.addCSourceFile(.{ + .file = b.path("vendor/glad/src/gl.c"), + .flags = &.{}, + }); + + // When we're targeting flatpak we ALWAYS link GTK so we + // get access to glib for dbus. + if (self.config.flatpak) step.linkSystemLibrary2("gtk4", dynamic_link_opts); + + switch (self.config.app_runtime) { + .none => {}, + + .glfw => glfw: { + const mach_glfw_dep = b.lazyDependency("mach_glfw", .{ + .target = target, + .optimize = optimize, + }) orelse break :glfw; + step.root_module.addImport("glfw", mach_glfw_dep.module("mach-glfw")); + }, + + .gtk => { + step.linkSystemLibrary2("gtk4", dynamic_link_opts); + if (self.config.adwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts); + if (self.config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts); + + if (self.config.wayland) { + const scanner = Scanner.create(b, .{}); + + const wayland = b.createModule(.{ .root_source_file = scanner.result }); + + const plasma_wayland_protocols = b.dependency("plasma_wayland_protocols", .{ + .target = target, + .optimize = optimize, + }); + scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/blur.xml")); + + scanner.generate("wl_compositor", 1); + scanner.generate("org_kde_kwin_blur_manager", 1); + + step.root_module.addImport("wayland", wayland); + step.linkSystemLibrary2("wayland-client", dynamic_link_opts); + } + + { + const gresource = @import("../apprt/gtk/gresource.zig"); + + const wf = b.addWriteFiles(); + const gresource_xml = wf.add("gresource.xml", gresource.gresource_xml); + + const generate_resources_c = b.addSystemCommand(&.{ + "glib-compile-resources", + "--c-name", + "ghostty", + "--generate-source", + "--target", + }); + const ghostty_resources_c = generate_resources_c.addOutputFileArg("ghostty_resources.c"); + generate_resources_c.addFileArg(gresource_xml); + generate_resources_c.extra_file_dependencies = &gresource.dependencies; + step.addCSourceFile(.{ .file = ghostty_resources_c, .flags = &.{} }); + + const generate_resources_h = b.addSystemCommand(&.{ + "glib-compile-resources", + "--c-name", + "ghostty", + "--generate-header", + "--target", + }); + const ghostty_resources_h = generate_resources_h.addOutputFileArg("ghostty_resources.h"); + generate_resources_h.addFileArg(gresource_xml); + generate_resources_h.extra_file_dependencies = &gresource.dependencies; + step.addIncludePath(ghostty_resources_h.dirname()); + } + }, + } + } + + self.help_strings.addImport(step); + self.unicode_tables.addImport(step); + + return static_libs; +} + +// 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. +const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, +}; diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig new file mode 100644 index 000000000..0159de442 --- /dev/null +++ b/src/build/UnicodeTables.zig @@ -0,0 +1,43 @@ +const UnicodeTables = @This(); + +const std = @import("std"); +const Config = @import("Config.zig"); + +/// The exe. +exe: *std.Build.Step.Compile, + +/// The output path for the unicode tables +output: std.Build.LazyPath, + +pub fn init(b: *std.Build) !UnicodeTables { + const exe = b.addExecutable(.{ + .name = "unigen", + .root_source_file = b.path("src/unicode/props.zig"), + .target = b.host, + }); + exe.linkLibC(); + + const ziglyph_dep = b.dependency("ziglyph", .{ + .target = b.host, + }); + exe.root_module.addImport("ziglyph", ziglyph_dep.module("ziglyph")); + + const run = b.addRunArtifact(exe); + return .{ + .exe = exe, + .output = run.captureStdOut(), + }; +} + +/// Add the "unicode_tables" import. +pub fn addImport(self: *const UnicodeTables, step: *std.Build.Step.Compile) void { + self.output.addStepDependencies(&step.step); + step.root_module.addAnonymousImport("unicode_tables", .{ + .root_source_file = self.output, + }); +} + +/// Install the exe +pub fn install(self: *const UnicodeTables, b: *std.Build) void { + b.installArtifact(self.exe); +} diff --git a/src/build/bash_completions.zig b/src/build/bash_completions.zig index 6649bcb01..86c2dc3cf 100644 --- a/src/build/bash_completions.zig +++ b/src/build/bash_completions.zig @@ -14,7 +14,7 @@ const Action = @import("../cli/action.zig").Action; /// it's part of an on going completion like --=. Working around this requires looking /// backward in the command line args to pretend the = is an empty string /// see: https://www.gnu.org/software/gnuastro/manual/html_node/Bash-TAB-completion-tutorial.html -pub const bash_completions = comptimeGenerateBashCompletions(); +pub const completions = comptimeGenerateBashCompletions(); fn comptimeGenerateBashCompletions() []const u8 { comptime { @@ -319,7 +319,7 @@ fn writeBashCompletions(writer: anytype) !void { \\ # clear out prev so we don't run any of the key specific completions \\ prev="" \\ fi - \\ + \\ \\ case "${COMP_WORDS[1]}" in \\ --*) _handle_config ;; \\ +*) _handle_actions ;; diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index b75c4dd16..dca119c6f 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -5,7 +5,7 @@ const Action = @import("../cli/action.zig").Action; /// A fish completions configuration that contains all the available commands /// and options. -pub const fish_completions = comptimeGenerateFishCompletions(); +pub const completions = comptimeGenerateFishCompletions(); fn comptimeGenerateFishCompletions() []const u8 { comptime { diff --git a/src/build/gtk.zig b/src/build/gtk.zig new file mode 100644 index 000000000..f33219988 --- /dev/null +++ b/src/build/gtk.zig @@ -0,0 +1,24 @@ +const std = @import("std"); + +pub const Targets = packed struct { + x11: bool = false, + wayland: bool = false, +}; + +/// Returns the targets that GTK4 was compiled with. +pub fn targets(b: *std.Build) Targets { + // Run pkg-config. We allow it to fail so that zig build --help + // works without all dependencies. The build will fail later when + // GTK isn't found anyways. + var code: u8 = undefined; + const output = b.runAllowFail( + &.{ "pkg-config", "--variable=targets", "gtk4" }, + &code, + .Ignore, + ) catch return .{}; + + return .{ + .x11 = std.mem.indexOf(u8, output, "x11") != null, + .wayland = std.mem.indexOf(u8, output, "wayland") != null, + }; +} diff --git a/src/build/main.zig b/src/build/main.zig new file mode 100644 index 000000000..8228abfbf --- /dev/null +++ b/src/build/main.zig @@ -0,0 +1,30 @@ +//! Build logic for Ghostty. A single "build.zig" file became far too complex +//! and spaghetti, so this package extracts the build logic into smaller, +//! more manageable pieces. + +pub const gtk = @import("gtk.zig"); +pub const Config = @import("Config.zig"); +pub const GitVersion = @import("GitVersion.zig"); + +// Artifacts +pub const GhosttyBench = @import("GhosttyBench.zig"); +pub const GhosttyDocs = @import("GhosttyDocs.zig"); +pub const GhosttyExe = @import("GhosttyExe.zig"); +pub const GhosttyLib = @import("GhosttyLib.zig"); +pub const GhosttyResources = @import("GhosttyResources.zig"); +pub const GhosttyXCFramework = @import("GhosttyXCFramework.zig"); +pub const GhosttyWebdata = @import("GhosttyWebdata.zig"); +pub const HelpStrings = @import("HelpStrings.zig"); +pub const SharedDeps = @import("SharedDeps.zig"); +pub const UnicodeTables = @import("UnicodeTables.zig"); + +// Steps +pub const LibtoolStep = @import("LibtoolStep.zig"); +pub const LipoStep = @import("LipoStep.zig"); +pub const MetallibStep = @import("MetallibStep.zig"); +pub const XCFrameworkStep = @import("XCFrameworkStep.zig"); + +// Shell completions +pub const fish_completions = @import("fish_completions.zig").completions; +pub const zsh_completions = @import("zsh_completions.zig").completions; +pub const bash_completions = @import("bash_completions.zig").completions; diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig index 5c42ea5ab..4114abc63 100644 --- a/src/build/zsh_completions.zig +++ b/src/build/zsh_completions.zig @@ -5,7 +5,7 @@ const Action = @import("../cli/action.zig").Action; /// A zsh completions configuration that contains all the available commands /// and options. -pub const zsh_completions = comptimeGenerateZshCompletions(); +pub const completions = comptimeGenerateZshCompletions(); const equals_required = "=-:::"; diff --git a/src/build_config.zig b/src/build_config.zig index 13131c132..b80247aab 100644 --- a/src/build_config.zig +++ b/src/build_config.zig @@ -10,88 +10,9 @@ const apprt = @import("apprt.zig"); const font = @import("font/main.zig"); const rendererpkg = @import("renderer.zig"); const WasmTarget = @import("os/wasm/target.zig").Target; +const BuildConfig = @import("build/Config.zig"); -/// The build configurations options. This may not be all available options -/// to `zig build` but it contains all the options that the Ghostty source -/// needs to know about at comptime. -/// -/// We put this all in a single struct so that we can check compatibility -/// between options, make it easy to copy and mutate options for different -/// build types, etc. -pub const BuildConfig = struct { - version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, - flatpak: bool = false, - adwaita: bool = false, - x11: bool = false, - wayland: bool = false, - sentry: bool = true, - app_runtime: apprt.Runtime = .none, - renderer: rendererpkg.Impl = .opengl, - font_backend: font.Backend = .freetype, - - /// The entrypoint for exe targets. - exe_entrypoint: ExeEntrypoint = .ghostty, - - /// The target runtime for the wasm build and whether to use wasm shared - /// memory or not. These are both legacy wasm-specific options that we - /// will probably have to revisit when we get back to work on wasm. - wasm_target: WasmTarget = .browser, - wasm_shared: bool = true, - - /// Configure the build options with our values. - pub fn addOptions(self: BuildConfig, step: *std.Build.Step.Options) !void { - // We need to break these down individual because addOption doesn't - // support all types. - step.addOption(bool, "flatpak", self.flatpak); - step.addOption(bool, "adwaita", self.adwaita); - step.addOption(bool, "x11", self.x11); - step.addOption(bool, "wayland", self.wayland); - step.addOption(bool, "sentry", self.sentry); - step.addOption(apprt.Runtime, "app_runtime", self.app_runtime); - step.addOption(font.Backend, "font_backend", self.font_backend); - step.addOption(rendererpkg.Impl, "renderer", self.renderer); - step.addOption(ExeEntrypoint, "exe_entrypoint", self.exe_entrypoint); - step.addOption(WasmTarget, "wasm_target", self.wasm_target); - step.addOption(bool, "wasm_shared", self.wasm_shared); - - // Our version. We also add the string version so we don't need - // to do any allocations at runtime. This has to be long enough to - // accommodate realistic large branch names for dev versions. - var buf: [1024]u8 = undefined; - step.addOption(std.SemanticVersion, "app_version", self.version); - step.addOption([:0]const u8, "app_version_string", try std.fmt.bufPrintZ( - &buf, - "{}", - .{self.version}, - )); - step.addOption( - ReleaseChannel, - "release_channel", - channel: { - const pre = self.version.pre orelse break :channel .stable; - if (pre.len == 0) break :channel .stable; - break :channel .tip; - }, - ); - } - - /// Rehydrate our BuildConfig from the comptime options. Note that not all - /// options are available at comptime, so look closely at this implementation - /// to see what is and isn't available. - pub fn fromOptions() BuildConfig { - return .{ - .version = options.app_version, - .flatpak = options.flatpak, - .adwaita = options.adwaita, - .app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?, - .font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?, - .renderer = std.meta.stringToEnum(rendererpkg.Impl, @tagName(options.renderer)).?, - .exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?, - .wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?, - .wasm_shared = options.wasm_shared, - }; - } -}; +pub const ReleaseChannel = BuildConfig.ReleaseChannel; /// The semantic version of this build. pub const version = options.app_version; @@ -114,7 +35,7 @@ pub const artifact = Artifact.detect(); /// Our build configuration. We re-export a lot of these back at the /// top-level so its a bit cleaner to use throughout the code. See the doc /// comments in BuildConfig for details on each. -pub const config = BuildConfig.fromOptions(); +const config = BuildConfig.fromOptions(); pub const exe_entrypoint = config.exe_entrypoint; pub const flatpak = options.flatpak; pub const app_runtime: apprt.Runtime = config.app_runtime; @@ -175,35 +96,3 @@ pub const Artifact = enum { }; } }; - -/// The possible entrypoints for the exe artifact. This has no effect on -/// other artifact types (i.e. lib, wasm_module). -/// -/// The whole existence of this enum is to workaround the fact that Zig -/// doesn't allow the main function to be in a file in a subdirctory -/// from the "root" of the module, and I don't want to pollute our root -/// directory with a bunch of individual zig files for each entrypoint. -/// -/// Therefore, main.zig uses this to switch between the different entrypoints. -pub const ExeEntrypoint = enum { - ghostty, - helpgen, - mdgen_ghostty_1, - mdgen_ghostty_5, - webgen_config, - webgen_actions, - bench_parser, - bench_stream, - bench_codepoint_width, - bench_grapheme_break, - bench_page_init, -}; - -/// The release channel for the build. -pub const ReleaseChannel = enum { - /// Unstable builds on every commit. - tip, - - /// Stable tagged releases. - stable, -}; From bade7be021c531a9ca02b8d5ebf2a767a11d5007 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 7 Jan 2025 20:19:08 -0800 Subject: [PATCH 080/238] Use build.zig.zon for Wayland protocols --- build.zig.zon | 42 +++++++++++++++++++++++++--------------- nix/zigCacheHash.nix | 2 +- src/build/SharedDeps.zig | 11 ++++++++++- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 33be26193..518022486 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -13,6 +13,14 @@ .hash = "12206ed982e709e565d536ce930701a8c07edfd2cfdce428683f3f2a601d37696a62", .lazy = true, }, + .vaxis = .{ + .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", + .hash = "12200df4ebeaed45de26cb2c9f3b6f3746d8013b604e035dae658f86f586c8c91d2f", + }, + .z2d = .{ + .url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a", + .hash = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a", + }, .zig_objc = .{ .url = "https://github.com/mitchellh/zig-objc/archive/9b8ba849b0f58fe207ecd6ab7c147af55b17556e.tar.gz", .hash = "1220e17e64ef0ef561b3e4b9f3a96a2494285f2ec31c097721bf8c8677ec4415c634", @@ -29,6 +37,10 @@ .url = "https://codeberg.org/ifreund/zig-wayland/archive/a5e2e9b6a6d7fba638ace4d4b24a3b576a02685b.tar.gz", .hash = "1220d41b23ae70e93355bb29dac1c07aa6aeb92427a2dffc4375e94b4de18111248c", }, + .zf = .{ + .url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd", + .hash = "1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8", + }, // C libs .cimgui = .{ .path = "./pkg/cimgui" }, @@ -50,27 +62,25 @@ .glslang = .{ .path = "./pkg/glslang" }, .spirv_cross = .{ .path = "./pkg/spirv-cross" }, + // Wayland + .wayland = .{ + .url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", + .hash = "12202cdac858abc52413a6c6711d5026d2d3c8e13f95ca2c327eade0736298bb021f", + }, + .wayland_protocols = .{ + .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", + .hash = "12201a57c6ce0001aa034fa80fba3e1cd2253c560a45748f4f4dd21ff23b491cddef", + }, + .plasma_wayland_protocols = .{ + .url = "git+https://github.com/KDE/plasma-wayland-protocols?ref=main#db525e8f9da548cffa2ac77618dd0fbe7f511b86", + .hash = "12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566", + }, + // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/4762ad5bd6d3906e28babdc2bda8a967d63a63be.tar.gz", .hash = "1220a263b22113273d01bd33e3c06b8119cb2f63b4e5d414a85d88e3aa95bb68a2de", }, - .vaxis = .{ - .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", - .hash = "12200df4ebeaed45de26cb2c9f3b6f3746d8013b604e035dae658f86f586c8c91d2f", - }, - .zf = .{ - .url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd", - .hash = "1220edc3b8d8bedbb50555947987e5e8e2f93871ca3c8e8d4cc8f1377c15b5dd35e8", - }, - .z2d = .{ - .url = "git+https://github.com/vancluever/z2d?ref=v0.4.0#4638bb02a9dc41cc2fb811f092811f6a951c752a", - .hash = "12201f0d542e7541cf492a001d4d0d0155c92f58212fbcb0d224e95edeba06b5416a", - }, - .plasma_wayland_protocols = .{ - .url = "git+https://invent.kde.org/libraries/plasma-wayland-protocols.git?ref=master#db525e8f9da548cffa2ac77618dd0fbe7f511b86", - .hash = "12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566", - }, }, } diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index f2592adf4..3806c64c9 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-eUY6MS3//r6pA/w9b+E4+YqmqUbzpUfL3afJJlnMhLY=" +"sha256-PnfSy793kcVt85q47kWR0xkivXoMOZAAmuUyKO9vqAI=" diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 792199212..077da96a6 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -435,7 +435,16 @@ pub fn add( if (self.config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts); if (self.config.wayland) { - const scanner = Scanner.create(b, .{}); + const scanner = Scanner.create(b, .{ + // We shouldn't be using getPath but we need to for now + // https://codeberg.org/ifreund/zig-wayland/issues/66 + .wayland_xml_path = b.dependency("wayland", .{}) + .path("protocol/wayland.xml") + .getPath(b), + .wayland_protocols_path = b.dependency("wayland_protocols", .{}) + .path("") + .getPath(b), + }); const wayland = b.createModule(.{ .root_source_file = scanner.result }); From c97205161155c227cd4102e050e16933ec7e806f Mon Sep 17 00:00:00 2001 From: Gabriele Musco Date: Wed, 8 Jan 2025 13:43:53 +0100 Subject: [PATCH 081/238] linux: add "Open in Ghostty" shortcut for nautilus --- dist/linux/ghostty_nautilus.py | 97 ++++++++++++++++++++++++++++++++++ src/build/GhosttyResources.zig | 6 +++ 2 files changed, 103 insertions(+) create mode 100644 dist/linux/ghostty_nautilus.py diff --git a/dist/linux/ghostty_nautilus.py b/dist/linux/ghostty_nautilus.py new file mode 100644 index 000000000..42c397642 --- /dev/null +++ b/dist/linux/ghostty_nautilus.py @@ -0,0 +1,97 @@ +# Adapted from wezterm: https://github.com/wez/wezterm/blob/main/assets/wezterm-nautilus.py +# original copyright notice: +# +# Copyright (C) 2022 Sebastian Wiesner +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from os.path import isdir +from gi import require_version +from gi.repository import Nautilus, GObject, Gio, GLib + + +class OpenInGhosttyAction(GObject.GObject, Nautilus.MenuProvider): + def __init__(self): + super().__init__() + session = Gio.bus_get_sync(Gio.BusType.SESSION, None) + self._systemd = None + # Check if the this system runs under systemd, per sd_booted(3) + if isdir('/run/systemd/system/'): + self._systemd = Gio.DBusProxy.new_sync(session, + Gio.DBusProxyFlags.NONE, + None, + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", None) + + def _open_terminal(self, path): + cmd = ['ghostty', f'--working-directory={path}', '--gtk-single-instance=false'] + child = Gio.Subprocess.new(cmd, Gio.SubprocessFlags.NONE) + if self._systemd: + # Move new terminal into a dedicated systemd scope to make systemd + # track the terminal separately; in particular this makes systemd + # keep a separate CPU and memory account for the terminal which in turn + # ensures that oomd doesn't take nautilus down if a process in + # ghostty consumes a lot of memory. + pid = int(child.get_identifier()) + props = [("PIDs", GLib.Variant('au', [pid])), + ('CollectMode', GLib.Variant('s', 'inactive-or-failed'))] + name = 'app-nautilus-com.mitchellh.ghostty-{}.scope'.format(pid) + args = GLib.Variant('(ssa(sv)a(sa(sv)))', (name, 'fail', props, [])) + self._systemd.call_sync('StartTransientUnit', args, + Gio.DBusCallFlags.NO_AUTO_START, 500, None) + + def _menu_item_activated(self, _menu, paths): + for path in paths: + self._open_terminal(path) + + def _make_item(self, name, paths): + item = Nautilus.MenuItem(name=name, label='Open in Ghostty', + icon='com.mitchellh.ghostty') + item.connect('activate', self._menu_item_activated, paths) + return item + + def _paths_to_open(self, files): + paths = [] + for file in files: + location = file.get_location() if file.is_directory() else file.get_parent_location() + path = location.get_path() + if path and path not in paths: + paths.append(path) + if 10 < len(paths): + # Let's not open anything if the user selected a lot of directories, + # to avoid accidentally spamming their desktop with dozends of + # new windows or tabs. Ten is a totally arbitrary limit :) + return [] + else: + return paths + + def get_file_items(self, *args): + # Nautilus 3.0 API passes args (window, files), 4.0 API just passes files + files = args[0] if len(args) == 1 else args[1] + paths = self._paths_to_open(files) + if paths: + return [self._make_item(name='GhosttyNautilus::open_in_ghostty', paths=paths)] + else: + return [] + + def get_background_items(self, *args): + # Nautilus 3.0 API passes args (window, file), 4.0 API just passes file + file = args[0] if len(args) == 1 else args[1] + paths = self._paths_to_open([file]) + if paths: + return [self._make_item(name='GhosttyNautilus::open_folder_in_ghostty', paths=paths)] + else: + return [] diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 9c5f7f809..cae907ec2 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -200,6 +200,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { "share/kio/servicemenus/com.mitchellh.ghostty.desktop", ).step); + // Right click menu action for Nautilus + try steps.append(&b.addInstallFile( + b.path("dist/linux/ghostty_nautilus.py"), + "share/nautilus-python/extensions/com.mitchellh.ghostty.py", + ).step); + // Various icons that our application can use, including the icon // that will be used for the desktop. try steps.append(&b.addInstallFile( From e7c71df0b7c056a426e6d61a2feff785741d1e6d Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 1 Jan 2025 13:38:46 -0600 Subject: [PATCH 082/238] gtk: implement dropping files and strings --- src/apprt/gtk/Surface.zig | 104 ++++++++++++++++++++++++++++++++++++++ src/os/main.zig | 2 + src/os/shell.zig | 90 +++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 src/os/shell.zig diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 056a3f40b..f0ae073f9 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -492,6 +492,17 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { c.gtk_widget_set_focusable(gl_area, 1); c.gtk_widget_set_focus_on_click(gl_area, 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 = c.gtk_drop_target_new(c.G_TYPE_INVALID, c.GDK_ACTION_COPY); + errdefer c.g_object_unref(drop_target); + var drop_target_types = [_]c.GType{ + c.gdk_file_list_get_type(), + c.G_TYPE_STRING, + }; + c.gtk_drop_target_set_gtypes(drop_target, @ptrCast(&drop_target_types), drop_target_types.len); + c.gtk_widget_add_controller(@ptrCast(overlay), @ptrCast(drop_target)); + // 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; @@ -574,6 +585,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { _ = c.g_signal_connect_data(im_context, "preedit-changed", c.G_CALLBACK(>kInputPreeditChanged), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(im_context, "preedit-end", c.G_CALLBACK(>kInputPreeditEnd), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(im_context, "commit", c.G_CALLBACK(>kInputCommit), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(drop_target, "drop", c.G_CALLBACK(>kDrop), self, null, c.G_CONNECT_DEFAULT); } fn realize(self: *Surface) !void { @@ -2025,3 +2037,95 @@ pub fn setSplitZoom(self: *Surface, new_split_zoom: bool) void { pub fn toggleSplitZoom(self: *Surface) void { self.setSplitZoom(!self.zoomed_in); } + +/// Handle items being dropped on our surface. +fn gtkDrop( + _: *c.GtkDropTarget, + value: *c.GValue, + x: f64, + y: f64, + ud: ?*anyopaque, +) callconv(.C) c.gboolean { + _ = x; + _ = y; + const self = userdataSelf(ud.?); + const alloc = self.app.core_app.alloc; + + if (g_value_holds(value, c.G_TYPE_BOXED)) { + 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 fl: *c.GdkFileList = @ptrCast(c.g_value_get_boxed(value)); + var l = c.gdk_file_list_get_files(fl); + + while (l != null) : (l = l.*.next) { + const file: *c.GFile = @ptrCast(l.*.data); + const path = c.g_file_get_path(file) orelse continue; + + writer.writeAll(std.mem.span(path)) 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 1; + }; + defer alloc.free(string); + + self.doPaste(string); + + return 1; + } + + if (g_value_holds(value, c.G_TYPE_STRING)) { + if (c.g_value_get_string(value)) |string| { + self.doPaste(std.mem.span(string)); + } + 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, + ) 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}), + }; +} + +/// 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_: ?*c.GValue, g_type: c.GType) bool { + if (value_) |value| { + if (value.*.g_type == g_type) return true; + return c.g_type_check_value_holds(value, g_type) != 0; + } + return false; +} diff --git a/src/os/main.zig b/src/os/main.zig index e652a7981..df6f894f5 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -21,6 +21,7 @@ pub const passwd = @import("passwd.zig"); pub const xdg = @import("xdg.zig"); pub const windows = @import("windows.zig"); pub const macos = @import("macos.zig"); +pub const shell = @import("shell.zig"); // Functions and types pub const CFReleaseThread = @import("cf_release_thread.zig"); @@ -48,3 +49,4 @@ pub const open = openpkg.open; pub const OpenType = openpkg.Type; pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; +pub const ShellEscapeWriter = shell.ShellEscapeWriter; diff --git a/src/os/shell.zig b/src/os/shell.zig new file mode 100644 index 000000000..a9cb61847 --- /dev/null +++ b/src/os/shell.zig @@ -0,0 +1,90 @@ +const std = @import("std"); +const testing = std.testing; + +pub fn ShellEscapeWriter(comptime T: type) type { + return struct { + child_writer: T, + + fn write(self: *ShellEscapeWriter(T), data: []const u8) error{Error}!usize { + var count: usize = 0; + for (data) |byte| { + const buf = switch (byte) { + '\\', + '"', + '\'', + '$', + '`', + '*', + '?', + ' ', + '|', + => &[_]u8{ '\\', byte }, + else => &[_]u8{byte}, + }; + self.child_writer.writeAll(buf) catch return error.Error; + count += 1; + } + return count; + } + + const Writer = std.io.Writer(*ShellEscapeWriter(T), error{Error}, write); + + pub fn writer(self: *ShellEscapeWriter(T)) Writer { + return .{ .context = self }; + } + }; +} + +test "shell escape 1" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("abc"); + try testing.expectEqualStrings("abc", fmt.getWritten()); +} + +test "shell escape 2" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("a c"); + try testing.expectEqualStrings("a\\ c", fmt.getWritten()); +} + +test "shell escape 3" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("a?c"); + try testing.expectEqualStrings("a\\?c", fmt.getWritten()); +} + +test "shell escape 4" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("a\\c"); + try testing.expectEqualStrings("a\\\\c", fmt.getWritten()); +} + +test "shell escape 5" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("a|c"); + try testing.expectEqualStrings("a\\|c", fmt.getWritten()); +} + +test "shell escape 6" { + var buf: [128]u8 = undefined; + var fmt = std.io.fixedBufferStream(&buf); + var shell: ShellEscapeWriter(@TypeOf(fmt).Writer) = .{ .child_writer = fmt.writer() }; + const writer = shell.writer(); + try writer.writeAll("a\"c"); + try testing.expectEqualStrings("a\\\"c", fmt.getWritten()); +} From e86b9a112e49dd147fe63210ef9ce9c8d0d5e72f Mon Sep 17 00:00:00 2001 From: Wes Campaigne Date: Mon, 6 Jan 2025 19:01:19 -0500 Subject: [PATCH 083/238] Implement "Paste Selection" on macOS like Terminal.app --- macos/Sources/App/macOS/AppDelegate.swift | 2 ++ macos/Sources/App/macOS/MainMenu.xib | 11 +++++++-- .../Terminal/BaseTerminalController.swift | 4 ++-- macos/Sources/Ghostty/Ghostty.App.swift | 18 +++++++------- macos/Sources/Ghostty/Package.swift | 4 ++-- .../Sources/Ghostty/SurfaceView_AppKit.swift | 24 +++++++++++++++++++ macos/Sources/Helpers/CrossKit.swift | 2 ++ .../Helpers/NSPasteboard+Extension.swift | 20 ++++++++++++++++ src/config/Config.zig | 14 +++++++---- 9 files changed, 78 insertions(+), 21 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index e3518cd2b..2fe835303 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -35,6 +35,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem? + @IBOutlet private var menuPasteSelection: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @@ -353,6 +354,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) + syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 7a8e0d894..0a197fe65 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -31,6 +31,7 @@ + @@ -185,6 +186,12 @@ + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 393c6ef4d..bda6d62bf 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -389,9 +389,9 @@ class BaseTerminalController: NSWindowController, } switch (request) { - case .osc_52_write: + case let .osc_52_write(pasteboard): guard case .confirm = action else { break } - let pb = NSPasteboard.general + let pb = pasteboard ?? NSPasteboard.general pb.declareTypes([.string], owner: nil) pb.setString(cc.contents, forType: .string) case .osc_52_read, .paste: diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index ed140dcd5..3a2510e3b 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -62,7 +62,7 @@ extension Ghostty { // uses to interface with the application runtime environment. var runtime_cfg = ghostty_runtime_config_s( userdata: Unmanaged.passUnretained(self).toOpaque(), - supports_selection_clipboard: false, + supports_selection_clipboard: true, wakeup_cb: { userdata in App.wakeup(userdata) }, action_cb: { app, target, action in App.action(app!, target: target, action: action) }, read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, @@ -320,13 +320,13 @@ extension Ghostty { let surfaceView = self.surfaceUserdata(from: userdata) guard let surface = surfaceView.surface else { return } - // We only support the standard clipboard - if (location != GHOSTTY_CLIPBOARD_STANDARD) { + // Get our pasteboard + guard let pasteboard = NSPasteboard.ghostty(location) else { return completeClipboardRequest(surface, data: "", state: state) } // Get our string - let str = NSPasteboard.general.getOpinionatedStringContents() ?? "" + let str = pasteboard.getOpinionatedStringContents() ?? "" completeClipboardRequest(surface, data: str, state: state) } @@ -364,14 +364,12 @@ extension Ghostty { static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, location: ghostty_clipboard_e, confirm: Bool) { let surface = self.surfaceUserdata(from: userdata) - // We only support the standard clipboard - if (location != GHOSTTY_CLIPBOARD_STANDARD) { return } + guard let pasteboard = NSPasteboard.ghostty(location) else { return } guard let valueStr = String(cString: string!, encoding: .utf8) else { return } if !confirm { - let pb = NSPasteboard.general - pb.declareTypes([.string], owner: nil) - pb.setString(valueStr, forType: .string) + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString(valueStr, forType: .string) return } @@ -380,7 +378,7 @@ extension Ghostty { object: surface, userInfo: [ Notification.ConfirmClipboardStrKey: valueStr, - Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write, + Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write(pasteboard), ] ) } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index d09100212..deca8f89d 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -159,7 +159,7 @@ extension Ghostty { case osc_52_read /// An application is attempting to write to the clipboard using OSC 52 - case osc_52_write + case osc_52_write(OSPasteboard?) /// The text to show in the clipboard confirmation prompt for a given request type func text() -> String { @@ -188,7 +188,7 @@ extension Ghostty { case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ: return .osc_52_read case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE: - return .osc_52_write + return .osc_52_write(nil) default: return nil } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index cf4357a8c..c933eb9bf 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1127,6 +1127,14 @@ extension Ghostty { } } + @IBAction func pasteSelection(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "paste_from_selection" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + @IBAction override func selectAll(_ sender: Any?) { guard let surface = self.surface else { return } let action = "select_all" @@ -1448,3 +1456,19 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor { return true } } + +// MARK: NSMenuItemValidation + +extension Ghostty.SurfaceView: NSMenuItemValidation { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(pasteSelection): + let pb = NSPasteboard.ghosttySelection + guard let str = pb.getOpinionatedStringContents() else { return false } + return !str.isEmpty + + default: + return true + } + } +} diff --git a/macos/Sources/Helpers/CrossKit.swift b/macos/Sources/Helpers/CrossKit.swift index 5a69b45a3..690e811bb 100644 --- a/macos/Sources/Helpers/CrossKit.swift +++ b/macos/Sources/Helpers/CrossKit.swift @@ -10,6 +10,7 @@ import AppKit typealias OSView = NSView typealias OSColor = NSColor typealias OSSize = NSSize +typealias OSPasteboard = NSPasteboard protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType { associatedtype OSViewType: NSView @@ -34,6 +35,7 @@ import UIKit typealias OSView = UIView typealias OSColor = UIColor typealias OSSize = CGSize +typealias OSPasteboard = UIPasteboard protocol OSViewRepresentable: UIViewRepresentable { associatedtype OSViewType: UIView diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/NSPasteboard+Extension.swift index b1755fea0..7794946f4 100644 --- a/macos/Sources/Helpers/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/NSPasteboard+Extension.swift @@ -1,6 +1,12 @@ import AppKit +import GhosttyKit extension NSPasteboard { + /// The pasteboard to used for Ghostty selection. + static var ghosttySelection: NSPasteboard = { + NSPasteboard(name: .init("com.mitchellh.ghostty.selection")) + }() + /// Gets the contents of the pasteboard as a string following a specific set of semantics. /// Does these things in order: /// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one. @@ -14,4 +20,18 @@ extension NSPasteboard { } return self.string(forType: .string) } + + /// The pasteboard for the Ghostty enum type. + static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? { + switch (clipboard) { + case GHOSTTY_CLIPBOARD_STANDARD: + return Self.general + + case GHOSTTY_CLIPBOARD_SELECTION: + return Self.ghosttySelection + + default: + return nil + } + } } diff --git a/src/config/Config.zig b/src/config/Config.zig index 2f38676c5..b14f83f64 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1389,13 +1389,10 @@ keybind: Keybinds = .{}, /// and the system clipboard on macOS. Middle-click paste is always enabled /// even if this is `false`. /// -/// The default value is true on Linux and false on macOS. macOS copy on -/// select behavior is not typical for applications so it is disabled by -/// default. On Linux, this is a standard behavior so it is enabled by -/// default. +/// The default value is true on Linux and macOS. @"copy-on-select": CopyOnSelect = switch (builtin.os.tag) { .linux => .true, - .macos => .false, + .macos => .true, else => .false, }, @@ -2749,6 +2746,13 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .toggle_fullscreen = {} }, ); + // Selection clipboard paste, matches Terminal.app + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .v }, .mods = .{ .super = true, .shift = true } }, + .{ .paste_from_selection = {} }, + ); + // "Natural text editing" keybinds. This forces these keys to go back // to legacy encoding (not fixterms). It seems macOS users more than // others are used to these keys so we set them as defaults. If From 40442ac02fad9c54a363feadef78bf70878b17f7 Mon Sep 17 00:00:00 2001 From: Kwee Lung Sin Date: Fri, 3 Jan 2025 16:10:48 +0800 Subject: [PATCH 084/238] chore: replace adwaita-1 to libadwaita-1 for better pkg-config handling --- src/build/SharedDeps.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 077da96a6..013aa5593 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -431,7 +431,7 @@ pub fn add( .gtk => { step.linkSystemLibrary2("gtk4", dynamic_link_opts); - if (self.config.adwaita) step.linkSystemLibrary2("adwaita-1", dynamic_link_opts); + if (self.config.adwaita) step.linkSystemLibrary2("libadwaita-1", dynamic_link_opts); if (self.config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts); if (self.config.wayland) { From 2206c509be70a314230e35422e45ba9a7de94fe6 Mon Sep 17 00:00:00 2001 From: Soh Satoh <20023945+sohsatoh@users.noreply.github.com> Date: Mon, 30 Dec 2024 18:57:27 +0900 Subject: [PATCH 085/238] Show quick terminal on another full-screen app --- .../Features/QuickTerminal/QuickTerminalWindow.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift index ed3a7f781..552b87e25 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift @@ -1,6 +1,6 @@ import Cocoa -class QuickTerminalWindow: NSWindow { +class QuickTerminalWindow: NSPanel { // Both of these must be true for windows without decorations to be able to // still become key/main and receive events. override var canBecomeKey: Bool { return true } @@ -26,6 +26,9 @@ class QuickTerminalWindow: NSWindow { // window remains resizable. self.styleMask.remove(.titled) + // We don't want to activate the owning app when quick terminal is triggered. + self.styleMask.insert(.nonactivatingPanel) + // We need to set our window level to a high value. In testing, only // popUpMenu and above do what we want. This gets it above the menu bar // and lets us render off screen. @@ -41,7 +44,7 @@ class QuickTerminalWindow: NSWindow { // We don't want to be part of command-tilde .ignoresCycle, - // We never support fullscreen - .fullScreenNone] + // We want to show the window on another space if it is visible + .fullScreenAuxiliary] } } From e2523c25cbe7b034b88cba23c1e7185dfeed0d0c Mon Sep 17 00:00:00 2001 From: Soh Satoh <20023945+sohsatoh@users.noreply.github.com> Date: Tue, 31 Dec 2024 21:55:03 +0900 Subject: [PATCH 086/238] Add quick-terminal-space-behavior option --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ .../QuickTerminalController.swift | 44 ++++++++++++++++++- .../QuickTerminalSpaceBehavior.swift | 36 +++++++++++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 10 +++++ src/config/Config.zig | 14 ++++++ 5 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index fded20911..3fa67c48a 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -102,6 +102,7 @@ C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; }; C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EE82B76CBFC00404083 /* VibrantLayer.m */; }; + CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */; }; FC5218FA2D10FFCE004C93E0 /* zsh in Resources */ = {isa = PBXBuildFile; fileRef = FC5218F92D10FFC7004C93E0 /* zsh */; }; FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; }; /* End PBXBuildFile section */ @@ -198,6 +199,7 @@ C1F26EE72B76CBFC00404083 /* VibrantLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VibrantLayer.h; sourceTree = ""; }; C1F26EE82B76CBFC00404083 /* VibrantLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VibrantLayer.m; sourceTree = ""; }; C1F26EEA2B76CC2400404083 /* ghostty-bridging-header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "ghostty-bridging-header.h"; sourceTree = ""; }; + CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalSpaceBehavior.swift; sourceTree = ""; }; FC5218F92D10FFC7004C93E0 /* zsh */ = {isa = PBXFileReference; lastKnownFileType = folder; name = zsh; path = "../zig-out/share/zsh"; sourceTree = ""; }; FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -448,6 +450,7 @@ children = ( A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, + CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */, A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */, A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */, @@ -616,6 +619,7 @@ A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, + CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 47ee2dfd9..2a443e8ed 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -3,6 +3,12 @@ import Cocoa import SwiftUI import GhosttyKit +// This is a Apple's private function that we need to call to get the active space. +@_silgen_name("CGSGetActiveSpace") +func CGSGetActiveSpace(_ cid: Int) -> size_t +@_silgen_name("CGSMainConnectionID") +func CGSMainConnectionID() -> Int + /// Controller for the "quick" terminal. class QuickTerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { "QuickTerminal" } @@ -18,6 +24,9 @@ class QuickTerminalController: BaseTerminalController { /// application to the front. private var previousApp: NSRunningApplication? = nil + // The active space when the quick terminal was last shown. + private var previousActiveSpace: size_t = 0 + /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig @@ -81,6 +90,9 @@ class QuickTerminalController: BaseTerminalController { delegate: self )) + // Change the collection behavior of the window depending on the configuration. + window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior + // Animate the window in animateIn() } @@ -107,8 +119,27 @@ class QuickTerminalController: BaseTerminalController { self.previousApp = nil } - if (derivedConfig.quickTerminalAutoHide) { - animateOut() + if derivedConfig.quickTerminalAutoHide { + switch derivedConfig.quickTerminalSpaceBehavior { + case .remain: + if self.window?.isOnActiveSpace == true { + // If we lose focus on the active space, then we can animate out + animateOut() + } + case .move: + // Check if the reason for losing focus is due to an active space change + let currentActiveSpace = CGSGetActiveSpace(CGSMainConnectionID()) + if previousActiveSpace == currentActiveSpace { + // If we lose focus on the active space, then we can animate out + animateOut() + } else { + // If we're from different space, then we bring the window back + DispatchQueue.main.async { + self.window?.makeKeyAndOrderFront(nil) + } + } + self.previousActiveSpace = currentActiveSpace + } } } @@ -163,6 +194,9 @@ class QuickTerminalController: BaseTerminalController { } } + // Set previous active space + self.previousActiveSpace = CGSGetActiveSpace(CGSMainConnectionID()) + // Animate the window in animateWindowIn(window: window, from: position) @@ -390,12 +424,16 @@ class QuickTerminalController: BaseTerminalController { self.derivedConfig = DerivedConfig(config) syncAppearance() + + // Update window.collectionBehavior + self.window?.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior } private struct DerivedConfig { let quickTerminalScreen: QuickTerminalScreen let quickTerminalAnimationDuration: Double let quickTerminalAutoHide: Bool + let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior let windowColorspace: String let backgroundOpacity: Double @@ -403,6 +441,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalScreen = .main self.quickTerminalAnimationDuration = 0.2 self.quickTerminalAutoHide = true + self.quickTerminalSpaceBehavior = .move self.windowColorspace = "" self.backgroundOpacity = 1.0 } @@ -411,6 +450,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalScreen = config.quickTerminalScreen self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration self.quickTerminalAutoHide = config.quickTerminalAutoHide + self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior self.windowColorspace = config.windowColorspace self.backgroundOpacity = config.backgroundOpacity } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift new file mode 100644 index 000000000..18e283d7b --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift @@ -0,0 +1,36 @@ +import Foundation +import Cocoa + +enum QuickTerminalSpaceBehavior { + case remain + case move + + init?(fromGhosttyConfig string: String) { + switch (string) { + case "move": + self = .move + + case "remain": + self = .remain + + default: + return nil + } + } + + var collectionBehavior: NSWindow.CollectionBehavior { + let commonBehavior: [NSWindow.CollectionBehavior] = [ + .ignoresCycle, + .fullScreenAuxiliary + ] + + switch (self) { + case .move: + // We want this to be part of every space because it is a singleton. + return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior) + case .remain: + // We want this to move the window to the active space. + return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior) + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index ed9364914..1b3263fc3 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -431,6 +431,16 @@ extension Ghostty { _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v } + + var quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior { + guard let config = self.config else { return .move } + var v: UnsafePointer? = nil + let key = "quick-terminal-space-behavior" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .move } + guard let ptr = v else { return .move } + let str = String(cString: ptr) + return QuickTerminalSpaceBehavior(fromGhosttyConfig: str) ?? .move + } #endif var resizeOverlay: ResizeOverlay { diff --git a/src/config/Config.zig b/src/config/Config.zig index da39e84ac..e792eba38 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1567,6 +1567,14 @@ keybind: Keybinds = .{}, /// Set it to false for the quick terminal to remain open even when it loses focus. @"quick-terminal-autohide": bool = true, +/// This configuration option determines the behavior of the quick terminal +/// when switching between spaces. If set to `move`, the quick terminal +/// will stay only in the space where it was originally opened and will not +/// follow when switching to another space. If set to `remain`, the quick terminal +/// will remain open and visible across all spaces, including after moving to +/// a different space. +@"quick-terminal-space-behavior": QuickTerminalSpaceBehavior = .move, + /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: /// @@ -5695,6 +5703,12 @@ pub const QuickTerminalScreen = enum { @"macos-menu-bar", }; +// See quick-terminal-space-behavior +pub const QuickTerminalSpaceBehavior = enum { + remain, + move, +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy, From 7bb3c31ceef22b85a9bbd5c2d9e0a41566730681 Mon Sep 17 00:00:00 2001 From: Soh Satoh <20023945+sohsatoh@users.noreply.github.com> Date: Wed, 1 Jan 2025 02:34:46 +0900 Subject: [PATCH 087/238] Move the quick terminal to active space if toggle() called while opening on another space --- .../Features/QuickTerminal/QuickTerminalController.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 3cae47b1d..4063aa26f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -164,6 +164,12 @@ class QuickTerminalController: BaseTerminalController { // MARK: Methods func toggle() { + if derivedConfig.quickTerminalSpaceBehavior == .remain && self.window?.isOnActiveSpace == false { + // If we're in the remain mode and the window is not on the active space, then we bring the window back to the active space. + self.window?.makeKeyAndOrderFront(nil) + return + } + if (visible) { animateOut() } else { From 0ddc1a21a694e4fb5b54ebd4d4de2e903b0aea67 Mon Sep 17 00:00:00 2001 From: Soh Satoh <20023945+sohsatoh@users.noreply.github.com> Date: Wed, 1 Jan 2025 07:48:30 +0900 Subject: [PATCH 088/238] fix the comment (quick-terminal-space-behavior) --- .../QuickTerminal/QuickTerminalSpaceBehavior.swift | 4 ++-- src/config/Config.zig | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift index 18e283d7b..0561aaa18 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift @@ -26,10 +26,10 @@ enum QuickTerminalSpaceBehavior { switch (self) { case .move: - // We want this to be part of every space because it is a singleton. + // We want this to move the window to the active space. return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior) case .remain: - // We want this to move the window to the active space. + // We want this to remain the window in the current space. return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior) } } diff --git a/src/config/Config.zig b/src/config/Config.zig index e792eba38..744dbcf7e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1568,11 +1568,10 @@ keybind: Keybinds = .{}, @"quick-terminal-autohide": bool = true, /// This configuration option determines the behavior of the quick terminal -/// when switching between spaces. If set to `move`, the quick terminal -/// will stay only in the space where it was originally opened and will not -/// follow when switching to another space. If set to `remain`, the quick terminal -/// will remain open and visible across all spaces, including after moving to -/// a different space. +/// when switching between spaces. If set to `move`, the quick terminal will +/// be moved to the space where the focused window is. If set to `remain`, +/// the quick terminal will stay only in the space where it was originally opened and +/// will not follow when switching to another space. @"quick-terminal-space-behavior": QuickTerminalSpaceBehavior = .move, /// Whether to enable shell integration auto-injection or not. Shell integration From 37db4578c8563dc7a744ec028a17c6635ab39e5f Mon Sep 17 00:00:00 2001 From: Soh Satoh <20023945+sohsatoh@users.noreply.github.com> Date: Wed, 1 Jan 2025 07:30:46 +0900 Subject: [PATCH 089/238] Fix the issue that the quick term not shown on first call --- .../Features/QuickTerminal/QuickTerminalController.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 4063aa26f..2a4137dc9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -166,7 +166,9 @@ class QuickTerminalController: BaseTerminalController { func toggle() { if derivedConfig.quickTerminalSpaceBehavior == .remain && self.window?.isOnActiveSpace == false { // If we're in the remain mode and the window is not on the active space, then we bring the window back to the active space. - self.window?.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + self.window?.makeKeyAndOrderFront(nil) + } return } @@ -239,7 +241,9 @@ class QuickTerminalController: BaseTerminalController { position.setInitial(in: window, on: screen) // Move it to the visible position since animation requires this - window.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + window.makeKeyAndOrderFront(nil) + } // Run the animation that moves our window into the proper place and makes // it visible. From 306c7ea2beef0f8b195fbd73d5c044180cc33368 Mon Sep 17 00:00:00 2001 From: Sabarigirish Manikandan <68274755+lg28literconvectionmicrowaveoven@users.noreply.github.com> Date: Thu, 9 Jan 2025 00:37:00 +0530 Subject: [PATCH 090/238] close_tab keybind (gtk apprt only) (#4033) Title. Adds a close_tab keybind that essentially behaves the exact same as clicking the tab close button on the tab bar. --- include/ghostty.h | 1 + src/Surface.zig | 6 ++++++ src/apprt/action.zig | 4 ++++ src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 19 +++++++++++++++++++ src/config/Config.zig | 10 ++++++++++ src/input/Binding.zig | 4 ++++ 7 files changed, 45 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 0e444a2fa..29da8f37b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -562,6 +562,7 @@ typedef enum { GHOSTTY_ACTION_QUIT, GHOSTTY_ACTION_NEW_WINDOW, GHOSTTY_ACTION_NEW_TAB, + GHOSTTY_ACTION_CLOSE_TAB, GHOSTTY_ACTION_NEW_SPLIT, GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, GHOSTTY_ACTION_TOGGLE_FULLSCREEN, diff --git a/src/Surface.zig b/src/Surface.zig index 70c32098f..50ef3c0cf 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4061,6 +4061,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .close_tab => try self.rt_app.performAction( + .{ .surface = self }, + .close_tab, + {}, + ), + inline .previous_tab, .next_tab, .last_tab, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index df30f7b7b..25e1cd640 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -82,6 +82,9 @@ pub const Action = union(Key) { /// the tab should be opened in a new window. new_tab, + /// Closes the tab belonging to the currently focused split. + close_tab, + /// Create a new split. The value determines the location of the split /// relative to the target. new_split: SplitDirection, @@ -225,6 +228,7 @@ pub const Action = union(Key) { quit, new_window, new_tab, + close_tab, new_split, close_all_windows, toggle_fullscreen, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index c91464068..8094baeb8 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -218,6 +218,7 @@ pub const App = struct { .toggle_split_zoom, .present_terminal, .close_all_windows, + .close_tab, .toggle_tab_overview, .toggle_window_decorations, .toggle_quick_terminal, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 38c019b3e..86a001cba 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -477,6 +477,7 @@ pub fn performAction( .toggle_fullscreen => self.toggleFullscreen(target, value), .new_tab => try self.newTab(target), + .close_tab => try self.closeTab(target), .goto_tab => self.gotoTab(target, value), .move_tab => self.moveTab(target, value), .new_split => try self.newSplit(target, value), @@ -532,6 +533,23 @@ fn newTab(_: *App, target: apprt.Target) !void { } } +fn closeTab(_: *App, target: apprt.Target) !void { + switch (target) { + .app => {}, + .surface => |v| { + const tab = v.rt_surface.container.tab() orelse { + log.info( + "close_tab invalid for container={s}", + .{@tagName(v.rt_surface.container)}, + ); + return; + }; + + tab.window.closeTab(tab); + }, + } +} + fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) void { switch (target) { .app => {}, @@ -1743,6 +1761,7 @@ fn initMenu(self: *App) void { c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); c.g_menu_append(section, "New Window", "win.new_window"); c.g_menu_append(section, "New Tab", "win.new_tab"); + c.g_menu_append(section, "Close Tab", "win.close_tab"); c.g_menu_append(section, "Split Right", "win.split_right"); c.g_menu_append(section, "Split Down", "win.split_down"); c.g_menu_append(section, "Close Window", "win.close"); diff --git a/src/config/Config.zig b/src/config/Config.zig index da39e84ac..044e053f2 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2373,6 +2373,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .t }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_tab = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .w }, .mods = .{ .ctrl = true, .shift = true } }, + .{ .close_tab = {} }, + ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .left }, .mods = .{ .ctrl = true, .shift = true } }, @@ -2653,6 +2658,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .t }, .mods = .{ .super = true } }, .{ .new_tab = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .w }, .mods = .{ .super = true } }, + .{ .close_tab = {} }, + ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .left_bracket }, .mods = .{ .super = true, .shift = true } }, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 64e07e85e..d0a34efe4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -383,6 +383,9 @@ pub const Action = union(enum) { /// This only works for macOS currently. close_all_windows: void, + /// Closes the tab belonging to the currently focused split. + close_tab: void, + /// Toggle fullscreen mode of window. toggle_fullscreen: void, @@ -750,6 +753,7 @@ pub const Action = union(enum) { .resize_split, .equalize_splits, .inspector, + .close_tab, => .surface, }; } From c85c277415e9b65a963ca1a09be3b0720c748302 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 8 Jan 2025 13:12:36 -0600 Subject: [PATCH 091/238] core: add docs for ShellEscapeWriter --- src/os/shell.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/os/shell.zig b/src/os/shell.zig index a9cb61847..23648a82a 100644 --- a/src/os/shell.zig +++ b/src/os/shell.zig @@ -1,6 +1,11 @@ const std = @import("std"); const testing = std.testing; +/// Writer that escapes characters that shells treat specially to reduce the +/// risk of injection attacks or other such weirdness. Specifically excludes +/// linefeeds so that they can be used to delineate lists of file paths. +/// +/// T should be a Zig type that follows the `std.io.Writer` interface. pub fn ShellEscapeWriter(comptime T: type) type { return struct { child_writer: T, From 6e54589db4a5ee38a3a861e666bcd200da61572b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jan 2025 11:28:02 -0800 Subject: [PATCH 092/238] misc cleanups --- .../QuickTerminalController.swift | 34 +++++++++++-------- src/config/Config.zig | 18 +++++++--- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 2a4137dc9..b5cbbab9c 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -90,9 +90,6 @@ class QuickTerminalController: BaseTerminalController { delegate: self )) - // Change the collection behavior of the window depending on the configuration. - window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior - // Animate the window in animateIn() } @@ -122,23 +119,24 @@ class QuickTerminalController: BaseTerminalController { if derivedConfig.quickTerminalAutoHide { switch derivedConfig.quickTerminalSpaceBehavior { case .remain: - if self.window?.isOnActiveSpace == true { - // If we lose focus on the active space, then we can animate out - animateOut() - } + // If we lose focus on the active space, then we can animate out + animateOut() + case .move: - // Check if the reason for losing focus is due to an active space change let currentActiveSpace = CGSGetActiveSpace(CGSMainConnectionID()) if previousActiveSpace == currentActiveSpace { - // If we lose focus on the active space, then we can animate out + // We haven't moved spaces. We lost focus to another app on the + // current space. Animate out. animateOut() } else { - // If we're from different space, then we bring the window back + // We've moved to a different space. Bring the quick terminal back + // into view. DispatchQueue.main.async { self.window?.makeKeyAndOrderFront(nil) } + + self.previousActiveSpace = currentActiveSpace } - self.previousActiveSpace = currentActiveSpace } } } @@ -320,6 +318,14 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { + // If the window isn't on our active space then we don't animate, we just + // hide it. + if !window.isOnActiveSpace { + self.previousApp = nil + window.orderOut(self) + return + } + // We always animate out to whatever screen the window is actually on. guard let screen = window.screen ?? NSScreen.main else { return } @@ -355,6 +361,9 @@ class QuickTerminalController: BaseTerminalController { private func syncAppearance() { guard let window else { return } + // Change the collection behavior of the window depending on the configuration. + window.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior + // If our window is not visible, then no need to sync the appearance yet. // Some APIs such as window blur have no effect unless the window is visible. guard window.isVisible else { return } @@ -433,9 +442,6 @@ class QuickTerminalController: BaseTerminalController { // Update our derived config self.derivedConfig = DerivedConfig(config) - // Update window.collectionBehavior - self.window?.collectionBehavior = derivedConfig.quickTerminalSpaceBehavior.collectionBehavior - syncAppearance() } diff --git a/src/config/Config.zig b/src/config/Config.zig index 744dbcf7e..8e577ea5e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1568,10 +1568,20 @@ keybind: Keybinds = .{}, @"quick-terminal-autohide": bool = true, /// This configuration option determines the behavior of the quick terminal -/// when switching between spaces. If set to `move`, the quick terminal will -/// be moved to the space where the focused window is. If set to `remain`, -/// the quick terminal will stay only in the space where it was originally opened and -/// will not follow when switching to another space. +/// when switching between macOS spaces. macOS spaces are virtual desktops +/// that can be manually created or are automatically created when an +/// application is in full-screen mode. +/// +/// Valid values are: +/// +/// * `move` - When switching to another space, the quick terminal will +/// also moved to the current space. +/// +/// * `remain` - The quick terminal will stay only in the space where it +/// was originally opened and will not follow when switching to another +/// space. +/// +/// The default value is `move`. @"quick-terminal-space-behavior": QuickTerminalSpaceBehavior = .move, /// Whether to enable shell integration auto-injection or not. Shell integration From 6ebc02b68d31e838a9ce90b77df3a23708add980 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jan 2025 11:30:17 -0800 Subject: [PATCH 093/238] macos: animate in even if remain on another space --- .../Features/QuickTerminal/QuickTerminalController.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index b5cbbab9c..fee6f0735 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -162,14 +162,6 @@ class QuickTerminalController: BaseTerminalController { // MARK: Methods func toggle() { - if derivedConfig.quickTerminalSpaceBehavior == .remain && self.window?.isOnActiveSpace == false { - // If we're in the remain mode and the window is not on the active space, then we bring the window back to the active space. - DispatchQueue.main.async { - self.window?.makeKeyAndOrderFront(nil) - } - return - } - if (visible) { animateOut() } else { From e4033ca4dff63f89eec33aa2901fb56bf34b06c4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jan 2025 11:47:24 -0800 Subject: [PATCH 094/238] config: close_tab on macOS should be cmd+opt+w to match iTerm2 --- src/config/Config.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 0bec3f772..90e0c166f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2660,6 +2660,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .w }, .mods = .{ .super = true } }, .{ .close_surface = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .alt = true } }, + .{ .close_tab = {} }, + ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .w }, .mods = .{ .super = true, .shift = true } }, @@ -2675,11 +2680,6 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .t }, .mods = .{ .super = true } }, .{ .new_tab = {} }, ); - try result.keybind.set.put( - alloc, - .{ .key = .{ .translated = .w }, .mods = .{ .super = true } }, - .{ .close_tab = {} }, - ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .left_bracket }, .mods = .{ .super = true, .shift = true } }, From 140ac9388492e3ab4be1826eb9f028b559b61a99 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:15:29 +0800 Subject: [PATCH 095/238] Add `close_tab` keybinding action for macOS Implement `close_tab` keybinding action to close the current tab and all splits within that tab. --- macos/Sources/App/macOS/AppDelegate.swift | 2 + macos/Sources/App/macOS/MainMenu.xib | 7 ++ .../Terminal/TerminalController.swift | 99 +++++++++++++------ macos/Sources/Ghostty/Ghostty.App.swift | 24 +++++ macos/Sources/Ghostty/Package.swift | 3 + src/Surface.zig | 1 + src/input/Binding.zig | 9 +- 7 files changed, 112 insertions(+), 33 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 2fe835303..a102beb91 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -30,6 +30,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuSplitRight: NSMenuItem? @IBOutlet private var menuSplitDown: NSMenuItem? @IBOutlet private var menuClose: NSMenuItem? + @IBOutlet private var menuCloseTab: NSMenuItem? @IBOutlet private var menuCloseWindow: NSMenuItem? @IBOutlet private var menuCloseAllWindows: NSMenuItem? @@ -347,6 +348,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) + syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab) syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 0a197fe65..4a01d5c62 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -17,6 +17,7 @@ + @@ -155,6 +156,12 @@ + + + + + + diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 2da498e3a..ef4054c2e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -60,6 +60,11 @@ class TerminalController: BaseTerminalController { selector: #selector(onGotoTab), name: Ghostty.Notification.ghosttyGotoTab, object: nil) + center.addObserver( + self, + selector: #selector(onCloseTab), + name: .ghosttyCloseTab, + object: nil) center.addObserver( self, selector: #selector(ghosttyConfigDidChange(_:)), @@ -508,7 +513,50 @@ class TerminalController: BaseTerminalController { ghostty.newTab(surface: surface) } - @IBAction override func closeWindow(_ sender: Any) { + private func confirmClose( + window: NSWindow, + messageText: String, + informativeText: String, + completion: @escaping () -> Void + ) { + // If we need confirmation by any, show one confirmation for all windows + // in the tab group. + let alert = NSAlert() + alert.messageText = messageText + alert.informativeText = informativeText + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: window) { response in + if response == .alertFirstButtonReturn { + completion() + } + } + } + + @IBAction func closeTab(_ sender: Any?) { + guard let window = window else { return } + guard window.tabGroup != nil else { + // No tabs, no tab group, just perform a normal close. + window.performClose(sender) + return + } + + if surfaceTree?.needsConfirmQuit() ?? false { + confirmClose( + window: window, + messageText: "Close Tab?", + informativeText: "The terminal still has a running process. If you close the tab the process will be killed." + ) { + window.close() + } + return + } + + window.close() + } + + @IBAction override func closeWindow(_ sender: Any?) { guard let window = window else { return } guard let tabGroup = window.tabGroup else { // No tabs, no tab group, just perform a normal close. @@ -523,47 +571,34 @@ class TerminalController: BaseTerminalController { } // Check if any windows require close confirmation. - var needsConfirm: Bool = false - for tabWindow in tabGroup.windows { - guard let c = tabWindow.windowController as? TerminalController else { continue } - if (c.surfaceTree?.needsConfirmQuit() ?? false) { - needsConfirm = true - break + let needsConfirm = tabGroup.windows.contains { tabWindow in + guard let controller = tabWindow.windowController as? TerminalController else { + return false } + return controller.surfaceTree?.needsConfirmQuit() ?? false } // If none need confirmation then we can just close all the windows. - if (!needsConfirm) { - for tabWindow in tabGroup.windows { - tabWindow.close() - } - + if !needsConfirm { + tabGroup.windows.forEach { $0.close() } return } - // If we need confirmation by any, show one confirmation for all windows - // in the tab group. - let alert = NSAlert() - alert.messageText = "Close Window?" - alert.informativeText = "All terminal sessions in this window will be terminated." - alert.addButton(withTitle: "Close Window") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - if (response == .alertFirstButtonReturn) { - for tabWindow in tabGroup.windows { - tabWindow.close() - } - } - }) + confirmClose( + window: window, + messageText: "Close Window?", + informativeText: "All terminal sessions in this window will be terminated." + ) { + tabGroup.windows.forEach { $0.close() } + } } - @IBAction func toggleGhosttyFullScreen(_ sender: Any) { + @IBAction func toggleGhosttyFullScreen(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleFullscreen(surface: surface) } - @IBAction func toggleTerminalInspector(_ sender: Any) { + @IBAction func toggleTerminalInspector(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleTerminalInspector(surface: surface) } @@ -720,6 +755,12 @@ class TerminalController: BaseTerminalController { targetWindow.makeKeyAndOrderFront(nil) } + @objc private func onCloseTab(notification: SwiftUI.Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree?.contains(view: target) ?? false else { return } + closeTab(self) + } + @objc private func onToggleFullscreen(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard target == self.focusedSurface else { return } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 3a2510e3b..43c0f245a 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -448,6 +448,9 @@ extension Ghostty { case GHOSTTY_ACTION_NEW_SPLIT: newSplit(app, target: target, direction: action.action.new_split) + case GHOSTTY_ACTION_CLOSE_TAB: + closeTab(app, target: target) + case GHOSTTY_ACTION_TOGGLE_FULLSCREEN: toggleFullscreen(app, target: target, mode: action.action.toggle_fullscreen) @@ -651,6 +654,27 @@ extension Ghostty { } } + private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("close tab does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + NotificationCenter.default.post( + name: .ghosttyCloseTab, + object: surfaceView + ) + + + default: + assertionFailure() + } + } + private static func toggleFullscreen( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index deca8f89d..71fac4a99 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -236,6 +236,9 @@ extension Notification.Name { /// Goto tab. Has tab index in the userinfo. static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab") static let GhosttyMoveTabKey = ghosttyMoveTab.rawValue + + /// Close tab + static let ghosttyCloseTab = Notification.Name("com.mitchellh.ghostty.closeTab") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/src/Surface.zig b/src/Surface.zig index 50ef3c0cf..91f914f38 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4266,6 +4266,7 @@ fn closingAction(action: input.Binding.Action) bool { return switch (action) { .close_surface, .close_window, + .close_tab, => true, else => false, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d0a34efe4..8cd3797ec 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -375,6 +375,10 @@ pub const Action = union(enum) { /// configured. close_surface: void, + /// Close the current tab, regardless of how many splits there may be. + /// This will trigger close confirmation as configured. + close_tab: void, + /// Close the window, regardless of how many tabs or splits there may be. /// This will trigger close confirmation as configured. close_window: void, @@ -383,9 +387,6 @@ pub const Action = union(enum) { /// This only works for macOS currently. close_all_windows: void, - /// Closes the tab belonging to the currently focused split. - close_tab: void, - /// Toggle fullscreen mode of window. toggle_fullscreen: void, @@ -729,6 +730,7 @@ pub const Action = union(enum) { .write_screen_file, .write_selection_file, .close_surface, + .close_tab, .close_window, .toggle_fullscreen, .toggle_window_decorations, @@ -753,7 +755,6 @@ pub const Action = union(enum) { .resize_split, .equalize_splits, .inspector, - .close_tab, => .surface, }; } From 3e24e96af51fe308705a1f1695e3b9045c54482e Mon Sep 17 00:00:00 2001 From: Danny Lin Date: Tue, 31 Dec 2024 02:01:46 -0800 Subject: [PATCH 096/238] termio/exec: fix SIGPIPE crash when reader exits early If the read thread has already exited, it will have closed the read end of the quit pipe. Unless SIGPIPE is masked with signal(SIGPIPE, SIG_IGN), or the macOS-specific fcntl(F_SETNOSIGPIPE), writing to the write end of a broken pipe kills the writer with SIGPIPE instead of returning -EPIPE as an error. This causes a crash if the read thread exits before threadExit. This was already a possible race condition if read() returns error.NotOpenForReading or error.InputOutput, but it's now much easier to trigger due to the recent "termio/exec: fix 100% CPU usage after wait-after-command process exits" fix. Fix this by closing the quit pipe instead of writing to it. --- src/termio/Exec.zig | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 1a3b8cad0..d409ccbb0 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -179,8 +179,11 @@ pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void { // Quit our read thread after exiting the subprocess so that // we don't get stuck waiting for data to stop flowing if it is // a particularly noisy process. - _ = posix.write(exec.read_thread_pipe, "x") catch |err| - log.warn("error writing to read thread quit pipe err={}", .{err}); + if (exec.read_thread_pipe) |pipe| { + posix.close(pipe); + // Tell deinit that we've already closed the pipe + exec.read_thread_pipe = null; + } if (comptime builtin.os.tag == .windows) { // Interrupt the blocking read so the thread can see the quit message @@ -639,7 +642,7 @@ pub const ThreadData = struct { /// Reader thread state read_thread: std.Thread, - read_thread_pipe: posix.fd_t, + read_thread_pipe: ?posix.fd_t, read_thread_fd: posix.fd_t, /// The timer to detect termios state changes. @@ -652,7 +655,8 @@ pub const ThreadData = struct { termios_mode: ptypkg.Mode = .{}, pub fn deinit(self: *ThreadData, alloc: Allocator) void { - posix.close(self.read_thread_pipe); + // If the pipe isn't closed, close it. + if (self.read_thread_pipe) |pipe| posix.close(pipe); // Clear our write pools. We know we aren't ever going to do // any more IO since we stop our data stream below so we can just @@ -1433,9 +1437,12 @@ pub const ReadThread = struct { }; // This happens on macOS instead of WouldBlock when the - // child process dies. To be safe, we just break the loop - // and let our poll happen. - if (n == 0) break; + // child process dies. It's equivalent to NotOpenForReading + // so we can just exit. + if (n == 0) { + log.info("io reader exiting", .{}); + return; + } // log.info("DATA: {d}", .{n}); @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] }); @@ -1447,8 +1454,8 @@ pub const ReadThread = struct { return; }; - // If our quit fd is set, we're done. - if (pollfds[1].revents & posix.POLL.IN != 0) { + // If our quit fd is closed, we're done. + if (pollfds[1].revents & posix.POLL.HUP != 0) { log.info("read thread got quit signal", .{}); return; } From 5213edfa6c6c01b8cac5f86307b174b81e5effb0 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Mon, 6 Jan 2025 17:54:00 +0800 Subject: [PATCH 097/238] Add keybind action `copy_url_to_clipboard` --- src/Surface.zig | 27 +++++++++++++++++++++++++++ src/input/Binding.zig | 5 +++++ 2 files changed, 32 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 91f914f38..ce00d8237 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3936,6 +3936,33 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool return false; }, + .copy_url_to_clipboard => { + // If the mouse isn't over a link, nothing we can do. + if (!self.mouse.over_link) return false; + + const pos = try self.rt_surface.getCursorPos(); + if (try self.linkAtPos(pos)) |link_info| { + // Get the URL text from selection + const url_text = (self.io.terminal.screen.selectionString(self.alloc, .{ + .sel = link_info[1], + .trim = self.config.clipboard_trim_trailing_spaces, + })) catch |err| { + log.err("error reading url string err={}", .{err}); + return false; + }; + defer self.alloc.free(url_text); + + self.rt_surface.setClipboardString(url_text, .standard, false) catch |err| { + log.err("error copying url to clipboard err={}", .{err}); + return true; + }; + + return true; + } + + return false; + }, + .paste_from_clipboard => try self.startClipboardRequest( .standard, .{ .paste = {} }, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 8cd3797ec..c5faaad06 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -259,6 +259,10 @@ pub const Action = union(enum) { paste_from_clipboard: void, paste_from_selection: void, + /// Copy the URL under the cursor to the clipboard. If there is no + /// URL under the cursor, this does nothing. + copy_url_to_clipboard: void, + /// Increase/decrease the font size by a certain amount. increase_font_size: f32, decrease_font_size: f32, @@ -711,6 +715,7 @@ pub const Action = union(enum) { .cursor_key, .reset, .copy_to_clipboard, + .copy_url_to_clipboard, .paste_from_clipboard, .paste_from_selection, .increase_font_size, From eb40cce45e6593a4065a32681d27c75c4ca3a9c9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jan 2025 13:49:45 -0800 Subject: [PATCH 098/238] build: requireZig cleanup --- build.zig | 16 +--------------- src/build/main.zig | 3 +++ src/build/zig.zig | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 src/build/zig.zig diff --git a/build.zig b/build.zig index 1364745ce..38d2bca6d 100644 --- a/build.zig +++ b/build.zig @@ -3,21 +3,7 @@ const builtin = @import("builtin"); const buildpkg = @import("src/build/main.zig"); comptime { - // This is the required Zig version for building this project. We allow - // any patch version but the major and minor must match exactly. - const required_zig = "0.13.0"; - - // Fail compilation if the current Zig version doesn't meet requirements. - const current_vsn = builtin.zig_version; - const required_vsn = std.SemanticVersion.parse(required_zig) catch unreachable; - if (current_vsn.major != required_vsn.major or - current_vsn.minor != required_vsn.minor) - { - @compileError(std.fmt.comptimePrint( - "Your Zig version v{} does not meet the required build version of v{}", - .{ current_vsn, required_vsn }, - )); - } + buildpkg.requireZig("0.13.0"); } pub fn build(b: *std.Build) !void { diff --git a/src/build/main.zig b/src/build/main.zig index 8228abfbf..291791917 100644 --- a/src/build/main.zig +++ b/src/build/main.zig @@ -28,3 +28,6 @@ pub const XCFrameworkStep = @import("XCFrameworkStep.zig"); pub const fish_completions = @import("fish_completions.zig").completions; pub const zsh_completions = @import("zsh_completions.zig").completions; pub const bash_completions = @import("bash_completions.zig").completions; + +// Helpers +pub const requireZig = @import("zig.zig").requireZig; diff --git a/src/build/zig.zig b/src/build/zig.zig new file mode 100644 index 000000000..7e327127d --- /dev/null +++ b/src/build/zig.zig @@ -0,0 +1,17 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Require a specific version of Zig to build this project. +pub fn requireZig(comptime required_zig: []const u8) void { + // Fail compilation if the current Zig version doesn't meet requirements. + const current_vsn = builtin.zig_version; + const required_vsn = std.SemanticVersion.parse(required_zig) catch unreachable; + if (current_vsn.major != required_vsn.major or + current_vsn.minor != required_vsn.minor) + { + @compileError(std.fmt.comptimePrint( + "Your Zig version v{} does not meet the required build version of v{}", + .{ current_vsn, required_vsn }, + )); + } +} From c33629aae5d5b4591ebf570538fa9db2b306fe7e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 8 Jan 2025 16:01:31 -0600 Subject: [PATCH 099/238] gtk: clean up context menu creation & refresh --- src/apprt/gtk/App.zig | 29 ++++++++++------------------- src/apprt/gtk/Surface.zig | 2 +- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 86a001cba..ff5a21886 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1777,12 +1777,6 @@ fn initMenu(self: *App) void { c.g_menu_append(section, "About Ghostty", "win.about"); } - // { - // const section = c.g_menu_new(); - // defer c.g_object_unref(section); - // c.g_menu_append_submenu(menu, "File", @ptrCast(@alignCast(section))); - // } - self.menu = menu; } @@ -1790,7 +1784,13 @@ fn initContextMenu(self: *App) void { const menu = c.g_menu_new(); errdefer c.g_object_unref(menu); - createContextMenuCopyPasteSection(menu, false); + { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + c.g_menu_append(section, "Copy", "win.copy"); + c.g_menu_append(section, "Paste", "win.paste"); + } { const section = c.g_menu_new(); @@ -1811,18 +1811,9 @@ fn initContextMenu(self: *App) void { self.context_menu = menu; } -fn createContextMenuCopyPasteSection(menu: ?*c.GMenu, has_selection: bool) void { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - c.g_menu_prepend_section(menu, null, @ptrCast(@alignCast(section))); - // FIXME: Feels really hackish, but disabling sensitivity on this doesn't seems to work(?) - c.g_menu_append(section, "Copy", if (has_selection) "win.copy" else "noop"); - c.g_menu_append(section, "Paste", "win.paste"); -} - -pub fn refreshContextMenu(self: *App, has_selection: bool) void { - c.g_menu_remove(self.context_menu, 0); - createContextMenuCopyPasteSection(self.context_menu, has_selection); +pub fn refreshContextMenu(_: *App, window: ?*c.GtkWindow, has_selection: bool) void { + const action: ?*c.GSimpleAction = @ptrCast(c.g_action_map_lookup_action(@ptrCast(window), "copy")); + c.g_simple_action_set_enabled(action, if (has_selection) 1 else 0); } fn isValidAppId(app_id: [:0]const u8) bool { diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 60b119aaa..ecdddf5c5 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1258,7 +1258,7 @@ fn showContextMenu(self: *Surface, x: f32, y: f32) void { }; c.gtk_popover_set_pointing_to(@ptrCast(@alignCast(window.context_menu)), &rect); - self.app.refreshContextMenu(self.core_surface.hasSelection()); + self.app.refreshContextMenu(window.window, self.core_surface.hasSelection()); c.gtk_popover_popup(@ptrCast(@alignCast(window.context_menu))); } From 06515863392b88a02b912b2aca27f03a01c10752 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:19:55 +0800 Subject: [PATCH 100/238] Reduce ghost emoji flash in title bar --- .../Sources/Features/Terminal/TerminalView.swift | 15 +++++---------- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 15b504875..d72200ef8 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -10,7 +10,7 @@ protocol TerminalViewDelegate: AnyObject { /// The title of the terminal should change. func titleDidChange(to: String) - + /// The URL of the pwd should change. func pwdDidChange(to: URL?) @@ -56,15 +56,10 @@ struct TerminalView: View { // The title for our window private var title: String { - var title = "👻" - - if let surfaceTitle = surfaceTitle { - if (surfaceTitle.count > 0) { - title = surfaceTitle - } + if let surfaceTitle = surfaceTitle, !surfaceTitle.isEmpty { + return surfaceTitle } - - return title + return "👻" } // The pwd of the focused surface as a URL @@ -72,7 +67,7 @@ struct TerminalView: View { guard let surfacePwd, surfacePwd != "" else { return nil } return URL(fileURLWithPath: surfacePwd) } - + var body: some View { switch ghostty.readiness { case .loading: diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c933eb9bf..8b0fe4352 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -12,7 +12,7 @@ extension Ghostty { // 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 // to the app level and it is set from there. - @Published private(set) var title: String = "👻" + @Published private(set) var title: String = "" // The current pwd of the surface as defined by the pty. This can be // changed with escape codes. From ea7c54d79daa5a4a067c9588202aca0b7954c84b Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:01:01 +0800 Subject: [PATCH 101/238] Simplify let binding in `TerminalView` title logic --- macos/Sources/Features/Terminal/TerminalView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index d72200ef8..3d4165e91 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -56,7 +56,7 @@ struct TerminalView: View { // The title for our window private var title: String { - if let surfaceTitle = surfaceTitle, !surfaceTitle.isEmpty { + if let surfaceTitle, !surfaceTitle.isEmpty { return surfaceTitle } return "👻" From 5bfb3925baf895f78c5b3233dbe9251bd1839700 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Thu, 9 Jan 2025 06:24:31 +0800 Subject: [PATCH 102/238] Add fallback timer for empty window title --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8b0fe4352..5c4d819e1 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -12,7 +12,14 @@ extension Ghostty { // 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 // to the app level and it is set from there. - @Published private(set) var title: String = "" + @Published private(set) var title: String = "" { + didSet { + if !title.isEmpty { + titleFallbackTimer?.invalidate() + titleFallbackTimer = nil + } + } + } // The current pwd of the surface as defined by the pty. This can be // changed with escape codes. @@ -113,6 +120,9 @@ extension Ghostty { // A small delay that is introduced before a title change to avoid flickers private var titleChangeTimer: Timer? + // A timer to fallback to ghost emoji if no title is set within the grace period + private var titleFallbackTimer: Timer? + /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil @@ -139,6 +149,13 @@ extension Ghostty { // can do SOMETHING. super.init(frame: NSMakeRect(0, 0, 800, 600)) + // Set a timer to show the ghost emoji after 500ms if no title is set + titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + if let self = self, self.title.isEmpty { + self.title = "👻" + } + } + // Before we initialize the surface we want to register our notifications // so there is no window where we can't receive them. let center = NotificationCenter.default From dac13701e33bfb213de07aa9636793ab6a8e63aa Mon Sep 17 00:00:00 2001 From: George Joseph Date: Wed, 8 Jan 2025 15:50:18 -0700 Subject: [PATCH 103/238] Implement a size-limit function for GTK A "size-limit" function has been implemented for GTK which calls gtk_widget_set_size_request() to set the minimum widget/window size. Without this function, it's left to GTK to set the minimum size which is usually a lot larger than the documented 10x4 cell minimum size. This doesn't fix the issue completely as GTK retains the final say in how small a window can be but it gets closer. Resolves: #4836 --- src/apprt/gtk/App.zig | 19 ++++++++++++++++++- src/apprt/gtk/Surface.zig | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 86a001cba..bc8c9a8d9 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -493,6 +493,7 @@ pub fn performAction( .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), @@ -505,7 +506,6 @@ pub fn performAction( .close_all_windows, .toggle_quick_terminal, .toggle_visibility, - .size_limit, .cell_size, .secure_input, .key_sequence, @@ -823,6 +823,23 @@ fn setInitialSize( } } +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, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 60b119aaa..4f54ec688 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -858,6 +858,28 @@ pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void ); } +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; + + // Note: this doesn't properly take into account the window decorations. + // I'm not currently sure how to do that. + c.gtk_widget_set_size_request( + @ptrCast(window.window), + @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 From bec690532d1e497f6bc68395f33019121fd5a7eb Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Thu, 9 Jan 2025 10:24:00 +0800 Subject: [PATCH 104/238] ci: update zig version regex in windows build --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 81d58a1ef..1bb2022fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -247,7 +247,7 @@ jobs: run: | # Get the zig version from build.zig so that it only needs to be updated $fileContent = Get-Content -Path "build.zig" -Raw - $pattern = 'const required_zig = "(.*?)";' + $pattern = 'buildpkg\.requireZig\("(.*?)"\)' $zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value Write-Output $version $version = "zig-windows-x86_64-$zigVersion" From 37256ec6a27a232dba1b839fb381310e0260ddf0 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Thu, 9 Jan 2025 10:31:41 +0800 Subject: [PATCH 105/238] ci: move version output after variable definition --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1bb2022fc..8b8e79959 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -247,10 +247,10 @@ jobs: run: | # Get the zig version from build.zig so that it only needs to be updated $fileContent = Get-Content -Path "build.zig" -Raw - $pattern = 'buildpkg\.requireZig\("(.*?)"\)' + $pattern = 'buildpkg\.requireZig\("(.*?)"\);' $zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value - Write-Output $version $version = "zig-windows-x86_64-$zigVersion" + Write-Output $version $uri = "https://ziglang.org/download/$zigVersion/$version.zip" Invoke-WebRequest -Uri "$uri" -OutFile ".\zig-windows.zip" Expand-Archive -Path ".\zig-windows.zip" -DestinationPath ".\" -Force From 622cc3f9c7c0181e1f57c09b88f5ca53f1499b98 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jan 2025 19:51:16 -0800 Subject: [PATCH 106/238] build: update zig-wayland to use new LazyPath API This is a more idiomatic way to handle build paths in Zig 0.13 and later. --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- src/build/SharedDeps.zig | 12 +++++------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 518022486..18a608bb4 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -34,8 +34,8 @@ .hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25", }, .zig_wayland = .{ - .url = "https://codeberg.org/ifreund/zig-wayland/archive/a5e2e9b6a6d7fba638ace4d4b24a3b576a02685b.tar.gz", - .hash = "1220d41b23ae70e93355bb29dac1c07aa6aeb92427a2dffc4375e94b4de18111248c", + .url = "https://codeberg.org/ifreund/zig-wayland/archive/0823d9116b80d65ecfad48a2efbca166c7b03497.tar.gz", + .hash = "12205e05d4db71ef30aeb3517727382c12d294968e541090a762689acbb9038826a1", }, .zf = .{ .url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd", diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 3806c64c9..48270c6e8 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-PnfSy793kcVt85q47kWR0xkivXoMOZAAmuUyKO9vqAI=" +"sha256-MeSJiiSDDWZ7vUgY56t9aPSLPTgIKb4jexoHmDhJOGM=" diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 013aa5593..16e7381fa 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -435,15 +435,13 @@ pub fn add( if (self.config.x11) step.linkSystemLibrary2("X11", dynamic_link_opts); if (self.config.wayland) { - const scanner = Scanner.create(b, .{ + const scanner = Scanner.create(b.dependency("zig_wayland", .{}), .{ // We shouldn't be using getPath but we need to for now // https://codeberg.org/ifreund/zig-wayland/issues/66 - .wayland_xml_path = b.dependency("wayland", .{}) - .path("protocol/wayland.xml") - .getPath(b), - .wayland_protocols_path = b.dependency("wayland_protocols", .{}) - .path("") - .getPath(b), + .wayland_xml = b.dependency("wayland", .{}) + .path("protocol/wayland.xml"), + .wayland_protocols = b.dependency("wayland_protocols", .{}) + .path(""), }); const wayland = b.createModule(.{ .root_source_file = scanner.result }); From aafe7deae76ef90136141cdbf7a0f4f627188ae6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jan 2025 21:19:58 -0800 Subject: [PATCH 107/238] macos: improve initial size calculation Fixes #4801 Our size calculation before improperly used a screens frame instead of its visibleFrame. Additionally, we didn't properly account for origin needing to move in order to fit the window on the screen. Apparently, setting a frame height to high crashes AppKit. The width gets clamped by AppKit but the height does not. Fun! --- .../Terminal/TerminalController.swift | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index ef4054c2e..08306a854 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -384,15 +384,26 @@ class TerminalController: BaseTerminalController { if case let .leaf(leaf) = surfaceTree { if let initialSize = leaf.surface.initialSize, let screen = window.screen ?? NSScreen.main { - // Setup our frame. We need to first subtract the views frame so that we can - // just get the chrome frame so that we only affect the surface view size. + // Get the current frame of the window var frame = window.frame - frame.size.width -= leaf.surface.frame.size.width - frame.size.height -= leaf.surface.frame.size.height - frame.size.width += min(initialSize.width, screen.frame.width) - frame.size.height += min(initialSize.height, screen.frame.height) - // We have no tabs and we are not a split, so set the initial size of the window. + // Calculate the chrome size (window size minus view size) + let chromeWidth = frame.size.width - leaf.surface.frame.size.width + let chromeHeight = frame.size.height - leaf.surface.frame.size.height + + // Calculate the new width and height, clamping to the screen's size + let newWidth = min(initialSize.width + chromeWidth, screen.visibleFrame.width) + let newHeight = min(initialSize.height + chromeHeight, screen.visibleFrame.height) + + // Update the frame size while keeping the window's position intact + frame.size.width = newWidth + frame.size.height = newHeight + + // Ensure the window doesn't go outside the screen boundaries + frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth)) + frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight)) + + // Set the updated frame to the window window.setFrame(frame, display: true) } } From 1636ac88fcd951696a726f39938d593ad3f982fa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jan 2025 21:52:19 -0800 Subject: [PATCH 108/238] macos: Handle ctrl characters in IME input Fixes: https://github.com/ghostty-org/ghostty/issues/4634#issuecomment-2573469532 This commit fixes two issues: 1. `libghostty` must not override ctrl+key inputs if we are in a preedit state. This allows thigs like `ctrl+h` to work properly in an IME. 2. On macOS, when an IME commits text, we strip the control modifier from the key event we send to libghostty. This is a bit of a hack but this avoids triggering special ctrl+key handling. --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 24 +++++++++++++++++-- src/apprt/embedded.zig | 5 ++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c933eb9bf..a9ad39136 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -828,8 +828,28 @@ extension Ghostty { var handled: Bool = false if let list = keyTextAccumulator, list.count > 0 { handled = true - for text in list { - _ = keyAction(action, event: event, text: text) + + // This is a hack. libghostty on macOS treats ctrl input as not having + // text because some keyboard layouts generate bogus characters for + // ctrl+key. libghostty can't tell this is from an IM keyboard giving + // us direct values. So, we just remove control. + var modifierFlags = event.modifierFlags + modifierFlags.remove(.control) + if let keyTextEvent = NSEvent.keyEvent( + with: .keyDown, + location: event.locationInWindow, + modifierFlags: modifierFlags, + timestamp: event.timestamp, + windowNumber: event.windowNumber, + context: nil, + characters: event.characters ?? "", + charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", + isARepeat: event.isARepeat, + keyCode: event.keyCode + ) { + for text in list { + _ = keyAction(action, event: keyTextEvent, text: text) + } } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 50d1e90e4..44c4c5f20 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -199,6 +199,11 @@ pub const App = struct { // This logic only applies to macOS. if (comptime builtin.os.tag != .macos) break :event_text event.text; + // If we're in a preedit state then we allow it through. This + // allows ctrl sequences that affect IME to work. For example, + // Ctrl+H deletes a character with Japanese input. + if (event.composing) break :event_text event.text; + // If the modifiers are ONLY "control" then we never process // the event text because we want to do our own translation so // we can handle ctrl+c, ctrl+z, etc. From ae81edfcbfc3a33a8b3d5b147c5557e1325395d4 Mon Sep 17 00:00:00 2001 From: Ismael Arias Date: Thu, 9 Jan 2025 12:50:49 +0100 Subject: [PATCH 109/238] feat(gtk): show menu in context menu if titlebar is disabled --- src/apprt/gtk/App.zig | 41 ++++++++++++++++++++++++++++------------ src/apprt/gtk/Window.zig | 2 +- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index eb5fa7292..e8c85907d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -831,12 +831,12 @@ fn setSizeLimit( 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), + .width = value.min_width, + .height = value.min_height, + }, if (value.max_width > 0) .{ + .width = value.max_width, + .height = value.max_height, + } else null), } } @@ -1766,12 +1766,10 @@ fn initActions(self: *App) void { } } -/// This sets the self.menu property to the application menu that can be -/// shared by all application windows. -fn initMenu(self: *App) void { - const menu = c.g_menu_new(); - errdefer c.g_object_unref(menu); - +/// Initializes and populates the provided GMenu with sections and actions. +/// This function is used to set up the application's menu structure, either for +/// the main menu button or as a context menu when window decorations are disabled. +fn initMenuContent(menu: *c.GMenu) void { { const section = c.g_menu_new(); defer c.g_object_unref(section); @@ -1793,7 +1791,14 @@ fn initMenu(self: *App) void { c.g_menu_append(section, "Reload Configuration", "app.reload-config"); c.g_menu_append(section, "About Ghostty", "win.about"); } +} +/// This sets the self.menu property to the application menu that can be +/// shared by all application windows. +fn initMenu(self: *App) void { + const menu = c.g_menu_new(); + errdefer c.g_object_unref(menu); + initMenuContent(@ptrCast(menu)); self.menu = menu; } @@ -1825,6 +1830,18 @@ fn initContextMenu(self: *App) void { c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); } + if (!self.config.@"window-decoration") { + const section = c.g_menu_new(); + defer c.g_object_unref(section); + const submenu = c.g_menu_new(); + defer c.g_object_unref(submenu); + initMenuContent(@ptrCast(submenu)); + + // Just append the submenu to the menu structure + c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu))); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); + } + self.context_menu = menu; } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 9058ca9da..0f44cee7b 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -270,7 +270,7 @@ pub fn init(self: *Window, app: *App) !void { } self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); - c.gtk_widget_set_parent(self.context_menu, window); + c.gtk_widget_set_parent(self.context_menu, box); c.gtk_popover_set_has_arrow(@ptrCast(@alignCast(self.context_menu)), 0); c.gtk_widget_set_halign(self.context_menu, c.GTK_ALIGN_START); From b25c59330923557d592dc835cf074287006992ea Mon Sep 17 00:00:00 2001 From: Ismael Arias Date: Thu, 9 Jan 2025 12:52:35 +0100 Subject: [PATCH 110/238] feat(GTK): remove comment --- src/apprt/gtk/App.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index e8c85907d..fa5eb7b9f 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1835,9 +1835,8 @@ fn initContextMenu(self: *App) void { defer c.g_object_unref(section); const submenu = c.g_menu_new(); defer c.g_object_unref(submenu); - initMenuContent(@ptrCast(submenu)); - // Just append the submenu to the menu structure + initMenuContent(@ptrCast(submenu)); c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu))); c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); } From 6ef757a8f85db7a124d370378850339a899c9e65 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jan 2025 12:43:41 -0800 Subject: [PATCH 111/238] Revert "termio/exec: fix SIGPIPE crash when reader exits early" This reverts commit 3e24e96af51fe308705a1f1695e3b9045c54482e. --- src/termio/Exec.zig | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index d409ccbb0..1a3b8cad0 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -179,11 +179,8 @@ pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void { // Quit our read thread after exiting the subprocess so that // we don't get stuck waiting for data to stop flowing if it is // a particularly noisy process. - if (exec.read_thread_pipe) |pipe| { - posix.close(pipe); - // Tell deinit that we've already closed the pipe - exec.read_thread_pipe = null; - } + _ = posix.write(exec.read_thread_pipe, "x") catch |err| + log.warn("error writing to read thread quit pipe err={}", .{err}); if (comptime builtin.os.tag == .windows) { // Interrupt the blocking read so the thread can see the quit message @@ -642,7 +639,7 @@ pub const ThreadData = struct { /// Reader thread state read_thread: std.Thread, - read_thread_pipe: ?posix.fd_t, + read_thread_pipe: posix.fd_t, read_thread_fd: posix.fd_t, /// The timer to detect termios state changes. @@ -655,8 +652,7 @@ pub const ThreadData = struct { termios_mode: ptypkg.Mode = .{}, pub fn deinit(self: *ThreadData, alloc: Allocator) void { - // If the pipe isn't closed, close it. - if (self.read_thread_pipe) |pipe| posix.close(pipe); + posix.close(self.read_thread_pipe); // Clear our write pools. We know we aren't ever going to do // any more IO since we stop our data stream below so we can just @@ -1437,12 +1433,9 @@ pub const ReadThread = struct { }; // This happens on macOS instead of WouldBlock when the - // child process dies. It's equivalent to NotOpenForReading - // so we can just exit. - if (n == 0) { - log.info("io reader exiting", .{}); - return; - } + // child process dies. To be safe, we just break the loop + // and let our poll happen. + if (n == 0) break; // log.info("DATA: {d}", .{n}); @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] }); @@ -1454,8 +1447,8 @@ pub const ReadThread = struct { return; }; - // If our quit fd is closed, we're done. - if (pollfds[1].revents & posix.POLL.HUP != 0) { + // If our quit fd is set, we're done. + if (pollfds[1].revents & posix.POLL.IN != 0) { log.info("read thread got quit signal", .{}); return; } From 03fee2ac33bfcad282e1dafee6808bb2d1395c7e Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 2 Jan 2025 23:53:22 +0800 Subject: [PATCH 112/238] gtk: unify Wayland and X11 platforms --- src/apprt/gtk/App.zig | 53 +------- src/apprt/gtk/Surface.zig | 11 +- src/apprt/gtk/Window.zig | 22 ++-- src/apprt/gtk/key.zig | 26 +--- src/apprt/gtk/protocol.zig | 149 +++++++++++++++++++++++ src/apprt/gtk/{ => protocol}/wayland.zig | 86 ++++++------- src/apprt/gtk/{ => protocol}/x11.zig | 128 ++++++++++++------- src/config/Config.zig | 8 ++ 8 files changed, 304 insertions(+), 179 deletions(-) create mode 100644 src/apprt/gtk/protocol.zig rename src/apprt/gtk/{ => protocol}/wayland.zig (58%) rename src/apprt/gtk/{ => protocol}/x11.zig (50%) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index fa5eb7b9f..ecbe61bce 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -36,8 +36,7 @@ const c = @import("c.zig").c; const version = @import("version.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); -const x11 = @import("x11.zig"); -const wayland = @import("wayland.zig"); +const protocol = @import("protocol.zig"); const testing = std.testing; const log = std.log.scoped(.gtk); @@ -71,11 +70,7 @@ clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, /// This is set to false when the main loop should exit. running: bool = true, -/// Xkb state (X11 only). Will be null on Wayland. -x11_xkb: ?x11.Xkb = null, - -/// Wayland app state. Will be null on X11. -wayland: ?wayland.AppState = null, +protocol: protocol.App, /// The base path of the transient cgroup used to put all surfaces /// into their own cgroup. This is only set if cgroups are enabled @@ -364,46 +359,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { return error.GtkApplicationRegisterFailed; } - // Perform all X11 initialization. This ultimately returns the X11 - // keyboard state but the block does more than that (i.e. setting up - // WM_CLASS). - const x11_xkb: ?x11.Xkb = x11_xkb: { - if (comptime !build_options.x11) break :x11_xkb null; - if (!x11.is_display(display)) break :x11_xkb null; - - // 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. - // - const prgname = if (config.@"x11-instance-name") |pn| - pn - else if (builtin.mode == .Debug) - "ghostty-debug" - else - "ghostty"; - c.g_set_prgname(prgname); - c.gdk_x11_display_set_program_class(display, app_id); - - // Set up Xkb - break :x11_xkb try x11.Xkb.init(display); - }; - - // Initialize Wayland state - var wl = wayland.AppState.init(display); - if (wl) |*w| try w.register(); + const app_protocol = try protocol.App.init(display, &config, app_id); // 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 @@ -429,8 +385,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { .config = config, .ctx = ctx, .cursor_none = cursor_none, - .x11_xkb = x11_xkb, - .wayland = wl, + .protocol = app_protocol, .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 diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 35932ac5e..97b7bb0f4 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -25,7 +25,6 @@ const ResizeOverlay = @import("ResizeOverlay.zig"); const inspector = @import("inspector.zig"); const gtk_key = @import("key.zig"); const c = @import("c.zig").c; -const x11 = @import("x11.zig"); const log = std.log.scoped(.gtk_surface); @@ -825,9 +824,6 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value); const gtk_xft_dpi = c.g_value_get_int(&value); - // 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; break :xft_scale xft_dpi / 96; }; @@ -1384,6 +1380,10 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque) return; }; + if (self.container.window()) |window| window.protocol.onResize() catch |err| { + log.warn("failed to notify X11/Wayland integration of resize={}", .{err}); + }; + self.resize_overlay.maybeShow(); } } @@ -1699,11 +1699,10 @@ pub fn keyEvent( // Get our modifier for the event const mods: input.Mods = gtk_key.eventMods( - @ptrCast(self.gl_area), event, physical_key, gtk_mods, - if (self.app.x11_xkb) |*xkb| xkb else null, + &self.app.protocol, ); // Get our consumed modifiers diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 0f44cee7b..fbba22195 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -25,7 +25,7 @@ const gtk_key = @import("key.zig"); const Notebook = @import("notebook.zig").Notebook; const HeaderBar = @import("headerbar.zig").HeaderBar; const version = @import("version.zig"); -const wayland = @import("wayland.zig"); +const protocol = @import("protocol.zig"); const log = std.log.scoped(.gtk); @@ -56,7 +56,7 @@ toast_overlay: ?*c.GtkWidget, /// See adwTabOverviewOpen for why we have this. adw_tab_overview_focus_timer: ?c.guint = null, -wayland: ?wayland.SurfaceState, +protocol: protocol.Surface, pub fn create(alloc: Allocator, app: *App) !*Window { // Allocate a fixed pointer for our window. We try to minimize @@ -82,7 +82,7 @@ pub fn init(self: *Window, app: *App) !void { .notebook = undefined, .context_menu = undefined, .toast_overlay = undefined, - .wayland = null, + .protocol = undefined, }; // Create the window @@ -396,14 +396,8 @@ pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { c.gtk_widget_add_css_class(@ptrCast(self.window), "background"); } - if (self.wayland) |*wl| { - const blurred = switch (config.@"background-blur-radius") { - .false => false, - .true => true, - .radius => |v| v > 0, - }; - try wl.setBlur(blurred); - } + // Perform protocol-specific config updates + try self.protocol.onConfigUpdate(config); } /// Sets up the GTK actions for the window scope. Actions are how GTK handles @@ -443,7 +437,7 @@ fn initActions(self: *Window) void { pub fn deinit(self: *Window) void { c.gtk_widget_unparent(@ptrCast(self.context_menu)); - if (self.wayland) |*wl| wl.deinit(); + self.protocol.deinit(); if (self.adw_tab_overview_focus_timer) |timer| { _ = c.g_source_remove(timer); @@ -584,9 +578,7 @@ pub fn sendToast(self: *Window, title: [:0]const u8) void { fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { const self = userdataSelf(ud.?); - if (self.app.wayland) |*wl| { - self.wayland = wayland.SurfaceState.init(v, wl); - } + self.protocol.init(v, &self.app.protocol, &self.app.config); self.syncAppearance(&self.app.config) catch |err| { log.err("failed to initialize appearance={}", .{err}); diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 311bff0da..ef460e62c 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -2,7 +2,7 @@ const std = @import("std"); const build_options = @import("build_options"); const input = @import("../../input.zig"); const c = @import("c.zig").c; -const x11 = @import("x11.zig"); +const protocol = @import("protocol.zig"); /// Returns a GTK accelerator string from a trigger. pub fn accelFromTrigger(buf: []u8, trigger: input.Binding.Trigger) !?[:0]const u8 { @@ -105,34 +105,14 @@ pub fn keyvalUnicodeUnshifted( /// This requires a lot of context because the GdkEvent /// doesn't contain enough on its own. pub fn eventMods( - widget: *c.GtkWidget, event: *c.GdkEvent, physical_key: input.Key, gtk_mods: c.GdkModifierType, - x11_xkb: ?*x11.Xkb, + app_protocol: *protocol.App, ) input.Mods { const device = c.gdk_event_get_device(event); - var mods = mods: { - // Add any modifier state events from Xkb if we have them (X11 - // only). Null back from the Xkb call means there was no modifier - // event to read. This likely means that the key event did not - // result in a modifier change and we can safely rely on the GDK - // state. - if (comptime build_options.x11) { - const display = c.gtk_widget_get_display(widget); - if (x11_xkb) |xkb| { - if (xkb.modifier_state_from_notify(display)) |x11_mods| break :mods x11_mods; - break :mods translateMods(gtk_mods); - } - } - - // On Wayland, we have to use the GDK device because the mods sent - // to this event do not have the modifier key applied if it was - // pressed (i.e. left control). - break :mods translateMods(c.gdk_device_get_modifier_state(device)); - }; - + var mods = app_protocol.eventMods(device, gtk_mods); mods.num_lock = c.gdk_device_get_num_lock_state(device) == 1; switch (physical_key) { diff --git a/src/apprt/gtk/protocol.zig b/src/apprt/gtk/protocol.zig new file mode 100644 index 000000000..c7d7247cf --- /dev/null +++ b/src/apprt/gtk/protocol.zig @@ -0,0 +1,149 @@ +const std = @import("std"); +const x11 = @import("protocol/x11.zig"); +const wayland = @import("protocol/wayland.zig"); +const c = @import("c.zig").c; +const build_options = @import("build_options"); +const input = @import("../../input.zig"); +const apprt = @import("../../apprt.zig"); +const Config = @import("../../config.zig").Config; +const adwaita = @import("adwaita.zig"); +const builtin = @import("builtin"); +const key = @import("key.zig"); + +const log = std.log.scoped(.gtk_platform); + +pub const App = struct { + gdk_display: *c.GdkDisplay, + derived_config: DerivedConfig, + + inner: union(enum) { + none, + x11: if (build_options.x11) x11.App else void, + wayland: if (build_options.wayland) wayland.App else void, + }, + + const DerivedConfig = struct { + app_id: [:0]const u8, + x11_program_name: [:0]const u8, + + pub fn init(config: *const Config, app_id: [:0]const u8) DerivedConfig { + return .{ + .app_id = app_id, + .x11_program_name = if (config.@"x11-instance-name") |pn| + pn + else if (builtin.mode == .Debug) + "ghostty-debug" + else + "ghostty", + }; + } + }; + + pub fn init(display: ?*c.GdkDisplay, config: *const Config, app_id: [:0]const u8) !App { + var self: App = .{ + .inner = .none, + .derived_config = DerivedConfig.init(config, app_id), + .gdk_display = display orelse { + // TODO: When does this ever happen...? + std.debug.panic("GDK display is null!", .{}); + }, + }; + + // The X11/Wayland init functions set `self.inner` when successful, + // so we only need to keep trying if `self.inner` stays `.none` + if (self.inner == .none and comptime build_options.wayland) try wayland.App.init(&self); + if (self.inner == .none and comptime build_options.x11) try x11.App.init(&self); + + // Welp, no integration for you + if (self.inner == .none) { + log.warn( + "neither X11 nor Wayland integrations enabled - lots of features would be missing!", + .{}, + ); + } + + return self; + } + + pub fn eventMods(self: *App, device: ?*c.GdkDevice, gtk_mods: c.GdkModifierType) input.Mods { + return switch (self.inner) { + // Add any modifier state events from Xkb if we have them (X11 + // only). Null back from the Xkb call means there was no modifier + // event to read. This likely means that the key event did not + // result in a modifier change and we can safely rely on the GDK + // state. + .x11 => |*x| if (comptime build_options.x11) + x.modifierStateFromNotify() orelse key.translateMods(gtk_mods) + else + unreachable, + + // On Wayland, we have to use the GDK device because the mods sent + // to this event do not have the modifier key applied if it was + // pressed (i.e. left control). + .wayland, .none => key.translateMods(c.gdk_device_get_modifier_state(device)), + }; + } +}; + +pub const Surface = struct { + app: *App, + gtk_window: *c.GtkWindow, + derived_config: DerivedConfig, + + inner: union(enum) { + none, + x11: if (build_options.x11) x11.Surface else void, + wayland: if (build_options.wayland) wayland.Surface else void, + }, + + pub const DerivedConfig = struct { + blur: Config.BackgroundBlur, + adw_enabled: bool, + + pub fn init(config: *const Config) DerivedConfig { + return .{ + .blur = config.@"background-blur-radius", + .adw_enabled = adwaita.enabled(config), + }; + } + }; + + pub fn init(self: *Surface, window: *c.GtkWindow, app: *App, config: *const Config) void { + self.* = .{ + .app = app, + .derived_config = DerivedConfig.init(config), + .gtk_window = window, + .inner = .none, + }; + + switch (app.inner) { + .x11 => if (comptime build_options.x11) x11.Surface.init(self) else unreachable, + .wayland => if (comptime build_options.wayland) wayland.Surface.init(self) else unreachable, + .none => {}, + } + } + + pub fn deinit(self: Surface) void { + switch (self.inner) { + .wayland => |wl| if (comptime build_options.wayland) wl.deinit() else unreachable, + .x11, .none => {}, + } + } + + pub fn onConfigUpdate(self: *Surface, config: *const Config) !void { + self.derived_config = DerivedConfig.init(config); + + switch (self.inner) { + .x11 => |*x| if (comptime build_options.x11) try x.onConfigUpdate() else unreachable, + .wayland => |*wl| if (comptime build_options.wayland) try wl.onConfigUpdate() else unreachable, + .none => {}, + } + } + + pub fn onResize(self: *Surface) !void { + switch (self.inner) { + .x11 => |*x| if (comptime build_options.x11) try x.onResize() else unreachable, + .wayland, .none => {}, + } + } +}; diff --git a/src/apprt/gtk/wayland.zig b/src/apprt/gtk/protocol/wayland.zig similarity index 58% rename from src/apprt/gtk/wayland.zig rename to src/apprt/gtk/protocol/wayland.zig index 92446cc46..985d7c5a8 100644 --- a/src/apprt/gtk/wayland.zig +++ b/src/apprt/gtk/protocol/wayland.zig @@ -1,106 +1,106 @@ const std = @import("std"); -const c = @import("c.zig").c; +const c = @import("../c.zig").c; const wayland = @import("wayland"); +const protocol = @import("../protocol.zig"); +const Config = @import("../../../config.zig").Config; + const wl = wayland.client.wl; const org = wayland.client.org; -const build_options = @import("build_options"); const log = std.log.scoped(.gtk_wayland); /// Wayland state that contains application-wide Wayland objects (e.g. wl_display). -pub const AppState = struct { +pub const App = struct { display: *wl.Display, blur_manager: ?*org.KdeKwinBlurManager = null, - pub fn init(display: ?*c.GdkDisplay) ?AppState { - if (comptime !build_options.wayland) return null; - - // It should really never be null - const display_ = display orelse return null; - + pub fn init(common: *protocol.App) !void { // Check if we're actually on Wayland if (c.g_type_check_instance_is_a( - @ptrCast(@alignCast(display_)), + @ptrCast(@alignCast(common.gdk_display)), c.gdk_wayland_display_get_type(), ) == 0) - return null; + return; - const wl_display: *wl.Display = @ptrCast(c.gdk_wayland_display_get_wl_display(display_) orelse return null); - - return .{ - .display = wl_display, + var self: App = .{ + .display = @ptrCast(c.gdk_wayland_display_get_wl_display(common.gdk_display) orelse return), }; - } - pub fn register(self: *AppState) !void { + log.debug("wayland platform init={}", .{self}); + const registry = try self.display.getRegistry(); - registry.setListener(*AppState, registryListener, self); + registry.setListener(*App, registryListener, &self); if (self.display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - log.debug("app wayland init={}", .{self}); + common.inner = .{ .wayland = self }; } }; /// Wayland state that contains Wayland objects associated with a window (e.g. wl_surface). -pub const SurfaceState = struct { - app_state: *AppState, +pub const Surface = struct { + common: *const protocol.Surface, + app: *App, surface: *wl.Surface, /// A token that, when present, indicates that the window is blurred. blur_token: ?*org.KdeKwinBlur = null, - pub fn init(window: *c.GtkWindow, app_state: *AppState) ?SurfaceState { - if (comptime !build_options.wayland) return null; - - const surface = c.gtk_native_get_surface(@ptrCast(window)) orelse return null; + pub fn init(common: *protocol.Surface) void { + const surface = c.gtk_native_get_surface(@ptrCast(common.gtk_window)) orelse return; // Check if we're actually on Wayland if (c.g_type_check_instance_is_a( @ptrCast(@alignCast(surface)), c.gdk_wayland_surface_get_type(), ) == 0) - return null; + return; - const wl_surface: *wl.Surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface(surface) orelse return null); - - return .{ - .app_state = app_state, - .surface = wl_surface, + const self: Surface = .{ + .common = common, + .app = &common.app.inner.wayland, + .surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface(surface) orelse return), }; + + common.inner = .{ .wayland = self }; } - pub fn deinit(self: *SurfaceState) void { + pub fn deinit(self: Surface) void { if (self.blur_token) |blur| blur.release(); } - pub fn setBlur(self: *SurfaceState, blurred: bool) !void { - log.debug("setting blur={}", .{blurred}); + pub fn onConfigUpdate(self: *Surface) !void { + try self.updateBlur(); + } - const mgr = self.app_state.blur_manager orelse { + fn updateBlur(self: *Surface) !void { + const blur = self.common.derived_config.blur; + log.debug("setting blur={}", .{blur}); + + const mgr = self.app.blur_manager orelse { log.warn("can't set blur: org_kde_kwin_blur_manager protocol unavailable", .{}); return; }; - if (self.blur_token) |blur| { + if (self.blur_token) |tok| { // Only release token when transitioning from blurred -> not blurred - if (!blurred) { + if (!blur.enabled()) { mgr.unset(self.surface); - blur.release(); + tok.release(); self.blur_token = null; } } else { // Only acquire token when transitioning from not blurred -> blurred - if (blurred) { - const blur_token = try mgr.create(self.surface); - blur_token.commit(); - self.blur_token = blur_token; + if (blur.enabled()) { + const tok = try mgr.create(self.surface); + tok.commit(); + self.blur_token = tok; } } } }; -fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, state: *AppState) void { +fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, state: *App) void { switch (event) { .global => |global| { log.debug("got global interface={s}", .{global.interface}); diff --git a/src/apprt/gtk/x11.zig b/src/apprt/gtk/protocol/x11.zig similarity index 50% rename from src/apprt/gtk/x11.zig rename to src/apprt/gtk/protocol/x11.zig index 21ff87b34..be991bcfe 100644 --- a/src/apprt/gtk/x11.zig +++ b/src/apprt/gtk/protocol/x11.zig @@ -1,57 +1,69 @@ /// Utility functions for X11 handling. const std = @import("std"); const build_options = @import("build_options"); -const c = @import("c.zig").c; -const input = @import("../../input.zig"); +const c = @import("../c.zig").c; +const input = @import("../../../input.zig"); +const Config = @import("../../../config.zig").Config; +const protocol = @import("../protocol.zig"); +const adwaita = @import("../adwaita.zig"); const log = std.log.scoped(.gtk_x11); -/// Returns true if the passed in display is an X11 display. -pub fn is_display(display: ?*c.GdkDisplay) bool { - if (comptime !build_options.x11) return false; - return c.g_type_check_instance_is_a( - @ptrCast(@alignCast(display orelse return false)), - c.gdk_x11_display_get_type(), - ) != 0; -} +pub const App = struct { + common: *protocol.App, + display: *c.Display, -/// Returns true if the app is running on X11 -pub fn is_current_display_server() bool { - if (comptime !build_options.x11) return false; - const display = c.gdk_display_get_default(); - return is_display(display); -} - -pub const Xkb = struct { - base_event_code: c_int, + base_event_code: c_int = 0, /// Initialize an Xkb struct for the given GDK display. If the display isn't /// backed by X then this will return null. - pub fn init(display_: ?*c.GdkDisplay) !?Xkb { - if (comptime !build_options.x11) return null; - - // Display should never be null but we just treat that as a non-X11 - // display so that the caller can just ignore it and not unwrap it. - const display = display_ orelse return null; - + pub fn init(common: *protocol.App) !void { // If the display isn't X11, then we don't need to do anything. - if (!is_display(display)) return null; + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(common.gdk_display)), + c.gdk_x11_display_get_type(), + ) == 0) + return; - log.debug("Xkb.init: initializing Xkb", .{}); - const xdisplay = c.gdk_x11_display_get_xdisplay(display); - var result: Xkb = .{ - .base_event_code = 0, + var self: App = .{ + .common = common, + .display = c.gdk_x11_display_get_xdisplay(common.gdk_display) orelse return, }; + log.debug("X11 platform init={}", .{self}); + + // 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. + + c.g_set_prgname(common.derived_config.x11_program_name); + c.gdk_x11_display_set_program_class(common.gdk_display, common.derived_config.app_id); + + // XKB + log.debug("Xkb.init: initializing Xkb", .{}); + log.debug("Xkb.init: running XkbQueryExtension", .{}); var opcode: c_int = 0; var base_error_code: c_int = 0; var major = c.XkbMajorVersion; var minor = c.XkbMinorVersion; if (c.XkbQueryExtension( - xdisplay, + self.display, &opcode, - &result.base_event_code, + &self.base_event_code, &base_error_code, &major, &minor, @@ -62,7 +74,7 @@ pub const Xkb = struct { log.debug("Xkb.init: running XkbSelectEventDetails", .{}); if (c.XkbSelectEventDetails( - xdisplay, + self.display, c.XkbUseCoreKbd, c.XkbStateNotify, c.XkbModifierStateMask, @@ -72,7 +84,7 @@ pub const Xkb = struct { return error.XkbInitializationError; } - return result; + common.inner = .{ .x11 = self }; } /// Checks for an immediate pending XKB state update event, and returns the @@ -85,18 +97,13 @@ pub const Xkb = struct { /// 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 modifier_state_from_notify(self: Xkb, display_: ?*c.GdkDisplay) ?input.Mods { - if (comptime !build_options.x11) return null; - - const display = display_ orelse return null; - + pub fn modifierStateFromNotify(self: App) ?input.Mods { // Shoutout to Mozilla for figuring out a clean way to do this, this is // paraphrased from Firefox/Gecko in widget/gtk/nsGtkKeyUtils.cpp. - const xdisplay = c.gdk_x11_display_get_xdisplay(display); - if (c.XEventsQueued(xdisplay, c.QueuedAfterReading) == 0) return null; + if (c.XEventsQueued(self.display, c.QueuedAfterReading) == 0) return null; var nextEvent: c.XEvent = undefined; - _ = c.XPeekEvent(xdisplay, &nextEvent); + _ = c.XPeekEvent(self.display, &nextEvent); if (nextEvent.type != self.base_event_code) return null; const xkb_event: *c.XkbEvent = @ptrCast(&nextEvent); @@ -117,3 +124,38 @@ pub const Xkb = struct { return mods; } }; + +pub const Surface = struct { + common: *protocol.Surface, + app: *App, + window: c.Window, + + pub fn init(common: *protocol.Surface) void { + const surface = c.gtk_native_get_surface(@ptrCast(common.gtk_window)) orelse return; + + // Check if we're actually on X11 + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(surface)), + c.gdk_x11_surface_get_type(), + ) == 0) + return; + + common.inner = .{ .x11 = .{ + .common = common, + .app = &common.app.inner.x11, + .window = c.gdk_x11_surface_get_xid(surface), + } }; + } + + pub fn onConfigUpdate(self: *Surface) !void { + _ = self; + } + + pub fn onResize(self: *Surface) !void { + _ = self; + } + + fn updateBlur(self: *Surface) !void { + _ = self; + } +}; diff --git a/src/config/Config.zig b/src/config/Config.zig index eae6541be..e0d25d0fb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5782,6 +5782,14 @@ pub const BackgroundBlur = union(enum) { ) catch return error.InvalidValue }; } + pub fn enabled(self: BackgroundBlur) bool { + return switch (self) { + .false => false, + .true => true, + .radius => |v| v > 0, + }; + } + pub fn cval(self: BackgroundBlur) u8 { return switch (self) { .false => 0, From 405a8972301b14b9f2570beda56c225f9609f1a8 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 2 Jan 2025 23:53:22 +0800 Subject: [PATCH 113/238] gtk(x11): implement background blur for KDE/KWin on X11 --- src/apprt/gtk/c.zig | 2 + src/apprt/gtk/protocol/x11.zig | 68 ++++++++++++++++++++++++++++++++-- src/config/Config.zig | 2 +- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/c.zig b/src/apprt/gtk/c.zig index dde99c78e..4dc8ea57f 100644 --- a/src/apprt/gtk/c.zig +++ b/src/apprt/gtk/c.zig @@ -11,6 +11,8 @@ pub const c = @cImport({ // Add in X11-specific GDK backend which we use for specific things // (e.g. X11 window class). @cInclude("gdk/x11/gdkx.h"); + @cInclude("X11/Xlib.h"); + @cInclude("X11/Xatom.h"); // Xkb for X11 state handling @cInclude("X11/XKBlib.h"); } diff --git a/src/apprt/gtk/protocol/x11.zig b/src/apprt/gtk/protocol/x11.zig index be991bcfe..55762f316 100644 --- a/src/apprt/gtk/protocol/x11.zig +++ b/src/apprt/gtk/protocol/x11.zig @@ -12,6 +12,7 @@ const log = std.log.scoped(.gtk_x11); pub const App = struct { common: *protocol.App, display: *c.Display, + kde_blur_atom: c.Atom, base_event_code: c_int = 0, @@ -28,6 +29,7 @@ pub const App = struct { var self: App = .{ .common = common, .display = c.gdk_x11_display_get_xdisplay(common.gdk_display) orelse return, + .kde_blur_atom = c.gdk_x11_get_xatom_by_name_for_display(common.gdk_display, "_KDE_NET_WM_BLUR_BEHIND_REGION"), }; log.debug("X11 platform init={}", .{self}); @@ -130,6 +132,8 @@ pub const Surface = struct { app: *App, window: c.Window, + blur_region: Region, + pub fn init(common: *protocol.Surface) void { const surface = c.gtk_native_get_surface(@ptrCast(common.gtk_window)) orelse return; @@ -140,22 +144,80 @@ pub const Surface = struct { ) == 0) return; + var blur_region: Region = .{}; + + if ((comptime adwaita.versionAtLeast(0, 0, 0)) and common.derived_config.adw_enabled) { + // 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, var y: f64 = .{ 0, 0 }; + c.gtk_native_get_surface_transform(@ptrCast(common.gtk_window), &x, &y); + blur_region.x, blur_region.y = .{ @intFromFloat(x), @intFromFloat(y) }; + } + common.inner = .{ .x11 = .{ .common = common, .app = &common.app.inner.x11, .window = c.gdk_x11_surface_get_xid(surface), + .blur_region = blur_region, } }; } pub fn onConfigUpdate(self: *Surface) !void { - _ = self; + // Whether background blur is enabled could've changed. Update. + try self.updateBlur(); } pub fn onResize(self: *Surface) !void { - _ = self; + // The blur region must update with window resizes + self.blur_region.width = c.gtk_widget_get_width(@ptrCast(self.common.gtk_window)); + self.blur_region.height = c.gtk_widget_get_height(@ptrCast(self.common.gtk_window)); + try self.updateBlur(); } fn updateBlur(self: *Surface) !void { - _ = self; + // 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 noticable enough to justify the effort. + // (Wayland also has this visual artifact anyway...) + + const blur = self.common.derived_config.blur; + log.debug("set blur={}, window xid={}, region={}", .{ blur, self.window, self.blur_region }); + + if (blur.enabled()) { + _ = c.XChangeProperty( + self.app.display, + self.window, + self.app.kde_blur_atom, + c.XA_CARDINAL, + // Despite what you might think, the "32" here does NOT mean + // that the data should be in u32s. Instead, they should be + // c_longs, which on any 64-bit architecture would be obviously + // 64 bits. WTF?! + 32, + c.PropModeReplace, + // SAFETY: Region is an extern struct that has the same + // representation of 4 c_longs put next to each other. + // Therefore, reinterpretation should be safe. + // We don't have to care about endianness either since + // Xlib converts it to network byte order for us. + @ptrCast(std.mem.asBytes(&self.blur_region)), + 4, + ); + } else { + _ = c.XDeleteProperty(self.app.display, self.window, self.app.kde_blur_atom); + } } }; + +const Region = extern struct { + x: c_long = 0, + y: c_long = 0, + width: c_long = 0, + height: c_long = 0, +}; diff --git a/src/config/Config.zig b/src/config/Config.zig index e0d25d0fb..7751f3e77 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -604,7 +604,7 @@ palette: Palette = .{}, /// /// Supported on macOS and on some Linux desktop environments, including: /// -/// * KDE Plasma (Wayland only) +/// * KDE Plasma (Wayland and X11) /// /// Warning: the exact blur intensity is _ignored_ under KDE Plasma, and setting /// this setting to either `true` or any positive blur intensity value would From c03828e03235a83918d907cf9b3a59928bd825b2 Mon Sep 17 00:00:00 2001 From: Anund Date: Tue, 7 Jan 2025 17:56:21 +1100 Subject: [PATCH 114/238] vim: work with theme config files --- src/config/vim.zig | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/config/vim.zig b/src/config/vim.zig index 62255bd79..ab487f9f9 100644 --- a/src/config/vim.zig +++ b/src/config/vim.zig @@ -3,7 +3,16 @@ const Config = @import("Config.zig"); /// This is the associated Vim file as named by the variable. pub const syntax = comptimeGenSyntax(); -pub const ftdetect = "au BufRead,BufNewFile */ghostty/config set ft=ghostty\n"; +pub const ftdetect = + \\" Vim filetype detect file + \\" Language: Ghostty config file + \\" Maintainer: Ghostty + \\" + \\" THIS FILE IS AUTO-GENERATED + \\ + \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* set ft=ghostty + \\ +; pub const ftplugin = \\" Vim filetype plugin file \\" Language: Ghostty config file @@ -31,13 +40,19 @@ pub const ftplugin = \\ ; pub const compiler = + \\" Vim compiler file + \\" Language: Ghostty config file + \\" Maintainer: Ghostty + \\" + \\" THIS FILE IS AUTO-GENERATED + \\ \\if exists("current_compiler") \\ finish \\endif \\let current_compiler = "ghostty" \\ - \\CompilerSet makeprg=ghostty\ +validate-config - \\CompilerSet errorformat=%f:%l:%m + \\CompilerSet makeprg=ghostty\ +validate-config\ --config-file=%:S + \\CompilerSet errorformat=%f:%l:%m,%m \\ ; From 19cfd9943991944e63cf1ce8d305387d8272dac3 Mon Sep 17 00:00:00 2001 From: Onno Siemens Date: Fri, 10 Jan 2025 18:11:57 +0100 Subject: [PATCH 115/238] docs: update copy-on-select documentation --- src/config/Config.zig | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index eae6541be..24e25437d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1387,16 +1387,14 @@ keybind: Keybinds = .{}, @"image-storage-limit": u32 = 320 * 1000 * 1000, /// Whether to automatically copy selected text to the clipboard. `true` -/// will prefer to copy to the selection clipboard if supported by the -/// OS, otherwise it will copy to the system clipboard. +/// will prefer to copy to the selection clipboard, otherwise it will copy to +/// the system clipboard. /// /// The value `clipboard` will always copy text to the selection clipboard -/// (for supported systems) as well as the system clipboard. This is sometimes -/// a preferred behavior on Linux. +/// as well as the system clipboard. /// -/// Middle-click paste will always use the selection clipboard on Linux -/// and the system clipboard on macOS. Middle-click paste is always enabled -/// even if this is `false`. +/// Middle-click paste will always use the selection clipboard. Middle-click +/// paste is always enabled even if this is `false`. /// /// The default value is true on Linux and macOS. @"copy-on-select": CopyOnSelect = switch (builtin.os.tag) { From ed81b62ec24790fea79877d2e2124d82271ee3df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jan 2025 20:00:30 -0800 Subject: [PATCH 116/238] apprt/gtk: winproto Rename "protocol" to "winproto". --- src/apprt/gtk/App.zig | 34 ++- src/apprt/gtk/Surface.zig | 15 +- src/apprt/gtk/Window.zig | 38 +++- src/apprt/gtk/key.zig | 6 +- src/apprt/gtk/protocol.zig | 149 ------------- src/apprt/gtk/protocol/wayland.zig | 125 ----------- src/apprt/gtk/winproto.zig | 128 +++++++++++ src/apprt/gtk/winproto/noop.zig | 56 +++++ src/apprt/gtk/winproto/wayland.zig | 211 +++++++++++++++++++ src/apprt/gtk/{protocol => winproto}/x11.zig | 178 +++++++++++----- 10 files changed, 589 insertions(+), 351 deletions(-) delete mode 100644 src/apprt/gtk/protocol.zig delete mode 100644 src/apprt/gtk/protocol/wayland.zig create mode 100644 src/apprt/gtk/winproto.zig create mode 100644 src/apprt/gtk/winproto/noop.zig create mode 100644 src/apprt/gtk/winproto/wayland.zig rename src/apprt/gtk/{protocol => winproto}/x11.zig (65%) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index ecbe61bce..b041d29fb 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -36,7 +36,7 @@ const c = @import("c.zig").c; const version = @import("version.zig"); const inspector = @import("inspector.zig"); const key = @import("key.zig"); -const protocol = @import("protocol.zig"); +const winproto = @import("winproto.zig"); const testing = std.testing; const log = std.log.scoped(.gtk); @@ -49,6 +49,9 @@ config: Config, app: *c.GtkApplication, ctx: *c.GMainContext, +/// State and logic for the underlying windowing protocol. +winproto: winproto.App, + /// True if the app was launched with single instance mode. single_instance: bool, @@ -70,8 +73,6 @@ clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, /// This is set to false when the main loop should exit. running: bool = true, -protocol: protocol.App, - /// 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. @@ -161,7 +162,12 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } c.gtk_init(); - const display = c.gdk_display_get_default(); + const display: *c.GdkDisplay = c.gdk_display_get_default() 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); + }; // If we're using libadwaita, log the version if (adwaita.enabled(&config)) { @@ -359,7 +365,14 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { return error.GtkApplicationRegisterFailed; } - const app_protocol = try protocol.App.init(display, &config, app_id); + // Setup our windowing protocol logic + var winproto_app = try winproto.App.init( + core_app.alloc, + display, + app_id, + &config, + ); + errdefer winproto_app.deinit(core_app.alloc); // 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 @@ -385,7 +398,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { .config = config, .ctx = ctx, .cursor_none = cursor_none, - .protocol = app_protocol, + .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 @@ -413,6 +426,8 @@ pub fn terminate(self: *App) void { } self.custom_css_providers.deinit(self.core_app.alloc); + self.winproto.deinit(self.core_app.alloc); + self.config.deinit(); } @@ -837,9 +852,10 @@ fn configChange( new_config: *const Config, ) void { switch (target) { - .surface => |surface| { - if (surface.rt_surface.container.window()) |window| window.syncAppearance(new_config) catch |err| { - log.warn("error syncing appearance changes to window err={}", .{err}); + .surface => |surface| surface: { + 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}); }; }, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 97b7bb0f4..c16f696b1 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -824,6 +824,9 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { c.g_object_get_property(@ptrCast(@alignCast(settings)), "gtk-xft-dpi", &value); const gtk_xft_dpi = c.g_value_get_int(&value); + // 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; break :xft_scale xft_dpi / 96; }; @@ -1380,9 +1383,13 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque) return; }; - if (self.container.window()) |window| window.protocol.onResize() catch |err| { - log.warn("failed to notify X11/Wayland integration of resize={}", .{err}); - }; + if (self.container.window()) |window| { + if (window.winproto) |*winproto| { + winproto.resizeEvent() catch |err| { + log.warn("failed to notify window protocol of resize={}", .{err}); + }; + } + } self.resize_overlay.maybeShow(); } @@ -1702,7 +1709,7 @@ pub fn keyEvent( event, physical_key, gtk_mods, - &self.app.protocol, + &self.app.winproto, ); // Get our consumed modifiers diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index fbba22195..d0e678057 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -25,7 +25,7 @@ const gtk_key = @import("key.zig"); const Notebook = @import("notebook.zig").Notebook; const HeaderBar = @import("headerbar.zig").HeaderBar; const version = @import("version.zig"); -const protocol = @import("protocol.zig"); +const winproto = @import("winproto.zig"); const log = std.log.scoped(.gtk); @@ -56,7 +56,8 @@ toast_overlay: ?*c.GtkWidget, /// See adwTabOverviewOpen for why we have this. adw_tab_overview_focus_timer: ?c.guint = null, -protocol: protocol.Surface, +/// State and logic for windowing protocol for a window. +winproto: ?winproto.Window, pub fn create(alloc: Allocator, app: *App) !*Window { // Allocate a fixed pointer for our window. We try to minimize @@ -82,7 +83,7 @@ pub fn init(self: *Window, app: *App) !void { .notebook = undefined, .context_menu = undefined, .toast_overlay = undefined, - .protocol = undefined, + .winproto = null, }; // Create the window @@ -384,6 +385,16 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_show(window); } +pub fn updateConfig( + self: *Window, + config: *const configpkg.Config, +) !void { + if (self.winproto) |*v| try v.updateConfigEvent(config); + + // We always resync our appearance whenever the config changes. + try self.syncAppearance(config); +} + /// Updates appearance based on config settings. Will be called once upon window /// realization, and every time the config is reloaded. /// @@ -396,8 +407,10 @@ pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { c.gtk_widget_add_css_class(@ptrCast(self.window), "background"); } - // Perform protocol-specific config updates - try self.protocol.onConfigUpdate(config); + // Window protocol specific appearance updates + if (self.winproto) |*v| v.syncAppearance() catch |err| { + log.warn("failed to sync window protocol appearance error={}", .{err}); + }; } /// Sets up the GTK actions for the window scope. Actions are how GTK handles @@ -437,7 +450,7 @@ fn initActions(self: *Window) void { pub fn deinit(self: *Window) void { c.gtk_widget_unparent(@ptrCast(self.context_menu)); - self.protocol.deinit(); + if (self.winproto) |*v| v.deinit(self.app.core_app.alloc); if (self.adw_tab_overview_focus_timer) |timer| { _ = c.g_source_remove(timer); @@ -578,8 +591,19 @@ pub fn sendToast(self: *Window, title: [:0]const u8) void { fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { const self = userdataSelf(ud.?); - self.protocol.init(v, &self.app.protocol, &self.app.config); + // Initialize our window protocol logic + if (winproto.Window.init( + self.app.core_app.alloc, + &self.app.winproto, + v, + &self.app.config, + )) |winproto_win| { + self.winproto = winproto_win; + } else |err| { + log.warn("failed to initialize window protocol error={}", .{err}); + } + // When we are realized we always setup our appearance self.syncAppearance(&self.app.config) catch |err| { log.err("failed to initialize appearance={}", .{err}); }; diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index ef460e62c..40c9ca9a4 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -2,7 +2,7 @@ const std = @import("std"); const build_options = @import("build_options"); const input = @import("../../input.zig"); const c = @import("c.zig").c; -const protocol = @import("protocol.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 { @@ -108,11 +108,11 @@ pub fn eventMods( event: *c.GdkEvent, physical_key: input.Key, gtk_mods: c.GdkModifierType, - app_protocol: *protocol.App, + app_winproto: *winproto.App, ) input.Mods { const device = c.gdk_event_get_device(event); - var mods = app_protocol.eventMods(device, gtk_mods); + var mods = app_winproto.eventMods(device, gtk_mods); mods.num_lock = c.gdk_device_get_num_lock_state(device) == 1; switch (physical_key) { diff --git a/src/apprt/gtk/protocol.zig b/src/apprt/gtk/protocol.zig deleted file mode 100644 index c7d7247cf..000000000 --- a/src/apprt/gtk/protocol.zig +++ /dev/null @@ -1,149 +0,0 @@ -const std = @import("std"); -const x11 = @import("protocol/x11.zig"); -const wayland = @import("protocol/wayland.zig"); -const c = @import("c.zig").c; -const build_options = @import("build_options"); -const input = @import("../../input.zig"); -const apprt = @import("../../apprt.zig"); -const Config = @import("../../config.zig").Config; -const adwaita = @import("adwaita.zig"); -const builtin = @import("builtin"); -const key = @import("key.zig"); - -const log = std.log.scoped(.gtk_platform); - -pub const App = struct { - gdk_display: *c.GdkDisplay, - derived_config: DerivedConfig, - - inner: union(enum) { - none, - x11: if (build_options.x11) x11.App else void, - wayland: if (build_options.wayland) wayland.App else void, - }, - - const DerivedConfig = struct { - app_id: [:0]const u8, - x11_program_name: [:0]const u8, - - pub fn init(config: *const Config, app_id: [:0]const u8) DerivedConfig { - return .{ - .app_id = app_id, - .x11_program_name = if (config.@"x11-instance-name") |pn| - pn - else if (builtin.mode == .Debug) - "ghostty-debug" - else - "ghostty", - }; - } - }; - - pub fn init(display: ?*c.GdkDisplay, config: *const Config, app_id: [:0]const u8) !App { - var self: App = .{ - .inner = .none, - .derived_config = DerivedConfig.init(config, app_id), - .gdk_display = display orelse { - // TODO: When does this ever happen...? - std.debug.panic("GDK display is null!", .{}); - }, - }; - - // The X11/Wayland init functions set `self.inner` when successful, - // so we only need to keep trying if `self.inner` stays `.none` - if (self.inner == .none and comptime build_options.wayland) try wayland.App.init(&self); - if (self.inner == .none and comptime build_options.x11) try x11.App.init(&self); - - // Welp, no integration for you - if (self.inner == .none) { - log.warn( - "neither X11 nor Wayland integrations enabled - lots of features would be missing!", - .{}, - ); - } - - return self; - } - - pub fn eventMods(self: *App, device: ?*c.GdkDevice, gtk_mods: c.GdkModifierType) input.Mods { - return switch (self.inner) { - // Add any modifier state events from Xkb if we have them (X11 - // only). Null back from the Xkb call means there was no modifier - // event to read. This likely means that the key event did not - // result in a modifier change and we can safely rely on the GDK - // state. - .x11 => |*x| if (comptime build_options.x11) - x.modifierStateFromNotify() orelse key.translateMods(gtk_mods) - else - unreachable, - - // On Wayland, we have to use the GDK device because the mods sent - // to this event do not have the modifier key applied if it was - // pressed (i.e. left control). - .wayland, .none => key.translateMods(c.gdk_device_get_modifier_state(device)), - }; - } -}; - -pub const Surface = struct { - app: *App, - gtk_window: *c.GtkWindow, - derived_config: DerivedConfig, - - inner: union(enum) { - none, - x11: if (build_options.x11) x11.Surface else void, - wayland: if (build_options.wayland) wayland.Surface else void, - }, - - pub const DerivedConfig = struct { - blur: Config.BackgroundBlur, - adw_enabled: bool, - - pub fn init(config: *const Config) DerivedConfig { - return .{ - .blur = config.@"background-blur-radius", - .adw_enabled = adwaita.enabled(config), - }; - } - }; - - pub fn init(self: *Surface, window: *c.GtkWindow, app: *App, config: *const Config) void { - self.* = .{ - .app = app, - .derived_config = DerivedConfig.init(config), - .gtk_window = window, - .inner = .none, - }; - - switch (app.inner) { - .x11 => if (comptime build_options.x11) x11.Surface.init(self) else unreachable, - .wayland => if (comptime build_options.wayland) wayland.Surface.init(self) else unreachable, - .none => {}, - } - } - - pub fn deinit(self: Surface) void { - switch (self.inner) { - .wayland => |wl| if (comptime build_options.wayland) wl.deinit() else unreachable, - .x11, .none => {}, - } - } - - pub fn onConfigUpdate(self: *Surface, config: *const Config) !void { - self.derived_config = DerivedConfig.init(config); - - switch (self.inner) { - .x11 => |*x| if (comptime build_options.x11) try x.onConfigUpdate() else unreachable, - .wayland => |*wl| if (comptime build_options.wayland) try wl.onConfigUpdate() else unreachable, - .none => {}, - } - } - - pub fn onResize(self: *Surface) !void { - switch (self.inner) { - .x11 => |*x| if (comptime build_options.x11) try x.onResize() else unreachable, - .wayland, .none => {}, - } - } -}; diff --git a/src/apprt/gtk/protocol/wayland.zig b/src/apprt/gtk/protocol/wayland.zig deleted file mode 100644 index 985d7c5a8..000000000 --- a/src/apprt/gtk/protocol/wayland.zig +++ /dev/null @@ -1,125 +0,0 @@ -const std = @import("std"); -const c = @import("../c.zig").c; -const wayland = @import("wayland"); -const protocol = @import("../protocol.zig"); -const Config = @import("../../../config.zig").Config; - -const wl = wayland.client.wl; -const org = wayland.client.org; - -const log = std.log.scoped(.gtk_wayland); - -/// Wayland state that contains application-wide Wayland objects (e.g. wl_display). -pub const App = struct { - display: *wl.Display, - blur_manager: ?*org.KdeKwinBlurManager = null, - - pub fn init(common: *protocol.App) !void { - // Check if we're actually on Wayland - if (c.g_type_check_instance_is_a( - @ptrCast(@alignCast(common.gdk_display)), - c.gdk_wayland_display_get_type(), - ) == 0) - return; - - var self: App = .{ - .display = @ptrCast(c.gdk_wayland_display_get_wl_display(common.gdk_display) orelse return), - }; - - log.debug("wayland platform init={}", .{self}); - - const registry = try self.display.getRegistry(); - - registry.setListener(*App, registryListener, &self); - if (self.display.roundtrip() != .SUCCESS) return error.RoundtripFailed; - - common.inner = .{ .wayland = self }; - } -}; - -/// Wayland state that contains Wayland objects associated with a window (e.g. wl_surface). -pub const Surface = struct { - common: *const protocol.Surface, - app: *App, - surface: *wl.Surface, - - /// A token that, when present, indicates that the window is blurred. - blur_token: ?*org.KdeKwinBlur = null, - - pub fn init(common: *protocol.Surface) void { - const surface = c.gtk_native_get_surface(@ptrCast(common.gtk_window)) orelse return; - - // Check if we're actually on Wayland - if (c.g_type_check_instance_is_a( - @ptrCast(@alignCast(surface)), - c.gdk_wayland_surface_get_type(), - ) == 0) - return; - - const self: Surface = .{ - .common = common, - .app = &common.app.inner.wayland, - .surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface(surface) orelse return), - }; - - common.inner = .{ .wayland = self }; - } - - pub fn deinit(self: Surface) void { - if (self.blur_token) |blur| blur.release(); - } - - pub fn onConfigUpdate(self: *Surface) !void { - try self.updateBlur(); - } - - fn updateBlur(self: *Surface) !void { - const blur = self.common.derived_config.blur; - log.debug("setting blur={}", .{blur}); - - const mgr = self.app.blur_manager orelse { - log.warn("can't set blur: org_kde_kwin_blur_manager protocol unavailable", .{}); - return; - }; - - if (self.blur_token) |tok| { - // Only release token when transitioning from blurred -> not blurred - if (!blur.enabled()) { - mgr.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 mgr.create(self.surface); - tok.commit(); - self.blur_token = tok; - } - } - } -}; - -fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, state: *App) void { - switch (event) { - .global => |global| { - log.debug("got global interface={s}", .{global.interface}); - if (bindInterface(org.KdeKwinBlurManager, registry, global, 1)) |iface| { - state.blur_manager = iface; - return; - } - }, - .global_remove => {}, - } -} - -fn bindInterface(comptime T: type, registry: *wl.Registry, global: anytype, version: u32) ?*T { - if (std.mem.orderZ(u8, global.interface, T.interface.name) == .eq) { - return registry.bind(global.name, T, version) catch |err| { - log.warn("encountered error={} while binding interface {s}", .{ err, global.interface }); - return null; - }; - } else { - return null; - } -} diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig new file mode 100644 index 000000000..49d96bb02 --- /dev/null +++ b/src/apprt/gtk/winproto.zig @@ -0,0 +1,128 @@ +const std = @import("std"); +const build_options = @import("build_options"); +const Allocator = std.mem.Allocator; +const c = @import("c.zig").c; +const Config = @import("../../config.zig").Config; +const input = @import("../../input.zig"); +const key = @import("key.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: *c.GdkDisplay, + 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: ?*c.GdkDevice, + gtk_mods: c.GdkModifierType, + ) input.Mods { + return switch (self.*) { + inline else => |*v| v.eventMods(device, gtk_mods), + } orelse key.translateMods(gtk_mods); + } +}; + +/// Per-Window state for the underlying windowing protocol. +/// +/// In both X and Wayland, the terminology used is "Surface" and 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, + window: *c.GtkWindow, + config: *const Config, + ) !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, + window, + config, + ), + ); + } + }, + }; + } + + 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 updateConfigEvent( + self: *Window, + config: *const Config, + ) !void { + switch (self.*) { + inline else => |*v| try v.updateConfigEvent(config), + } + } + + pub fn syncAppearance(self: *Window) !void { + switch (self.*) { + inline else => |*v| try v.syncAppearance(), + } + } +}; diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig new file mode 100644 index 000000000..54c14fe14 --- /dev/null +++ b/src/apprt/gtk/winproto/noop.zig @@ -0,0 +1,56 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const c = @import("../c.zig").c; +const Config = @import("../../../config.zig").Config; +const input = @import("../../../input.zig"); + +const log = std.log.scoped(.winproto_noop); + +pub const App = struct { + pub fn init( + _: Allocator, + _: *c.GdkDisplay, + _: [:0]const u8, + _: *const Config, + ) !?App { + return .{}; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + pub fn eventMods( + _: *App, + _: ?*c.GdkDevice, + _: c.GdkModifierType, + ) ?input.Mods { + return null; + } +}; + +pub const Window = struct { + pub fn init( + _: Allocator, + _: *App, + _: *c.GtkWindow, + _: *const Config, + ) !Window { + return .{}; + } + + pub fn deinit(self: Window, alloc: Allocator) void { + _ = self; + _ = alloc; + } + + pub fn updateConfigEvent( + _: *Window, + _: *const Config, + ) !void {} + + pub fn resizeEvent(_: *Window) !void {} + + pub fn syncAppearance(_: *Window) !void {} +}; diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig new file mode 100644 index 000000000..3f7ad0068 --- /dev/null +++ b/src/apprt/gtk/winproto/wayland.zig @@ -0,0 +1,211 @@ +//! Wayland protocol implementation for the Ghostty GTK apprt. +const std = @import("std"); +const wayland = @import("wayland"); +const Allocator = std.mem.Allocator; +const c = @import("../c.zig").c; +const Config = @import("../../../config.zig").Config; +const input = @import("../../../input.zig"); + +const wl = wayland.client.wl; +const org = wayland.client.org; + +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, + }; + + pub fn init( + alloc: Allocator, + gdk_display: *c.GdkDisplay, + app_id: [:0]const u8, + config: *const Config, + ) !?App { + _ = config; + _ = app_id; + + // Check if we're actually on Wayland + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(gdk_display)), + c.gdk_wayland_display_get_type(), + ) == 0) return null; + + const display: *wl.Display = @ptrCast(c.gdk_wayland_display_get_wl_display( + gdk_display, + ) 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; + + return .{ + .display = display, + .context = context, + }; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + alloc.destroy(self.context); + } + + pub fn eventMods( + _: *App, + _: ?*c.GdkDevice, + _: c.GdkModifierType, + ) ?input.Mods { + return null; + } + + fn registryListener( + registry: *wl.Registry, + event: wl.Registry.Event, + context: *Context, + ) void { + switch (event) { + // https://wayland.app/protocols/wayland#wl_registry:event:global + .global => |global| global: { + log.debug("wl_registry.global: interface={s}", .{global.interface}); + + if (registryBind( + org.KdeKwinBlurManager, + registry, + global, + 1, + )) |blur_manager| { + context.kde_blur_manager = blur_manager; + break :global; + } + }, + + // We don't handle removal events + .global_remove => {}, + } + } + + fn registryBind( + comptime T: type, + registry: *wl.Registry, + global: anytype, + version: u32, + ) ?*T { + if (std.mem.orderZ( + u8, + global.interface, + T.interface.name, + ) != .eq) return null; + + return registry.bind(global.name, T, version) catch |err| { + log.warn("error binding interface {s} error={}", .{ + global.interface, + err, + }); + return null; + }; + } +}; + +/// Per-window (wl_surface) state for the Wayland protocol. +pub const Window = struct { + config: DerivedConfig, + + /// 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, + + const DerivedConfig = struct { + blur: bool, + + pub fn init(config: *const Config) DerivedConfig { + return .{ + .blur = config.@"background-blur-radius".enabled(), + }; + } + }; + + pub fn init( + alloc: Allocator, + app: *App, + gtk_window: *c.GtkWindow, + config: *const Config, + ) !Window { + _ = alloc; + + const gdk_surface = c.gtk_native_get_surface( + @ptrCast(gtk_window), + ) 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. + if (c.g_type_check_instance_is_a( + @ptrCast(@alignCast(gdk_surface)), + c.gdk_wayland_surface_get_type(), + ) == 0) return error.NotWaylandSurface; + + const wl_surface: *wl.Surface = @ptrCast(c.gdk_wayland_surface_get_wl_surface( + gdk_surface, + ) orelse return error.NoWaylandSurface); + + return .{ + .config = DerivedConfig.init(config), + .surface = wl_surface, + .app_context = app.context, + }; + } + + pub fn deinit(self: Window, alloc: Allocator) void { + _ = alloc; + if (self.blur_token) |blur| blur.release(); + } + + pub fn updateConfigEvent(self: *Window, config: *const Config) !void { + self.config = DerivedConfig.init(config); + } + + pub fn resizeEvent(_: *Window) !void {} + + pub fn syncAppearance(self: *Window) !void { + try self.syncBlur(); + } + + /// 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.config.blur; + + if (self.blur_token) |tok| { + // Only release token when transitioning from blurred -> not blurred + if (!blur) { + manager.unset(self.surface); + tok.release(); + self.blur_token = null; + } + } else { + // Only acquire token when transitioning from not blurred -> blurred + if (blur) { + const tok = try manager.create(self.surface); + tok.commit(); + self.blur_token = tok; + } + } + } +}; diff --git a/src/apprt/gtk/protocol/x11.zig b/src/apprt/gtk/winproto/x11.zig similarity index 65% rename from src/apprt/gtk/protocol/x11.zig rename to src/apprt/gtk/winproto/x11.zig index 55762f316..d896fc051 100644 --- a/src/apprt/gtk/protocol/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -1,38 +1,45 @@ -/// Utility functions for X11 handling. +//! 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 c = @import("../c.zig").c; const input = @import("../../../input.zig"); const Config = @import("../../../config.zig").Config; -const protocol = @import("../protocol.zig"); const adwaita = @import("../adwaita.zig"); const log = std.log.scoped(.gtk_x11); pub const App = struct { - common: *protocol.App, display: *c.Display, + base_event_code: c_int, kde_blur_atom: c.Atom, - base_event_code: c_int = 0, + pub fn init( + alloc: Allocator, + gdk_display: *c.GdkDisplay, + app_id: [:0]const u8, + config: *const Config, + ) !?App { + _ = alloc; - /// Initialize an Xkb struct for the given GDK display. If the display isn't - /// backed by X then this will return null. - pub fn init(common: *protocol.App) !void { // If the display isn't X11, then we don't need to do anything. if (c.g_type_check_instance_is_a( - @ptrCast(@alignCast(common.gdk_display)), + @ptrCast(@alignCast(gdk_display)), c.gdk_x11_display_get_type(), - ) == 0) - return; + ) == 0) return null; - var self: App = .{ - .common = common, - .display = c.gdk_x11_display_get_xdisplay(common.gdk_display) orelse return, - .kde_blur_atom = c.gdk_x11_get_xatom_by_name_for_display(common.gdk_display, "_KDE_NET_WM_BLUR_BEHIND_REGION"), - }; + // Get our X11 display + const display: *c.Display = c.gdk_x11_display_get_xdisplay( + gdk_display, + ) orelse return error.NoX11Display; - log.debug("X11 platform init={}", .{self}); + 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. @@ -50,22 +57,21 @@ pub const App = struct { // WM_CLASS(STRING) = "ghostty", "com.mitchellh.ghostty" // // Append "-debug" on both when using the debug build. - - c.g_set_prgname(common.derived_config.x11_program_name); - c.gdk_x11_display_set_program_class(common.gdk_display, common.derived_config.app_id); + c.g_set_prgname(x11_program_name); + c.gdk_x11_display_set_program_class(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( - self.display, + display, &opcode, - &self.base_event_code, + &base_event_code, &base_error_code, &major, &minor, @@ -76,7 +82,7 @@ pub const App = struct { log.debug("Xkb.init: running XkbSelectEventDetails", .{}); if (c.XkbSelectEventDetails( - self.display, + display, c.XkbUseCoreKbd, c.XkbStateNotify, c.XkbModifierStateMask, @@ -86,7 +92,19 @@ pub const App = struct { return error.XkbInitializationError; } - common.inner = .{ .x11 = self }; + return .{ + .display = display, + .base_event_code = base_event_code, + .kde_blur_atom = c.gdk_x11_get_xatom_by_name_for_display( + gdk_display, + "_KDE_NET_WM_BLUR_BEHIND_REGION", + ), + }; + } + + pub fn deinit(self: *App, alloc: Allocator) void { + _ = self; + _ = alloc; } /// Checks for an immediate pending XKB state update event, and returns the @@ -99,7 +117,14 @@ pub const App = struct { /// 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 modifierStateFromNotify(self: App) ?input.Mods { + pub fn eventMods( + self: App, + device: ?*c.GdkDevice, + gtk_mods: c.GdkModifierType, + ) ?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(self.display, c.QueuedAfterReading) == 0) return null; @@ -127,57 +152,94 @@ pub const App = struct { } }; -pub const Surface = struct { - common: *protocol.Surface, +pub const Window = struct { app: *App, + config: DerivedConfig, window: c.Window, - + gtk_window: *c.GtkWindow, blur_region: Region, - pub fn init(common: *protocol.Surface) void { - const surface = c.gtk_native_get_surface(@ptrCast(common.gtk_window)) orelse return; + const DerivedConfig = struct { + blur: bool, + + pub fn init(config: *const Config) DerivedConfig { + return .{ + .blur = config.@"background-blur-radius".enabled(), + }; + } + }; + + pub fn init( + _: Allocator, + app: *App, + gtk_window: *c.GtkWindow, + config: *const Config, + ) !Window { + const surface = c.gtk_native_get_surface( + @ptrCast(gtk_window), + ) orelse return error.NotX11Surface; // Check if we're actually on X11 if (c.g_type_check_instance_is_a( @ptrCast(@alignCast(surface)), c.gdk_x11_surface_get_type(), - ) == 0) - return; + ) == 0) return error.NotX11Surface; - var blur_region: Region = .{}; + const blur_region: Region = blur: { + if ((comptime !adwaita.versionAtLeast(0, 0, 0)) or + !adwaita.enabled(config)) break :blur .{}; - if ((comptime adwaita.versionAtLeast(0, 0, 0)) and common.derived_config.adw_enabled) { // 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; + c.gtk_native_get_surface_transform( + @ptrCast(gtk_window), + &x, + &y, + ); - var x: f64, var y: f64 = .{ 0, 0 }; - c.gtk_native_get_surface_transform(@ptrCast(common.gtk_window), &x, &y); - blur_region.x, blur_region.y = .{ @intFromFloat(x), @intFromFloat(y) }; - } + break :blur .{ + .x = @intFromFloat(x), + .y = @intFromFloat(y), + }; + }; - common.inner = .{ .x11 = .{ - .common = common, - .app = &common.app.inner.x11, + return .{ + .app = app, + .config = DerivedConfig.init(config), .window = c.gdk_x11_surface_get_xid(surface), + .gtk_window = gtk_window, .blur_region = blur_region, - } }; + }; } - pub fn onConfigUpdate(self: *Surface) !void { - // Whether background blur is enabled could've changed. Update. - try self.updateBlur(); + pub fn deinit(self: Window, alloc: Allocator) void { + _ = self; + _ = alloc; } - pub fn onResize(self: *Surface) !void { + pub fn updateConfigEvent( + self: *Window, + config: *const Config, + ) !void { + self.config = DerivedConfig.init(config); + } + + pub fn resizeEvent(self: *Window) !void { // The blur region must update with window resizes - self.blur_region.width = c.gtk_widget_get_width(@ptrCast(self.common.gtk_window)); - self.blur_region.height = c.gtk_widget_get_height(@ptrCast(self.common.gtk_window)); - try self.updateBlur(); + self.blur_region.width = c.gtk_widget_get_width(@ptrCast(self.gtk_window)); + self.blur_region.height = c.gtk_widget_get_height(@ptrCast(self.gtk_window)); + try self.syncBlur(); } - fn updateBlur(self: *Surface) !void { + pub fn syncAppearance(self: *Window) !void { + try self.syncBlur(); + } + + 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 @@ -186,10 +248,14 @@ pub const Surface = struct { // and I think it's not really noticable enough to justify the effort. // (Wayland also has this visual artifact anyway...) - const blur = self.common.derived_config.blur; - log.debug("set blur={}, window xid={}, region={}", .{ blur, self.window, self.blur_region }); + const blur = self.config.blur; + log.debug("set blur={}, window xid={}, region={}", .{ + blur, + self.window, + self.blur_region, + }); - if (blur.enabled()) { + if (blur) { _ = c.XChangeProperty( self.app.display, self.window, @@ -210,7 +276,11 @@ pub const Surface = struct { 4, ); } else { - _ = c.XDeleteProperty(self.app.display, self.window, self.app.kde_blur_atom); + _ = c.XDeleteProperty( + self.app.display, + self.window, + self.app.kde_blur_atom, + ); } } }; From be0370cb0e8bde6bcea056f537c71873a90c54a0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 09:41:07 -0800 Subject: [PATCH 117/238] ci: test gtk-wayland in the GTK matrix --- .github/workflows/test.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b8e79959..1e021af64 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -342,7 +342,8 @@ jobs: matrix: adwaita: ["true", "false"] x11: ["true", "false"] - name: GTK adwaita=${{ matrix.adwaita }} x11=${{ matrix.x11 }} + wayland: ["true", "false"] + name: GTK adwaita=${{ matrix.adwaita }} x11=${{ matrix.x11 }} wayland=${{ matrix.wayland }} runs-on: namespace-profile-ghostty-sm needs: test env: @@ -374,7 +375,8 @@ jobs: zig build \ -Dapp-runtime=gtk \ -Dgtk-adwaita=${{ matrix.adwaita }} \ - -Dgtk-x11=${{ matrix.x11 }} + -Dgtk-x11=${{ matrix.x11 }} \ + -Dgtk-wayland=${{ matrix.wayland }} test-sentry-linux: strategy: From 2f81c360bd66541ebea40dcb1d62b8f8211bad64 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 09:42:36 -0800 Subject: [PATCH 118/238] ci: typos --- src/apprt/gtk/winproto/x11.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index d896fc051..4eac9cdf3 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -245,7 +245,7 @@ pub const Window = struct { // 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 noticable enough to justify the effort. + // and I think it's not really noticeable enough to justify the effort. // (Wayland also has this visual artifact anyway...) const blur = self.config.blur; From 6e411d60f209300d7f06c3502c085b086c058734 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 09:56:33 -0800 Subject: [PATCH 119/238] Fix wayland-scanner/protocols packaging dependency By updating zig-wayland: https://codeberg.org/ifreund/zig-wayland/issues/67 --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 18a608bb4..3c6ab85d6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -34,8 +34,8 @@ .hash = "12207831bce7d4abce57b5a98e8f3635811cfefd160bca022eb91fe905d36a02cf25", }, .zig_wayland = .{ - .url = "https://codeberg.org/ifreund/zig-wayland/archive/0823d9116b80d65ecfad48a2efbca166c7b03497.tar.gz", - .hash = "12205e05d4db71ef30aeb3517727382c12d294968e541090a762689acbb9038826a1", + .url = "https://codeberg.org/ifreund/zig-wayland/archive/fbfe3b4ac0b472a27b1f1a67405436c58cbee12d.tar.gz", + .hash = "12209ca054cb1919fa276e328967f10b253f7537c4136eb48f3332b0f7cf661cad38", }, .zf = .{ .url = "git+https://github.com/natecraddock/zf/?ref=main#ed99ca18b02dda052e20ba467e90b623c04690dd", diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 48270c6e8..db909a936 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-MeSJiiSDDWZ7vUgY56t9aPSLPTgIKb4jexoHmDhJOGM=" +"sha256-Nx1tOhDnEZ7LVi/pKxYS3sg/Sf8TAUXDmST6EtBgDoQ=" From 010f4d167dc0f53d96fbf81b8b3c03c55d5d7ce0 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 8 Jan 2025 22:53:25 -0600 Subject: [PATCH 120/238] GTK: refactor headerbar into separate Adwaita & GTK structs --- src/apprt/gtk/Window.zig | 127 +++++++++++++------------------- src/apprt/gtk/headerbar.zig | 75 +++++-------------- src/apprt/gtk/headerbar_adw.zig | 77 +++++++++++++++++++ src/apprt/gtk/headerbar_gtk.zig | 52 +++++++++++++ 4 files changed, 201 insertions(+), 130 deletions(-) create mode 100644 src/apprt/gtk/headerbar_adw.zig create mode 100644 src/apprt/gtk/headerbar_gtk.zig diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 0f44cee7b..86640695f 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -37,7 +37,7 @@ window: *c.GtkWindow, /// The header bar for the window. This is possibly null since it can be /// disabled using gtk-titlebar. This is either an AdwHeaderBar or /// GtkHeaderBar depending on if adw is enabled and linked. -header: ?HeaderBar, +headerbar: HeaderBar, /// The tab overview for the window. This is possibly null since there is no /// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0). @@ -77,7 +77,7 @@ pub fn init(self: *Window, app: *App) !void { self.* = .{ .app = app, .window = undefined, - .header = null, + .headerbar = undefined, .tab_overview = null, .notebook = undefined, .context_menu = undefined, @@ -150,64 +150,56 @@ pub fn init(self: *Window, app: *App) !void { 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. - if (app.config.@"gtk-titlebar") { - const header = HeaderBar.init(self); + // 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(); - // If we are not decorated then we hide the titlebar. - header.setVisible(app.config.@"window-decoration"); + { + const btn = c.gtk_menu_button_new(); + c.gtk_widget_set_tooltip_text(btn, "Main Menu"); + c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic"); + c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu))); + self.headerbar.packEnd(btn); + } - { - const btn = c.gtk_menu_button_new(); - c.gtk_widget_set_tooltip_text(btn, "Main Menu"); - c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic"); - c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu))); - header.packEnd(btn); - } + // If we're using an AdwWindow then we can support the tab overview. + if (self.tab_overview) |tab_overview| { + if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; + assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0)); + const btn = switch (app.config.@"gtk-tabs-location") { + .top, .bottom, .left, .right => btn: { + const btn = c.gtk_toggle_button_new(); + c.gtk_widget_set_tooltip_text(btn, "View Open Tabs"); + c.gtk_button_set_icon_name(@ptrCast(btn), "view-grid-symbolic"); + _ = c.g_object_bind_property( + btn, + "active", + tab_overview, + "open", + c.G_BINDING_BIDIRECTIONAL | c.G_BINDING_SYNC_CREATE, + ); - // If we're using an AdwWindow then we can support the tab overview. - if (self.tab_overview) |tab_overview| { - if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; - assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0)); - const btn = switch (app.config.@"gtk-tabs-location") { - .top, .bottom, .left, .right => btn: { - const btn = c.gtk_toggle_button_new(); - c.gtk_widget_set_tooltip_text(btn, "View Open Tabs"); - c.gtk_button_set_icon_name(@ptrCast(btn), "view-grid-symbolic"); - _ = c.g_object_bind_property( - btn, - "active", - tab_overview, - "open", - c.G_BINDING_BIDIRECTIONAL | c.G_BINDING_SYNC_CREATE, - ); + break :btn btn; + }, - break :btn btn; - }, + .hidden => btn: { + const btn = c.adw_tab_button_new(); + c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw.tab_view); + c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open"); + break :btn btn; + }, + }; - .hidden => btn: { - const btn = c.adw_tab_button_new(); - c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw.tab_view); - c.gtk_actionable_set_action_name(@ptrCast(btn), "overview.open"); - break :btn btn; - }, - }; + c.gtk_widget_set_focus_on_click(btn, c.FALSE); + self.headerbar.packEnd(btn); + } - c.gtk_widget_set_focus_on_click(btn, c.FALSE); - header.packEnd(btn); - } - - { - const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic"); - c.gtk_widget_set_tooltip_text(btn, "New Tab"); - _ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(>kTabNewClick), self, null, c.G_CONNECT_DEFAULT); - header.packStart(btn); - } - - self.header = header; + { + const btn = c.gtk_button_new_from_icon_name("tab-new-symbolic"); + c.gtk_widget_set_tooltip_text(btn, "New Tab"); + _ = c.g_signal_connect_data(btn, "clicked", c.G_CALLBACK(>kTabNewClick), self, null, c.G_CONNECT_DEFAULT); + self.headerbar.packStart(btn); } _ = c.g_signal_connect_data(gtk_window, "notify::decorated", c.G_CALLBACK(>kWindowNotifyDecorated), self, null, c.G_CONNECT_DEFAULT); @@ -220,9 +212,7 @@ pub fn init(self: *Window, app: *App) !void { // 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 (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { - if (self.header) |h| { - c.gtk_box_append(@ptrCast(box), h.asWidget()); - } + c.gtk_box_append(@ptrCast(box), self.headerbar.asWidget()); } // In debug we show a warning and apply the 'devel' class to the window. @@ -297,10 +287,7 @@ pub fn init(self: *Window, app: *App) !void { if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new()); - if (self.header) |header| { - const header_widget = header.asWidget(); - c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); - } + c.adw_toolbar_view_add_top_bar(toolbar_view, self.headerbar.asWidget()); if (self.app.config.@"gtk-tabs-location" != .hidden) { const tab_bar = c.adw_tab_bar_new(); @@ -373,10 +360,8 @@ pub fn init(self: *Window, app: *App) !void { box, ); } else { + c.gtk_window_set_titlebar(gtk_window, self.headerbar.asWidget()); c.gtk_window_set_child(gtk_window, box); - if (self.header) |h| { - c.gtk_window_set_titlebar(gtk_window, h.asWidget()); - } } } @@ -452,18 +437,12 @@ pub fn deinit(self: *Window) void { /// Set the title of the window. pub fn setTitle(self: *Window, title: [:0]const u8) void { - if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config) and self.app.config.@"gtk-titlebar") { - if (self.header) |header| header.setTitle(title); - } else { - c.gtk_window_set_title(self.window, title); - } + self.headerbar.setTitle(title); } /// Set the subtitle of the window if it has one. pub fn setSubtitle(self: *Window, subtitle: [:0]const u8) void { - if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config) and self.app.config.@"gtk-titlebar") { - if (self.header) |header| header.setSubtitle(subtitle); - } + self.headerbar.setSubtitle(subtitle); } /// Add a new tab to this window. @@ -556,9 +535,7 @@ pub fn toggleWindowDecorations(self: *Window) void { // decorated state. GTK tends to consider the titlebar part of the frame // and hides it with decorations, but libadwaita doesn't. This makes it // explicit. - if (self.header) |headerbar| { - headerbar.setVisible(new_decorated); - } + self.headerbar.setVisible(new_decorated); } /// Grabs focus on the currently selected tab. diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig index 97c48a4c2..2b47ea4b7 100644 --- a/src/apprt/gtk/headerbar.zig +++ b/src/apprt/gtk/headerbar.zig @@ -4,93 +4,58 @@ const c = @import("c.zig").c; const Window = @import("Window.zig"); const adwaita = @import("adwaita.zig"); -const AdwHeaderBar = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwHeaderBar else void; +const HeaderBarAdw = @import("headerbar_adw.zig"); +const HeaderBarGtk = @import("headerbar_gtk.zig"); pub const HeaderBar = union(enum) { - adw: *AdwHeaderBar, - gtk: *c.GtkHeaderBar, + adw: HeaderBarAdw, + gtk: HeaderBarGtk, - pub fn init(window: *Window) HeaderBar { - if ((comptime adwaita.versionAtLeast(1, 4, 0)) and - adwaita.enabled(&window.app.config)) - { - return initAdw(window); + pub fn init(self: *HeaderBar) void { + const window: *Window = @fieldParentPtr("headerbar", self); + if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&window.app.config)) { + HeaderBarAdw.init(self); + } else { + HeaderBarGtk.init(self); } - return initGtk(); - } - - fn initAdw(window: *Window) HeaderBar { - const headerbar = c.adw_header_bar_new(); - c.adw_header_bar_set_title_widget(@ptrCast(headerbar), @ptrCast(c.adw_window_title_new(c.gtk_window_get_title(window.window) orelse "Ghostty", null))); - return .{ .adw = @ptrCast(headerbar) }; - } - - fn initGtk() HeaderBar { - const headerbar = c.gtk_header_bar_new(); - return .{ .gtk = @ptrCast(headerbar) }; + if (!window.app.config.@"gtk-titlebar" or !window.app.config.@"window-decoration") + self.setVisible(false); } pub fn setVisible(self: HeaderBar, visible: bool) void { - c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); + switch (self) { + inline else => |v| v.setVisible(visible), + } } pub fn asWidget(self: HeaderBar) *c.GtkWidget { return switch (self) { - .adw => |headerbar| @ptrCast(@alignCast(headerbar)), - .gtk => |headerbar| @ptrCast(@alignCast(headerbar)), + inline else => |v| v.asWidget(), }; } pub fn packEnd(self: HeaderBar, widget: *c.GtkWidget) void { switch (self) { - .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { - c.adw_header_bar_pack_end( - @ptrCast(@alignCast(headerbar)), - widget, - ); - }, - .gtk => |headerbar| c.gtk_header_bar_pack_end( - @ptrCast(@alignCast(headerbar)), - widget, - ), + inline else => |v| v.packEnd(widget), } } pub fn packStart(self: HeaderBar, widget: *c.GtkWidget) void { switch (self) { - .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { - c.adw_header_bar_pack_start( - @ptrCast(@alignCast(headerbar)), - widget, - ); - }, - .gtk => |headerbar| c.gtk_header_bar_pack_start( - @ptrCast(@alignCast(headerbar)), - widget, - ), + inline else => |v| v.packStart(widget), } } pub fn setTitle(self: HeaderBar, title: [:0]const u8) void { switch (self) { - .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { - const window_title: *c.AdwWindowTitle = @ptrCast(c.adw_header_bar_get_title_widget(@ptrCast(headerbar))); - c.adw_window_title_set_title(window_title, title); - }, - // The title is owned by the window when not using Adwaita - .gtk => unreachable, + inline else => |v| v.setTitle(title), } } pub fn setSubtitle(self: HeaderBar, subtitle: [:0]const u8) void { switch (self) { - .adw => |headerbar| if (comptime adwaita.versionAtLeast(0, 0, 0)) { - const window_title: *c.AdwWindowTitle = @ptrCast(c.adw_header_bar_get_title_widget(@ptrCast(headerbar))); - c.adw_window_title_set_subtitle(window_title, subtitle); - }, - // There is no subtitle unless Adwaita is used - .gtk => unreachable, + inline else => |v| v.setSubtitle(subtitle), } } }; diff --git a/src/apprt/gtk/headerbar_adw.zig b/src/apprt/gtk/headerbar_adw.zig new file mode 100644 index 000000000..c0d622207 --- /dev/null +++ b/src/apprt/gtk/headerbar_adw.zig @@ -0,0 +1,77 @@ +const HeaderBarAdw = @This(); + +const std = @import("std"); +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const adwaita = @import("adwaita.zig"); + +const HeaderBar = @import("headerbar.zig").HeaderBar; + +const AdwHeaderBar = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwHeaderBar else anyopaque; +const AdwWindowTitle = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwWindowTitle else anyopaque; + +/// the window that this headerbar is attached to +window: *Window, +/// the Adwaita headerbar widget +headerbar: *AdwHeaderBar, +/// the Adwaita window title widget +title: *AdwWindowTitle, + +pub fn init(headerbar: *HeaderBar) void { + if (!adwaita.versionAtLeast(0, 0, 0)) return; + + const window: *Window = @fieldParentPtr("headerbar", headerbar); + headerbar.* = .{ + .adw = .{ + .window = window, + .headerbar = @ptrCast(@alignCast(c.adw_header_bar_new())), + .title = @ptrCast(@alignCast(c.adw_window_title_new( + c.gtk_window_get_title(window.window) orelse "Ghostty", + null, + ))), + }, + }; + c.adw_header_bar_set_title_widget( + headerbar.adw.headerbar, + @ptrCast(@alignCast(headerbar.adw.title)), + ); +} + +pub fn setVisible(self: HeaderBarAdw, visible: bool) void { + c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); +} + +pub fn asWidget(self: HeaderBarAdw) *c.GtkWidget { + return @ptrCast(@alignCast(self.headerbar)); +} + +pub fn packEnd(self: HeaderBarAdw, widget: *c.GtkWidget) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_header_bar_pack_end( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); + } +} + +pub fn packStart(self: HeaderBarAdw, widget: *c.GtkWidget) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_header_bar_pack_start( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); + } +} + +pub fn setTitle(self: HeaderBarAdw, title: [:0]const u8) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_window_title_set_title(self.title, title); + } +} + +pub fn setSubtitle(self: HeaderBarAdw, subtitle: [:0]const u8) void { + if (comptime adwaita.versionAtLeast(0, 0, 0)) { + c.adw_window_title_set_subtitle(self.title, subtitle); + } +} diff --git a/src/apprt/gtk/headerbar_gtk.zig b/src/apprt/gtk/headerbar_gtk.zig new file mode 100644 index 000000000..63ba8b389 --- /dev/null +++ b/src/apprt/gtk/headerbar_gtk.zig @@ -0,0 +1,52 @@ +const HeaderBarGtk = @This(); + +const std = @import("std"); +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const adwaita = @import("adwaita.zig"); + +const HeaderBar = @import("headerbar.zig").HeaderBar; + +/// the window that this headarbar is attached to +window: *Window, +/// the GTK headerbar widget +headerbar: *c.GtkHeaderBar, + +pub fn init(headerbar: *HeaderBar) void { + const window: *Window = @fieldParentPtr("headerbar", headerbar); + headerbar.* = .{ + .gtk = .{ + .window = window, + .headerbar = @ptrCast(c.gtk_header_bar_new()), + }, + }; +} + +pub fn setVisible(self: HeaderBarGtk, visible: bool) void { + c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); +} + +pub fn asWidget(self: HeaderBarGtk) *c.GtkWidget { + return @ptrCast(@alignCast(self.headerbar)); +} + +pub fn packEnd(self: HeaderBarGtk, widget: *c.GtkWidget) void { + c.gtk_header_bar_pack_end( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); +} + +pub fn packStart(self: HeaderBarGtk, widget: *c.GtkWidget) void { + c.gtk_header_bar_pack_start( + @ptrCast(@alignCast(self.headerbar)), + widget, + ); +} + +pub fn setTitle(self: HeaderBarGtk, title: [:0]const u8) void { + c.gtk_window_set_title(self.window.window, title); +} + +pub fn setSubtitle(_: HeaderBarGtk, _: [:0]const u8) void {} From d26c114b5d013d311929be177f97d2ce46617580 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 12:10:26 -0800 Subject: [PATCH 121/238] apprt/gtk: make sure noop winproto never initializes --- src/apprt/gtk/App.zig | 1 + src/apprt/gtk/winproto.zig | 2 +- src/apprt/gtk/winproto/noop.zig | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index b041d29fb..6fa98a011 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -373,6 +373,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { &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 diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index 49d96bb02..cb873fe01 100644 --- a/src/apprt/gtk/winproto.zig +++ b/src/apprt/gtk/winproto.zig @@ -40,7 +40,7 @@ pub const App = union(Protocol) { } } - return .none; + return .{ .none = .{} }; } pub fn deinit(self: *App, alloc: Allocator) void { diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig index 54c14fe14..14f3dc6a7 100644 --- a/src/apprt/gtk/winproto/noop.zig +++ b/src/apprt/gtk/winproto/noop.zig @@ -13,7 +13,7 @@ pub const App = struct { _: [:0]const u8, _: *const Config, ) !?App { - return .{}; + return null; } pub fn deinit(self: *App, alloc: Allocator) void { From 2fb0d99f00a832430e98d55e1b3198a51ca6c9da Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 12:56:17 -0800 Subject: [PATCH 122/238] ci: add required checks jobs This is a hack to make it easier for our GitHub branching rules to require a single check to pass before merging. This lets us describe the required checks in code rather than via the GH UI. --- .github/workflows/nix.yml | 9 +++++++++ .github/workflows/test.yml | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d5ee328e5..d557fcebd 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -1,6 +1,15 @@ on: [push, pull_request] name: Nix jobs: + required: + name: Required Checks + runs-on: namespace-profile-ghostty-sm + needs: + - check-zig-cache-hash + steps: + - name: Noop + run: echo "Required Checks Met" + check-zig-cache-hash: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-sm diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e021af64..150901cb6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,29 @@ on: name: Test jobs: + required: + name: Required Checks + runs-on: namespace-profile-ghostty-sm + needs: + - build + - build-bench + - build-linux-libghostty + - build-nix + - build-macos + - build-macos-matrix + - build-windows + - test + - test-gtk + - test-sentry-linux + - test-macos + - prettier + - alejandra + - typos + - test-pkg-linux + steps: + - name: Noop + run: echo "Required Checks Met" + build: strategy: fail-fast: false From 13e96c7ec86718dcec2066cf60e7ecbf7011ec17 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Jan 2025 18:49:24 -0600 Subject: [PATCH 123/238] gtk: add config option to disable GTK OpenGL debug logging --- src/apprt/gtk/App.zig | 125 ++++++++++++++++++++++++++++++------------ src/config/Config.zig | 9 +++ 2 files changed, 98 insertions(+), 36 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 6fa98a011..ba01236cc 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -104,42 +104,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { c.gtk_get_micro_version(), }); - // Disabling Vulkan can improve startup times by hundreds of - // milliseconds on some systems. We don't use Vulkan so we can just - // disable it. - if (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. - _ = internal_os.setenv("GDK_DISABLE", "gles-api,vulkan"); - _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-no-fractional"); - } else if (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 - // - // Specific details about values: - // - "opengl" - output OpenGL debug information - // - "gl-disable-gles" - disable GLES, Ghostty can't use GLES - // - "vulkan-disable" - disable Vulkan, Ghostty can't use Vulkan - // and initializing a Vulkan context was causing a longer delay - // on some systems. - _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-disable-gles,vulkan-disable,gl-no-fractional"); - } else { - // 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. - _ = internal_os.setenv("GDK_DEBUG", "vulkan-disable"); - } - - if (version.runtimeAtLeast(4, 14, 0)) { - // We need to export GSK_RENDERER to opengl because GTK uses ngl by - // default after 4.14 - _ = internal_os.setenv("GSK_RENDERER", "opengl"); - } - // Load our configuration var config = try Config.load(core_app.alloc); errdefer config.deinit(); @@ -161,6 +125,95 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } } + var gdk_debug: struct { + /// output OpenGL debug information + opengl: bool = false, + /// disable GLES, Ghostty can't use GLES + @"gl-disable-gles": bool = false, + @"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, + /// 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 (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; + gdk_debug.@"gl-no-fractional" = true; + break :environment; + } + if (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.@"gl-no-fractional" = true; + gdk_debug.@"vulkan-disable" = 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); + log.warn("setting GDK_DEBUG={s}", .{fmt.getWritten()}); + _ = internal_os.setenv("GDK_DEBUG", buf[0 .. fmt.pos - 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); + log.warn("setting GDK_DISABLE={s}", .{fmt.getWritten()}); + _ = internal_os.setenv("GDK_DISABLE", buf[0 .. fmt.pos - 1 :0]); + } + + if (version.runtimeAtLeast(4, 14, 0)) { + // We need to export GSK_RENDERER to opengl because GTK uses ngl by + // default after 4.14 + _ = internal_os.setenv("GSK_RENDERER", "opengl"); + } + c.gtk_init(); const display: *c.GdkDisplay = c.gdk_display_get_default() orelse { // I'm unsure of any scenario where this happens. Because we don't diff --git a/src/config/Config.zig b/src/config/Config.zig index 1de0ddaad..6c5d0b1e2 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1975,6 +1975,15 @@ keybind: Keybinds = .{}, /// must always be able to move themselves into an isolated cgroup. @"linux-cgroup-hard-fail": bool = false, +/// Enable or disable GTK's OpenGL debugging logs. The default depends on the +/// optimization level that Ghostty was built with: +/// +/// - `Debug`: `true` +/// - `ReleaseSafe`: `true` +/// - `ReleaseSmall`: `true` +/// - `ReleaseFast`: `false` +@"gtk-opengl-debug": bool = build_config.slow_runtime_safety, + /// If `true`, the Ghostty GTK application will run in single-instance mode: /// each new `ghostty` process launched will result in a new window if there is /// already a running process. From 06a57842af1c3c71d6103e63fe4321eda1c6a556 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Jan 2025 19:27:53 -0600 Subject: [PATCH 124/238] gtk: add config option to control GSK_RENDERER env var --- src/apprt/gtk/App.zig | 13 ++++++++++--- src/config/Config.zig | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index ba01236cc..10b8f756a 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -209,9 +209,16 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } if (version.runtimeAtLeast(4, 14, 0)) { - // We need to export GSK_RENDERER to opengl because GTK uses ngl by - // default after 4.14 - _ = internal_os.setenv("GSK_RENDERER", "opengl"); + switch (config.@"gtk-gsk-renderer") { + .default => {}, + else => |renderer| { + // Force the GSK renderer to a specific value. After GTK 4.14 the + // `ngl` renderer is used by default which causes artifacts when + // used with Ghostty so it should be avoided. + log.warn("setting GSK_RENDERER={s}", .{@tagName(renderer)}); + _ = internal_os.setenv("GSK_RENDERER", @tagName(renderer)); + }, + } } c.gtk_init(); diff --git a/src/config/Config.zig b/src/config/Config.zig index 6c5d0b1e2..6c10213e8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1984,6 +1984,14 @@ keybind: Keybinds = .{}, /// - `ReleaseFast`: `false` @"gtk-opengl-debug": bool = build_config.slow_runtime_safety, +/// After GTK 4.14.0, we need to force the GSK renderer to OpenGL as the default +/// GSK renderer is broken on some systems. If you would like to override +/// that bekavior, set `gtk-gsk-renderer=default` and either use your system's +/// default GSK renderer, or set the GSK_RENDERER environment variable to your +/// renderer of choice before launching Ghostty. This setting has no effect when +/// using versions of GTK earlier than 4.14.0. +@"gtk-gsk-renderer": GtkGskRenderer = .opengl, + /// If `true`, the Ghostty GTK application will run in single-instance mode: /// each new `ghostty` process launched will result in a new window if there is /// already a running process. @@ -6167,6 +6175,12 @@ pub const WindowPadding = struct { } }; +/// See the `gtk-gsk-renderer` config. +pub const GtkGskRenderer = enum { + default, + opengl, +}; + test "parse duration" { inline for (Duration.units) |unit| { var buf: [16]u8 = undefined; From cd638588c4e8b0dc9420878aa348e7f19a4995c6 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 8 Jan 2025 08:34:47 -0600 Subject: [PATCH 125/238] gtk: better method for setting GDK env vars --- src/apprt/gtk/App.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 10b8f756a..70fc182e5 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -187,8 +187,9 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } } try writer.writeByte(0); - log.warn("setting GDK_DEBUG={s}", .{fmt.getWritten()}); - _ = internal_os.setenv("GDK_DEBUG", buf[0 .. fmt.pos - 1 :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]); } { @@ -204,8 +205,9 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { } } try writer.writeByte(0); - log.warn("setting GDK_DISABLE={s}", .{fmt.getWritten()}); - _ = internal_os.setenv("GDK_DISABLE", buf[0 .. fmt.pos - 1 :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]); } if (version.runtimeAtLeast(4, 14, 0)) { From 6237377a59df054657105530271d450bf29523a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 13:22:29 -0800 Subject: [PATCH 126/238] ci: avoid "successful failure" of status check job by inspecting needs Thanks to @ryanec for this tip. --- .github/workflows/nix.yml | 20 ++++++++++++++++++-- .github/workflows/test.yml | 20 ++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d557fcebd..ced1df6df 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -7,8 +7,24 @@ jobs: needs: - check-zig-cache-hash steps: - - name: Noop - run: echo "Required Checks Met" + - id: status + name: Determine status + run: | + results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}') + if ! grep -q -v -E '(failure|cancelled)' <<< "$results"; then + result="failed" + else + result="success" + fi + { + echo "result=${result}" + echo "results=${results}" + } | tee -a "$GITHUB_OUTPUT" + - if: always() && steps.status.outputs.result != 'success' + name: Check for failed status + run: | + echo "One or more required build workflows failed: ${{ steps.status.outputs.results }}" + exit 1 check-zig-cache-hash: if: github.repository == 'ghostty-org/ghostty' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 150901cb6..1436339f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,8 +26,24 @@ jobs: - typos - test-pkg-linux steps: - - name: Noop - run: echo "Required Checks Met" + - id: status + name: Determine status + run: | + results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}') + if ! grep -q -v -E '(failure|cancelled)' <<< "$results"; then + result="failed" + else + result="success" + fi + { + echo "result=${result}" + echo "results=${results}" + } | tee -a "$GITHUB_OUTPUT" + - if: always() && steps.status.outputs.result != 'success' + name: Check for failed status + run: | + echo "One or more required build workflows failed: ${{ steps.status.outputs.results }}" + exit 1 build: strategy: From f5add68100b45880105db8126868a8482c83d8df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 13:31:21 -0800 Subject: [PATCH 127/238] ci: required checks must be named separately --- .github/workflows/nix.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ced1df6df..3339ee71c 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -2,7 +2,7 @@ on: [push, pull_request] name: Nix jobs: required: - name: Required Checks + name: "Required Checks: Nix" runs-on: namespace-profile-ghostty-sm needs: - check-zig-cache-hash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1436339f1..0f32162a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ name: Test jobs: required: - name: Required Checks + name: "Required Checks: Test" runs-on: namespace-profile-ghostty-sm needs: - build From 96e427cd6a86b765ba411135789319f3b8501902 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 10 Jan 2025 15:48:20 -0600 Subject: [PATCH 128/238] gtk: default to opengl debugging only on debug builds --- src/config/Config.zig | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6c10213e8..144796554 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1975,14 +1975,9 @@ keybind: Keybinds = .{}, /// must always be able to move themselves into an isolated cgroup. @"linux-cgroup-hard-fail": bool = false, -/// Enable or disable GTK's OpenGL debugging logs. The default depends on the -/// optimization level that Ghostty was built with: -/// -/// - `Debug`: `true` -/// - `ReleaseSafe`: `true` -/// - `ReleaseSmall`: `true` -/// - `ReleaseFast`: `false` -@"gtk-opengl-debug": bool = build_config.slow_runtime_safety, +/// Enable or disable GTK's OpenGL debugging logs. The default is `true` for +/// debug builds, `false` for all others. +@"gtk-opengl-debug": bool = builtin.mode == .Debug, /// After GTK 4.14.0, we need to force the GSK renderer to OpenGL as the default /// GSK renderer is broken on some systems. If you would like to override From 4dd9fe5cfd53bd60760ce74225c2065625594a89 Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Tue, 7 Jan 2025 22:54:02 +0100 Subject: [PATCH 129/238] fix: ensure terminal tabs are reconstructed in main window after toggling visibility --- macos/Sources/App/macOS/AppDelegate.swift | 38 +++++++++++++++---- .../Features/Terminal/TerminalManager.swift | 2 +- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a102beb91..eb9734f6c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -706,20 +706,42 @@ class AppDelegate: NSObject, /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application @IBAction func toggleVisibility(_ sender: Any) { - // We only care about terminal windows. - for window in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { - if isVisible { - window.orderOut(nil) - } else { - window.makeKeyAndOrderFront(nil) + if let mainWindow = terminalManager.mainWindow { + guard let parent = mainWindow.controller.window else { + Self.logger.debug("could not get parent window") + return + } + + guard let controller = parent.windowController as? TerminalController, + let primaryWindow = controller.window else { + Self.logger.debug("Could not retrieve primary window") + return + } + + // Fetch all terminal windows controlled by BaseTerminalController + for terminalWindow in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { + if isVisible { + terminalWindow.orderOut(nil) + } else { + primaryWindow.makeKeyAndOrderFront(nil) + primaryWindow.addTabbedWindow(terminalWindow, ordered: .above) + } + } + + // If our parent tab group already has this window, macOS added it and + // we need to remove it so we can set the correct order in the next line. + // If we don't do this, macOS gets really confused and the tabbedWindows + // state becomes incorrect. + if let tg = parent.tabGroup, tg.windows.firstIndex(of: parent) != nil { + tg.removeWindow(parent) } } - + // After bringing them all to front we make sure our app is active too. if !isVisible { NSApp.activate(ignoringOtherApps: true) } - + isVisible.toggle() } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 42e35b90e..82a5978c7 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -26,7 +26,7 @@ class TerminalManager { /// Returns the main window of the managed window stack. If there is no window /// then an arbitrary window will be chosen. - private var mainWindow: Window? { + var mainWindow: Window? { for window in windows { if (window.controller.window?.isMainWindow ?? false) { return window From 3a5aecc216290cb0f5a50da9808d34bb9ae5bec5 Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Thu, 9 Jan 2025 23:14:00 +0100 Subject: [PATCH 130/238] fix: hide windows without calling orderOut API --- macos/Sources/App/macOS/AppDelegate.swift | 33 +++++------------------ 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index eb9734f6c..776ada63e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -706,34 +706,13 @@ class AppDelegate: NSObject, /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application @IBAction func toggleVisibility(_ sender: Any) { - if let mainWindow = terminalManager.mainWindow { - guard let parent = mainWindow.controller.window else { - Self.logger.debug("could not get parent window") - return + if isVisible { + NSApp.windows.forEach { window in + window.alphaValue = 0.0 } - - guard let controller = parent.windowController as? TerminalController, - let primaryWindow = controller.window else { - Self.logger.debug("Could not retrieve primary window") - return - } - - // Fetch all terminal windows controlled by BaseTerminalController - for terminalWindow in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { - if isVisible { - terminalWindow.orderOut(nil) - } else { - primaryWindow.makeKeyAndOrderFront(nil) - primaryWindow.addTabbedWindow(terminalWindow, ordered: .above) - } - } - - // If our parent tab group already has this window, macOS added it and - // we need to remove it so we can set the correct order in the next line. - // If we don't do this, macOS gets really confused and the tabbedWindows - // state becomes incorrect. - if let tg = parent.tabGroup, tg.windows.firstIndex(of: parent) != nil { - tg.removeWindow(parent) + } else { + NSApp.windows.forEach { window in + window.alphaValue = 1.0 } } From 61a78efa83d176c2a81e590425f52f962125c34f Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Thu, 9 Jan 2025 23:15:06 +0100 Subject: [PATCH 131/238] chore: revert on TerminalManager changes --- macos/Sources/Features/Terminal/TerminalManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 82a5978c7..42e35b90e 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -26,7 +26,7 @@ class TerminalManager { /// Returns the main window of the managed window stack. If there is no window /// then an arbitrary window will be chosen. - var mainWindow: Window? { + private var mainWindow: Window? { for window in windows { if (window.controller.window?.isMainWindow ?? false) { return window From 200aee9acf0a4b4ec4d4f57cde12443cef257448 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 14:35:43 -0800 Subject: [PATCH 132/238] macos: rework toggle_visibility to better match iTerm2 Two major changes: 1. Hiding uses `NSApp.hide` which hides all windows, preserves tabs, and yields focus to the next app. 2. Unhiding manually tracks and brings forward only the windows we hid. Proper focus should be retained. --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ macos/Sources/App/macOS/AppDelegate.swift | 58 ++++++++++++----------- macos/Sources/Helpers/Weak.swift | 9 ++++ src/input/Binding.zig | 6 +-- 4 files changed, 47 insertions(+), 30 deletions(-) create mode 100644 macos/Sources/Helpers/Weak.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 3fa67c48a..efa4a07c9 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; + A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; @@ -167,6 +168,7 @@ A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; + A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = ""; }; @@ -282,6 +284,7 @@ AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */, + A5CA378D2D31D6C100931030 /* Weak.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, A5CEAFDA29B8005900646FDA /* SplitView */, @@ -647,6 +650,7 @@ A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, + A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 776ada63e..4b11b68aa 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -92,10 +92,8 @@ class AppDelegate: NSObject, return ProcessInfo.processInfo.systemUptime - applicationLaunchTime } - /// Tracks whether the application is currently visible. This can be gamed, i.e. if a user manually - /// brings each window one by one to the front. But at worst its off by one set of toggles and this - /// makes our logic very easy. - private var isVisible: Bool = true + /// Tracks the windows that we hid for toggleVisibility. + private var hiddenWindows: [Weak] = [] /// The observer for the app appearance. private var appearanceObserver: NSKeyValueObservation? = nil @@ -219,15 +217,20 @@ class AppDelegate: NSObject, } func applicationDidBecomeActive(_ notification: Notification) { - guard !applicationHasBecomeActive else { return } - applicationHasBecomeActive = true + // If we're back then clear the hidden windows + self.hiddenWindows = [] - // Let's launch our first window. We only do this if we have no other windows. It - // is possible to have other windows in a few scenarios: - // - if we're opening a URL since `application(_:openFile:)` is called before this. - // - if we're restoring from persisted state - if terminalManager.windows.count == 0 && derivedConfig.initialWindow { - terminalManager.newWindow() + // First launch stuff + if (!applicationHasBecomeActive) { + applicationHasBecomeActive = true + + // Let's launch our first window. We only do this if we have no other windows. It + // is possible to have other windows in a few scenarios: + // - if we're opening a URL since `application(_:openFile:)` is called before this. + // - if we're restoring from persisted state + if terminalManager.windows.count == 0 && derivedConfig.initialWindow { + terminalManager.newWindow() + } } } @@ -706,22 +709,23 @@ class AppDelegate: NSObject, /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application @IBAction func toggleVisibility(_ sender: Any) { - if isVisible { - NSApp.windows.forEach { window in - window.alphaValue = 0.0 - } - } else { - NSApp.windows.forEach { window in - window.alphaValue = 1.0 - } + // If we have focus, then we hide all windows. + if NSApp.isActive { + // We need to keep track of the windows that were visible because we only + // want to bring back these windows if we remove the toggle. + self.hiddenWindows = NSApp.windows.filter { $0.isVisible }.map { Weak($0) } + NSApp.hide(nil) + return } - - // After bringing them all to front we make sure our app is active too. - if !isVisible { - NSApp.activate(ignoringOtherApps: true) - } - - isVisible.toggle() + + // If we're not active, we want to become active + NSApp.activate(ignoringOtherApps: true) + + // Bring all windows to the front. Note: we don't use NSApp.unhide because + // that will unhide ALL hidden windows. We want to only bring forward the + // ones that we hid. + self.hiddenWindows.forEach { $0.value?.orderFrontRegardless() } + self.hiddenWindows = [] } private struct DerivedConfig { diff --git a/macos/Sources/Helpers/Weak.swift b/macos/Sources/Helpers/Weak.swift new file mode 100644 index 000000000..d5f784844 --- /dev/null +++ b/macos/Sources/Helpers/Weak.swift @@ -0,0 +1,9 @@ +/// A wrapper that holds a weak reference to an object. This lets us create native containers +/// of weak references. +class Weak { + weak var value: T? + + init(_ value: T) { + self.value = value + } +} diff --git a/src/input/Binding.zig b/src/input/Binding.zig index c5faaad06..2fdbc4cba 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -441,10 +441,10 @@ pub const Action = union(enum) { toggle_quick_terminal: void, /// Show/hide all windows. If all windows become shown, we also ensure - /// Ghostty is focused. + /// Ghostty becomes focused. When hiding all windows, focus is yielded + /// to the next application as determined by the OS. /// - /// This currently only works on macOS. When hiding all windows, we do - /// not yield focus to the previous application. + /// This currently only works on macOS. toggle_visibility: void, /// Quit ghostty. From b7b5b9bbf5f570f4669c92e61322469a825d0b33 Mon Sep 17 00:00:00 2001 From: Leigh Oliver Date: Tue, 31 Dec 2024 23:40:49 +0000 Subject: [PATCH 133/238] fix(gtk): add close confirmation for tabs --- src/apprt/gtk/Tab.zig | 67 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index ed0804fd3..1a3b44136 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -121,10 +121,71 @@ pub fn remove(self: *Tab) void { self.window.closeTab(self); } +/// Helper function to check if any surface in the split hierarchy needs close confirmation +const needsConfirm = struct { + fn check(elem: Surface.Container.Elem) bool { + return switch (elem) { + .surface => |s| s.core_surface.needsConfirmQuit(), + .split => |s| check(s.top_left) or check(s.bottom_right), + }; + } +}.check; + +/// Close the tab, asking for confirmation if any surface requests it. +fn closeWithConfirmation(tab: *Tab) void { + switch (tab.elem) { + .surface => |s| s.close(s.core_surface.needsConfirmQuit()), + .split => |s| { + if (needsConfirm(s.top_left) or needsConfirm(s.bottom_right)) { + const alert = c.gtk_message_dialog_new( + tab.window.window, + c.GTK_DIALOG_MODAL, + c.GTK_MESSAGE_QUESTION, + c.GTK_BUTTONS_YES_NO, + "Close this tab?", + ); + c.gtk_message_dialog_format_secondary_text( + @ptrCast(alert), + "All terminal sessions in this tab will be terminated.", + ); + + // We want the "yes" to appear destructive. + const yes_widget = c.gtk_dialog_get_widget_for_response( + @ptrCast(alert), + c.GTK_RESPONSE_YES, + ); + c.gtk_widget_add_css_class(yes_widget, "destructive-action"); + + // We want the "no" to be the default action + c.gtk_dialog_set_default_response( + @ptrCast(alert), + c.GTK_RESPONSE_NO, + ); + + _ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kTabCloseConfirmation), tab, null, c.G_CONNECT_DEFAULT); + c.gtk_widget_show(alert); + return; + } + tab.remove(); + }, + } +} + pub fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { const tab: *Tab = @ptrCast(@alignCast(ud)); - const window = tab.window; - window.closeTab(tab); + tab.closeWithConfirmation(); +} + +fn gtkTabCloseConfirmation( + alert: *c.GtkMessageDialog, + response: c.gint, + ud: ?*anyopaque, +) callconv(.C) void { + c.gtk_window_destroy(@ptrCast(alert)); + if (response == c.GTK_RESPONSE_YES) { + const tab: *Tab = @ptrCast(@alignCast(ud)); + tab.remove(); + } } fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { @@ -146,6 +207,6 @@ pub fn gtkTabClick( const self: *Tab = @ptrCast(@alignCast(ud)); const gtk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture)); if (gtk_button == c.GDK_BUTTON_MIDDLE) { - self.remove(); + self.closeWithConfirmation(); } } From 8c1ad59de761153023fba73913bb168df91c55d5 Mon Sep 17 00:00:00 2001 From: Leigh Oliver Date: Wed, 1 Jan 2025 10:14:16 +0000 Subject: [PATCH 134/238] remove unnecessary struct --- src/apprt/gtk/Tab.zig | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index 1a3b44136..6e28b8644 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -122,14 +122,12 @@ pub fn remove(self: *Tab) void { } /// Helper function to check if any surface in the split hierarchy needs close confirmation -const needsConfirm = struct { - fn check(elem: Surface.Container.Elem) bool { - return switch (elem) { - .surface => |s| s.core_surface.needsConfirmQuit(), - .split => |s| check(s.top_left) or check(s.bottom_right), - }; - } -}.check; +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. fn closeWithConfirmation(tab: *Tab) void { From 00137c41895628cc6a068e40445b6670ae3a9012 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 15:32:25 -0800 Subject: [PATCH 135/238] apprt/gtk: adw tab view close confirmation --- src/apprt/gtk/Tab.zig | 28 ++++--------------------- src/apprt/gtk/notebook_adw.zig | 37 ++++++++++++++++++++++++++++++++++ src/apprt/gtk/notebook_gtk.zig | 23 +++++++++++++++++++-- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index 6e28b8644..d320daa7c 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -130,7 +130,7 @@ fn needsConfirm(elem: Surface.Container.Elem) bool { } /// Close the tab, asking for confirmation if any surface requests it. -fn closeWithConfirmation(tab: *Tab) void { +pub fn closeWithConfirmation(tab: *Tab) void { switch (tab.elem) { .surface => |s| s.close(s.core_surface.needsConfirmQuit()), .split => |s| { @@ -169,21 +169,15 @@ fn closeWithConfirmation(tab: *Tab) void { } } -pub fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { - const tab: *Tab = @ptrCast(@alignCast(ud)); - tab.closeWithConfirmation(); -} - fn gtkTabCloseConfirmation( alert: *c.GtkMessageDialog, response: c.gint, ud: ?*anyopaque, ) callconv(.C) void { + const tab: *Tab = @ptrCast(@alignCast(ud)); c.gtk_window_destroy(@ptrCast(alert)); - if (response == c.GTK_RESPONSE_YES) { - const tab: *Tab = @ptrCast(@alignCast(ud)); - tab.remove(); - } + if (response != c.GTK_RESPONSE_YES) return; + tab.remove(); } fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { @@ -194,17 +188,3 @@ fn gtkDestroy(v: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) void { const tab: *Tab = @ptrCast(@alignCast(ud)); tab.destroy(tab.window.app.core_app.alloc); } - -pub fn gtkTabClick( - gesture: *c.GtkGestureClick, - _: c.gint, - _: c.gdouble, - _: c.gdouble, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Tab = @ptrCast(@alignCast(ud)); - const gtk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture)); - if (gtk_button == c.GDK_BUTTON_MIDDLE) { - self.closeWithConfirmation(); - } -} diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig index 48f005467..649db9be3 100644 --- a/src/apprt/gtk/notebook_adw.zig +++ b/src/apprt/gtk/notebook_adw.zig @@ -17,6 +17,14 @@ pub const NotebookAdw = struct { /// the tab view tab_view: *AdwTabView, + /// 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(notebook: *Notebook) void { const window: *Window = @fieldParentPtr("notebook", notebook); const app = window.app; @@ -38,6 +46,7 @@ pub const NotebookAdw = struct { }; _ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(tab_view, "close-page", c.G_CALLBACK(&adwClosePage), window, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(tab_view, "notify::selected-page", c.G_CALLBACK(&adwSelectPage), window, null, c.G_CONNECT_DEFAULT); } @@ -112,6 +121,12 @@ pub const NotebookAdw = struct { pub fn closeTab(self: *NotebookAdw, tab: *Tab) void { if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + // 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; + defer self.forcing_close = false; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return; c.adw_tab_view_close_page(self.tab_view, page); @@ -143,6 +158,28 @@ fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaqu window.focusCurrentTab(); } +fn adwClosePage( + _: *AdwTabView, + page: *c.AdwTabPage, + ud: ?*anyopaque, +) callconv(.C) c.gboolean { + const child = c.adw_tab_page_get_child(page); + const tab: *Tab = @ptrCast(@alignCast(c.g_object_get_data( + @ptrCast(child), + Tab.GHOSTTY_TAB, + ) orelse return 0)); + + const window: *Window = @ptrCast(@alignCast(ud.?)); + const notebook = window.notebook.adw; + c.adw_tab_view_close_page_finish( + notebook.tab_view, + page, + @intFromBool(notebook.forcing_close), + ); + if (!notebook.forcing_close) tab.closeWithConfirmation(); + return 1; +} + fn adwTabViewCreateWindow( _: *AdwTabView, ud: ?*anyopaque, diff --git a/src/apprt/gtk/notebook_gtk.zig b/src/apprt/gtk/notebook_gtk.zig index a2c482500..5f145dc84 100644 --- a/src/apprt/gtk/notebook_gtk.zig +++ b/src/apprt/gtk/notebook_gtk.zig @@ -157,8 +157,8 @@ pub const NotebookGtk = struct { c.gtk_gesture_single_set_button(@ptrCast(gesture_tab_click), 0); c.gtk_widget_add_controller(label_box_widget, @ptrCast(gesture_tab_click)); - _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(>kTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(>kTabClick), tab, null, c.G_CONNECT_DEFAULT); // Tab settings c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1); @@ -283,3 +283,22 @@ fn gtkNotebookCreateWindow( return newWindow.notebook.gtk.notebook; } + +fn gtkTabCloseClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { + const tab: *Tab = @ptrCast(@alignCast(ud)); + tab.closeWithConfirmation(); +} + +fn gtkTabClick( + gesture: *c.GtkGestureClick, + _: c.gint, + _: c.gdouble, + _: c.gdouble, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Tab = @ptrCast(@alignCast(ud)); + const gtk_button = c.gtk_gesture_single_get_current_button(@ptrCast(gesture)); + if (gtk_button == c.GDK_BUTTON_MIDDLE) { + self.closeWithConfirmation(); + } +} From 16233b16e731b5330e83ad3e95204b8fe785489e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 10 Jan 2025 19:12:08 -0600 Subject: [PATCH 136/238] gtk: fix crash due to accessing invalidated pointer to adwaita notebook --- src/apprt/gtk/notebook_adw.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig index 649db9be3..b4190fbc4 100644 --- a/src/apprt/gtk/notebook_adw.zig +++ b/src/apprt/gtk/notebook_adw.zig @@ -125,7 +125,10 @@ pub const NotebookAdw = struct { // as true so that the close_page call below doesn't request // confirmation. self.forcing_close = true; - defer self.forcing_close = false; + const n = self.nPages(); + defer { + if (n > 1) self.forcing_close = false; + } const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return; c.adw_tab_view_close_page(self.tab_view, page); From 0a26321e9d48e2cbffeef2571512d8093a8e8393 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 10 Jan 2025 19:19:11 -0600 Subject: [PATCH 137/238] gtk: add some comments about closing the last tab invaldating self pointer --- src/apprt/gtk/notebook_adw.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig index b4190fbc4..89a316332 100644 --- a/src/apprt/gtk/notebook_adw.zig +++ b/src/apprt/gtk/notebook_adw.zig @@ -127,6 +127,8 @@ pub const NotebookAdw = struct { 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; } @@ -146,6 +148,8 @@ pub const NotebookAdw = struct { c.g_object_unref(tab.box); } + // `self` will become invalid after this call because it will have + // been freed up as part of the process of closing the window. c.gtk_window_destroy(tab.window.window); } } From 941915b862f42367866e31cadaa02922b48067ec Mon Sep 17 00:00:00 2001 From: Samuel <36420837+Samueru-sama@users.noreply.github.com> Date: Fri, 10 Jan 2025 22:26:48 -0400 Subject: [PATCH 138/238] declare `StartupWMClass` in .desktop --- dist/linux/app.desktop | 1 + 1 file changed, 1 insertion(+) diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop index 6fc43d470..6e464ea87 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop @@ -7,6 +7,7 @@ Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; StartupNotify=true +StartupWMClass=com.mitchellh.ghostty Terminal=false Actions=new-window; X-GNOME-UsesNotifications=true From 8102fddceb7e2c1bea1c8901977f9cab38a25618 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Fri, 10 Jan 2025 22:42:41 -0600 Subject: [PATCH 139/238] apprt/gtk: add toggle_maximize keybind and window-maximize config option --- include/ghostty.h | 1 + src/Surface.zig | 16 ++++++++++++++++ src/apprt/action.zig | 4 ++++ src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 17 +++++++++++++++++ src/apprt/gtk/Window.zig | 9 +++++++++ src/config/Config.zig | 7 +++++++ src/input/Binding.zig | 4 ++++ 8 files changed, 59 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 29da8f37b..4275bad7e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -565,6 +565,7 @@ typedef enum { GHOSTTY_ACTION_CLOSE_TAB, GHOSTTY_ACTION_NEW_SPLIT, GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, + GHOSTTY_ACTION_TOGGLE_MAXIMIZE, GHOSTTY_ACTION_TOGGLE_FULLSCREEN, GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, diff --git a/src/Surface.zig b/src/Surface.zig index ce00d8237..53890e287 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -646,6 +646,16 @@ pub fn init( // an initial size shouldn't stop our terminal from working. log.warn("unable to set initial window size: {s}", .{err}); }; + + if (config.@"window-maximize") { + rt_app.performAction( + .{ .surface = self }, + .toggle_maximize, + {}, + ) catch |err| { + log.warn("unable to maximize window: {s}", .{err}); + }; + } } if (config.title) |title| { @@ -4168,6 +4178,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_maximize => try self.rt_app.performAction( + .{ .surface = self }, + .toggle_maximize, + {}, + ), + .toggle_fullscreen => try self.rt_app.performAction( .{ .surface = self }, .toggle_fullscreen, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 25e1cd640..fe2039e52 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -92,6 +92,9 @@ pub const Action = union(Key) { /// Close all open windows. close_all_windows, + /// Toggle maximized window state. + toggle_maximize, + /// Toggle fullscreen mode. toggle_fullscreen: Fullscreen, @@ -231,6 +234,7 @@ pub const Action = union(Key) { close_tab, new_split, close_all_windows, + toggle_maximize, toggle_fullscreen, toggle_tab_overview, toggle_window_decorations, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 8094baeb8..686a70ddb 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -237,6 +237,7 @@ pub const App = struct { .color_change, .pwd, .config_change, + .toggle_maximize, => log.info("unimplemented action={}", .{action}), } } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 70fc182e5..f49d275de 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -507,6 +507,7 @@ pub fn performAction( .app => null, .surface => |v| v, }), + .toggle_maximize => self.toggleMaximize(target), .toggle_fullscreen => self.toggleFullscreen(target, value), .new_tab => try self.newTab(target), @@ -709,6 +710,22 @@ fn controlInspector( surface.controlInspector(mode); } +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, diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 8f111cbc9..599a4d184 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -522,6 +522,15 @@ pub fn toggleTabOverview(self: *Window) void { } } +/// Toggle the maximized state for this window. +pub fn toggleMaximize(self: *Window) void { + if (c.gtk_window_is_maximized(self.window) == 0) { + c.gtk_window_maximize(self.window); + } else { + c.gtk_window_unmaximize(self.window); + } +} + /// Toggle fullscreen for this window. pub fn toggleFullscreen(self: *Window) void { const is_fullscreen = c.gtk_window_is_fullscreen(self.window); diff --git a/src/config/Config.zig b/src/config/Config.zig index 144796554..310b11623 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1214,6 +1214,13 @@ keybind: Keybinds = .{}, @"window-position-x": ?i16 = null, @"window-position-y": ?i16 = null, +/// Whether to start the window in a maximized state. This is only related to +/// the X11 window manager's concept of maximization. In other words, this +/// will set the _NET_WM_STATE property to _NET_WM_STATE_MAXIMIZED_VERT and +/// _NET_WM_STATE_MAXIMIZED_HORZ on launch. This will not affect the window +/// size or position. This is only supported on Linux. +@"window-maximize": bool = false, + /// Whether to enable saving and restoring window state. Window state includes /// their position, size, tabs, splits, etc. Some window state requires shell /// integration, such as preserving working directories. See `shell-integration` diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 2fdbc4cba..48725fb13 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -391,6 +391,9 @@ pub const Action = union(enum) { /// This only works for macOS currently. close_all_windows: void, + /// Toggle maximized window state. This only works on Linux. + toggle_maximize: void, + /// Toggle fullscreen mode of window. toggle_fullscreen: void, @@ -737,6 +740,7 @@ pub const Action = union(enum) { .close_surface, .close_tab, .close_window, + .toggle_maximize, .toggle_fullscreen, .toggle_window_decorations, .toggle_secure_input, From c9636598fc21864d75d4209bdc6feccae3b7cbb5 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Fri, 10 Jan 2025 23:24:00 -0600 Subject: [PATCH 140/238] chore: rename config value to maximize and move startup logic to proper location --- src/Surface.zig | 10 ---------- src/apprt/gtk/Window.zig | 3 +++ src/config/Config.zig | 12 +++++------- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 53890e287..9fb103ce3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -646,16 +646,6 @@ pub fn init( // an initial size shouldn't stop our terminal from working. log.warn("unable to set initial window size: {s}", .{err}); }; - - if (config.@"window-maximize") { - rt_app.performAction( - .{ .surface = self }, - .toggle_maximize, - {}, - ) catch |err| { - log.warn("unable to maximize window: {s}", .{err}); - }; - } } if (config.title) |title| { diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 599a4d184..42c179462 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -265,6 +265,9 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_popover_set_has_arrow(@ptrCast(@alignCast(self.context_menu)), 0); c.gtk_widget_set_halign(self.context_menu, c.GTK_ALIGN_START); + // If we want the window to be maximized, we do that here. + if (app.config.maximize) c.gtk_window_maximize(self.window); + // If we are in fullscreen mode, new windows start fullscreen. if (app.config.fullscreen) c.gtk_window_fullscreen(self.window); diff --git a/src/config/Config.zig b/src/config/Config.zig index 310b11623..6c5b64316 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -764,6 +764,11 @@ link: RepeatableLink = .{}, /// `link`). If you want to customize URL matching, use `link` and disable this. @"link-url": bool = true, +/// Whether to start the window in a maximized state. This setting applies +/// to new windows and does not apply to tabs, splits, etc. However, this setting +/// will apply to all new windows, not just the first one. +maximize: bool = false, + /// Start new windows in fullscreen. This setting applies to new windows and /// does not apply to tabs, splits, etc. However, this setting will apply to all /// new windows, not just the first one. @@ -1214,13 +1219,6 @@ keybind: Keybinds = .{}, @"window-position-x": ?i16 = null, @"window-position-y": ?i16 = null, -/// Whether to start the window in a maximized state. This is only related to -/// the X11 window manager's concept of maximization. In other words, this -/// will set the _NET_WM_STATE property to _NET_WM_STATE_MAXIMIZED_VERT and -/// _NET_WM_STATE_MAXIMIZED_HORZ on launch. This will not affect the window -/// size or position. This is only supported on Linux. -@"window-maximize": bool = false, - /// Whether to enable saving and restoring window state. Window state includes /// their position, size, tabs, splits, etc. Some window state requires shell /// integration, such as preserving working directories. See `shell-integration` From 95fc1d64c89f90aa72cc45aa6617b50121f1a4e5 Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Sat, 11 Jan 2025 17:24:13 +0100 Subject: [PATCH 141/238] parse ConEmu OSC9;5 --- src/terminal/osc.zig | 38 ++++++++++++++++++++++++++++++++++++++ src/terminal/stream.zig | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 33d753c9f..01f017731 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -178,6 +178,9 @@ pub const Command = union(enum) { progress: ?u8 = null, }, + /// Wait input (OSC 9;5) + wait_input: void, + pub const ColorKind = union(enum) { palette: u8, foreground, @@ -377,6 +380,7 @@ pub const Parser = struct { conemu_progress_state, conemu_progress_prevalue, conemu_progress_value, + conemu_wait, }; /// This must be called to clean up any allocated memory. @@ -811,6 +815,11 @@ pub const Parser = struct { '4' => { self.state = .conemu_progress_prestate; }, + '5' => { + self.state = .conemu_wait; + self.command = .{ .wait_input = {} }; + self.complete = true; + }, // Todo: parse out other ConEmu operating system commands. // Even if we don't support them we probably don't want @@ -943,6 +952,11 @@ pub const Parser = struct { }, }, + .conemu_wait => { + self.state = .invalid; + self.complete = false; + }, + .query_fg_color => switch (c) { '?' => { self.command = .{ .report_color = .{ .kind = .foreground } }; @@ -2096,6 +2110,30 @@ test "OSC: OSC9 progress pause with progress" { try testing.expect(cmd.progress.progress == 100); } +test "OSC: OSC9 conemu wait input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;5"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .wait_input); +} + +test "OSC: OSC9 conemu wait invalid input" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;5;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} + test "OSC: empty param" { const testing = std.testing; diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index f75d86c0a..5657d63f4 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1605,7 +1605,7 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, - .progress, .sleep, .show_message_box, .change_conemu_tab_title => { + .progress, .sleep, .show_message_box, .change_conemu_tab_title, .wait_input => { log.warn("unimplemented OSC callback: {}", .{cmd}); }, } From 2409d46600444f0d5001ec1140e7c3055d544549 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Sun, 12 Jan 2025 01:15:53 +0800 Subject: [PATCH 142/238] Correct IME position calculation with window padding --- src/Surface.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index ce00d8237..d018d396d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1316,8 +1316,8 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 }; const x: f64 = x: { - // Simple x * cell width gives the top-left corner - var x: f64 = @floatFromInt(cursor.x * self.size.cell.width); + // Simple x * cell width gives the top-left corner, then add padding offset + var x: f64 = @floatFromInt(cursor.x * self.size.cell.width + self.size.padding.left); // We want the midpoint x += @as(f64, @floatFromInt(self.size.cell.width)) / 2; @@ -1329,8 +1329,8 @@ pub fn imePoint(self: *const Surface) apprt.IMEPos { }; const y: f64 = y: { - // Simple x * cell width gives the top-left corner - var y: f64 = @floatFromInt(cursor.y * self.size.cell.height); + // Simple y * cell height gives the top-left corner, then add padding offset + var y: f64 = @floatFromInt(cursor.y * self.size.cell.height + self.size.padding.top); // We want the bottom y += @floatFromInt(self.size.cell.height); From af5e423ea5086b1acb6b265db0870ac79ba5e405 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Sun, 12 Jan 2025 01:48:48 +0800 Subject: [PATCH 143/238] Clear selection when IME input starts --- include/ghostty.h | 1 + macos/Sources/Ghostty/SurfaceView_AppKit.swift | 5 +++++ src/apprt/embedded.zig | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 29da8f37b..e6e625c4b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -748,6 +748,7 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t, bool); bool ghostty_surface_has_selection(ghostty_surface_t); uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t); +void ghostty_surface_clear_selection(ghostty_surface_t); #ifdef __APPLE__ void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 14143313e..362547e9e 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1293,6 +1293,11 @@ extension Ghostty.SurfaceView: NSTextInputClient { } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + // Clear selection when IME input starts + if let surface = self.surface, ghostty_surface_has_selection(surface) { + ghostty_surface_clear_selection(surface) + } + switch string { case let v as NSAttributedString: self.markedText = NSMutableAttributedString(attributedString: v) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 44c4c5f20..441d10f59 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1550,6 +1550,11 @@ pub const CAPI = struct { return selection.len; } + /// Clear the current selection in the surface. + export fn ghostty_surface_clear_selection(surface: *Surface) void { + surface.core_surface.io.terminal.screen.clearSelection(); + } + /// Tell the surface that it needs to schedule a render export fn ghostty_surface_refresh(surface: *Surface) void { surface.refresh(); From fc99c99b74f72776ac39ba8c3d9028e38f4db07d Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Sat, 11 Jan 2025 22:19:42 +0100 Subject: [PATCH 144/238] code review --- src/terminal/osc.zig | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 01f017731..10ba5b5e7 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -380,7 +380,6 @@ pub const Parser = struct { conemu_progress_state, conemu_progress_prevalue, conemu_progress_value, - conemu_wait, }; /// This must be called to clean up any allocated memory. @@ -816,7 +815,7 @@ pub const Parser = struct { self.state = .conemu_progress_prestate; }, '5' => { - self.state = .conemu_wait; + self.state = .swallow; self.command = .{ .wait_input = {} }; self.complete = true; }, @@ -952,11 +951,6 @@ pub const Parser = struct { }, }, - .conemu_wait => { - self.state = .invalid; - self.complete = false; - }, - .query_fg_color => switch (c) { '?' => { self.command = .{ .report_color = .{ .kind = .foreground } }; @@ -2122,16 +2116,16 @@ test "OSC: OSC9 conemu wait input" { try testing.expect(cmd == .wait_input); } -test "OSC: OSC9 conemu wait invalid input" { +test "OSC: OSC9 conemu wait ignores trailing characters" { const testing = std.testing; var p: Parser = .{}; - const input = "9;5;"; + const input = "9;5;foo"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b'); - try testing.expect(cmd == null); + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .wait_input); } test "OSC: empty param" { From 0811b1d5ac051f1d26e0c257a9ce4e5730af9ac9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Jan 2025 13:57:37 -0800 Subject: [PATCH 145/238] macos: paste multiple files separated by space https://github.com/ghostty-org/ghostty/discussions/4892#discussioncomment-11808631 --- macos/Sources/Helpers/NSPasteboard+Extension.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/NSPasteboard+Extension.swift index 7794946f4..7315739c6 100644 --- a/macos/Sources/Helpers/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/NSPasteboard+Extension.swift @@ -13,11 +13,11 @@ extension NSPasteboard { /// - Tries to get any string from the pasteboard. /// If all of the above fail, returns None. func getOpinionatedStringContents() -> String? { - if let file = self.string(forType: .fileURL) { - if let path = NSURL(string: file)?.path { - return path - } + if let urls = readObjects(forClasses: [NSURL.self]) as? [URL], + urls.count > 0 { + return urls.map { $0.path }.joined(separator: " ") } + return self.string(forType: .string) } From 6c5c5b2ec0bde5db6ae1d3d7f071c97dcb49af98 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Jan 2025 13:59:38 -0800 Subject: [PATCH 146/238] core: clear selection whenever preedit is changed --- include/ghostty.h | 1 - macos/Sources/Ghostty/SurfaceView_AppKit.swift | 5 ----- macos/Sources/Helpers/NSPasteboard+Extension.swift | 1 + src/Surface.zig | 9 +++++++++ src/apprt/embedded.zig | 5 ----- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index e6e625c4b..29da8f37b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -748,7 +748,6 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t, bool); bool ghostty_surface_has_selection(ghostty_surface_t); uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t); -void ghostty_surface_clear_selection(ghostty_surface_t); #ifdef __APPLE__ void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 362547e9e..14143313e 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1293,11 +1293,6 @@ extension Ghostty.SurfaceView: NSTextInputClient { } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - // Clear selection when IME input starts - if let surface = self.surface, ghostty_surface_has_selection(surface) { - ghostty_surface_clear_selection(surface) - } - switch string { case let v as NSAttributedString: self.markedText = NSMutableAttributedString(attributedString: v) diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/NSPasteboard+Extension.swift index 7794946f4..c2801da74 100644 --- a/macos/Sources/Helpers/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/NSPasteboard+Extension.swift @@ -18,6 +18,7 @@ extension NSPasteboard { return path } } + return self.string(forType: .string) } diff --git a/src/Surface.zig b/src/Surface.zig index d018d396d..4682f4fb5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1591,6 +1591,15 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); + // We clear our selection when ANY OF: + // 1. We have an existing preedit + // 2. We have preedit text + if (self.renderer_state.preedit != null or + preedit_ != null) + { + self.setSelection(null) catch {}; + } + // We always clear our prior preedit if (self.renderer_state.preedit) |p| { self.alloc.free(p.codepoints); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 441d10f59..44c4c5f20 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1550,11 +1550,6 @@ pub const CAPI = struct { return selection.len; } - /// Clear the current selection in the surface. - export fn ghostty_surface_clear_selection(surface: *Surface) void { - surface.core_surface.io.terminal.screen.clearSelection(); - } - /// Tell the surface that it needs to schedule a render export fn ghostty_surface_refresh(surface: *Surface) void { surface.refresh(); From 50e33a66654a083e15085549842bad0c449f30eb Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 12 Jan 2025 01:01:09 +0000 Subject: [PATCH 147/238] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 3c6ab85d6..4b9a3856b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -79,8 +79,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/4762ad5bd6d3906e28babdc2bda8a967d63a63be.tar.gz", - .hash = "1220a263b22113273d01bd33e3c06b8119cb2f63b4e5d414a85d88e3aa95bb68a2de", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/25cb3c3f52c7011cd8a599f8d144fc63f4409eb6.tar.gz", + .hash = "1220dc1096bda9721c1f5256177539bf37b41ac6fb70d58eadf0eec45359676382e5", }, }, } diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index db909a936..def5a11e3 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-Nx1tOhDnEZ7LVi/pKxYS3sg/Sf8TAUXDmST6EtBgDoQ=" +"sha256-2zXNHWSSWjnpW8oHu2sufT5+Ms4IKWaH6yRARQeMcxk=" From a06fc4ff114f42c85f79f14ad13602d6b2bd74a5 Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Sat, 11 Jan 2025 23:45:21 +0100 Subject: [PATCH 148/238] feat: ensure text, files and URLs can be drag and dropped to terminal window --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 14143313e..67154a3af 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -230,6 +230,8 @@ extension Ghostty { ghostty_surface_set_color_scheme(surface, scheme) } + + registerForDraggedTypes([.string, .fileURL, .URL]) } required init?(coder: NSCoder) { @@ -389,6 +391,68 @@ extension Ghostty { self?.title = title } } + + // MARK: - Drag and Drop + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + if let _ = sender.draggingPasteboard.string(forType: .string) { + return .generic + } + + if let _ = sender.draggingPasteboard.string(forType: .URL) { + return .generic + } + + if let _ = sender.draggingPasteboard.string(forType: .fileURL) { + return .generic + } + return [] + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + if let droppedText = sender.draggingPasteboard.string(forType: .string) { + let content = Shell.escape(droppedText) + + DispatchQueue.main.async { + self.insertText( + content, + replacementRange: NSMakeRange(0, 0) + ) + } + + return true + } + + if let droppedURL = sender.draggingPasteboard.string(forType: .URL) { + let content = Shell.escape(droppedURL) + + DispatchQueue.main.async { + self.insertText( + content, + replacementRange: NSMakeRange(0, 0) + ) + } + + return true + } + + if let droppedFileURL = sender.draggingPasteboard.string(forType: .fileURL) { + guard let urlPath = URL(string: droppedFileURL)?.path(percentEncoded: false) else { + return false + } + + DispatchQueue.main.async { + self.insertText( + urlPath, + replacementRange: NSMakeRange(0, 0) + ) + } + + return true + } + + return false + } // MARK: Local Events From a2d2cfea59854abfeee364efa22f21233785ad37 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 11 Jan 2025 19:19:49 -0800 Subject: [PATCH 149/238] macos: move drop implementation to separate extension --- macos/Sources/Ghostty/SurfaceView.swift | 16 --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 127 +++++++++--------- 2 files changed, 63 insertions(+), 80 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 4abf87c7f..beae50331 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -92,22 +92,6 @@ extension Ghostty { windowFocus = false } } - .onDrop(of: [.fileURL], isTargeted: nil) { providers in - providers.forEach { provider in - _ = provider.loadObject(ofClass: URL.self) { url, _ in - guard let url = url else { return } - let path = Shell.escape(url.path) - DispatchQueue.main.async { - surfaceView.insertText( - path, - replacementRange: NSMakeRange(0, 0) - ) - } - } - } - - return true - } #endif // If our geo size changed then we show the resize overlay as configured. diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 67154a3af..f5cb93580 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import CoreText import UserNotifications @@ -230,8 +231,9 @@ extension Ghostty { ghostty_surface_set_color_scheme(surface, scheme) } - - registerForDraggedTypes([.string, .fileURL, .URL]) + + // The UTTypes that can be dragged onto this view. + registerForDraggedTypes(Array(Self.dropTypes)) } required init?(coder: NSCoder) { @@ -391,68 +393,6 @@ extension Ghostty { self?.title = title } } - - // MARK: - Drag and Drop - - override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - if let _ = sender.draggingPasteboard.string(forType: .string) { - return .generic - } - - if let _ = sender.draggingPasteboard.string(forType: .URL) { - return .generic - } - - if let _ = sender.draggingPasteboard.string(forType: .fileURL) { - return .generic - } - return [] - } - - override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - if let droppedText = sender.draggingPasteboard.string(forType: .string) { - let content = Shell.escape(droppedText) - - DispatchQueue.main.async { - self.insertText( - content, - replacementRange: NSMakeRange(0, 0) - ) - } - - return true - } - - if let droppedURL = sender.draggingPasteboard.string(forType: .URL) { - let content = Shell.escape(droppedURL) - - DispatchQueue.main.async { - self.insertText( - content, - replacementRange: NSMakeRange(0, 0) - ) - } - - return true - } - - if let droppedFileURL = sender.draggingPasteboard.string(forType: .fileURL) { - guard let urlPath = URL(string: droppedFileURL)?.path(percentEncoded: false) else { - return false - } - - DispatchQueue.main.async { - self.insertText( - urlPath, - replacementRange: NSMakeRange(0, 0) - ) - } - - return true - } - - return false - } // MARK: Local Events @@ -1573,3 +1513,62 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { } } } + +// MARK: NSDraggingDestination + +extension Ghostty.SurfaceView { + static let dropTypes: Set = [ + .string, + .fileURL, + .URL + ] + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + guard let types = sender.draggingPasteboard.types else { return [] } + + // If the dragging object contains none of our types then we return none. + // This shouldn't happen because AppKit should guarantee that we only + // receive types we registered for but its good to check. + if Set(types).isDisjoint(with: Self.dropTypes) { + return [] + } + + // We use copy to get the proper icon + return .copy + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let pb = sender.draggingPasteboard + + let content: String? + if let url = pb.string(forType: .URL) { + // URLs first, they get escaped as-is. + content = Ghostty.Shell.escape(url) + } else if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL], + urls.count > 0 { + // File URLs next. They get escaped individually and then joined by a + // space if there are multiple. + content = urls + .map { Ghostty.Shell.escape($0.path) } + .joined(separator: " ") + } else if let str = pb.string(forType: .string) { + // Strings are not escaped because they may be copy/pasting a + // command they want to execute. + content = str + } else { + content = nil + } + + if let content { + DispatchQueue.main.async { + self.insertText( + content, + replacementRange: NSMakeRange(0, 0) + ) + } + return true + } + + return false + } +} From a3bb2df94f2e8576e689d247f54c68c7bb88a711 Mon Sep 17 00:00:00 2001 From: Michael Himing Date: Sun, 12 Jan 2025 14:46:05 +1100 Subject: [PATCH 150/238] fix(gtk): fix segfault on ctrl-d on older adw --- src/apprt/gtk/notebook_adw.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig index 89a316332..790b3aa35 100644 --- a/src/apprt/gtk/notebook_adw.zig +++ b/src/apprt/gtk/notebook_adw.zig @@ -137,6 +137,8 @@ pub const NotebookAdw = struct { // If we have no more tabs we close the window if (self.nPages() == 0) { + const window = tab.window.window; + // libadw versions <= 1.3.x leak the final page view // which causes our surface to not properly cleanup. We // unref to force the cleanup. This will trigger a critical @@ -150,7 +152,7 @@ pub const NotebookAdw = struct { // `self` will become invalid after this call because it will have // been freed up as part of the process of closing the window. - c.gtk_window_destroy(tab.window.window); + c.gtk_window_destroy(window); } } }; From faea09bbdea196d78684e79a58012a74ee98a3d9 Mon Sep 17 00:00:00 2001 From: james Date: Sat, 11 Jan 2025 23:53:19 -0500 Subject: [PATCH 151/238] for GTK runtime, don't call cursorPosCallback in mouse motion callback if the cursor hasn't actually moved --- src/apprt/gtk/Surface.zig | 48 ++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index c16f696b1..c5a001f34 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1496,31 +1496,37 @@ fn gtkMouseMotion( .y = @floatCast(scaled.y), }; - // When the GLArea is resized under the mouse, GTK issues a mouse motion - // event. This has the unfortunate side effect of causing focus to potentially - // change when `focus-follows-mouse` is enabled. To prevent this, we check - // if the cursor is still in the same place as the last event and only grab - // focus if it has moved. + // 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 we don't have focus, and we want it, grab it. - const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area)); - if (!is_cursor_still and c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") { - self.grabFocus(); + if (!is_cursor_still) { + // If we don't have focus, and we want it, grab it. + const gl_widget = @as(*c.GtkWidget, @ptrCast(self.gl_area)); + if (c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") { + self.grabFocus(); + } + + // Our pos changed, update + self.cursor_pos = pos; + + // Get our modifiers + const gtk_mods = c.gdk_event_get_modifier_state(event); + 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; + }; } - - // Our pos changed, update - self.cursor_pos = pos; - - // Get our modifiers - const gtk_mods = c.gdk_event_get_modifier_state(event); - 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( From ea0704148d8fc205de16fc2ef1da88b162c0c739 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 12 Jan 2025 12:48:53 -0800 Subject: [PATCH 152/238] macos: only set quick terminal level to popUpMenu during animation Fixes #4999 We need to set the level to popUpMenu so that we can move the window offscreen and animate it over the main menu, but we must reset it back to floating after the animation is complete so that other higher-level windows can be shown on top of it such as IME windows. --- .../QuickTerminalController.swift | 15 +++++++++++++++ .../QuickTerminal/QuickTerminalWindow.swift | 18 ------------------ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index fee6f0735..c23aad755 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -230,6 +230,11 @@ class QuickTerminalController: BaseTerminalController { // Move our window off screen to the top position.setInitial(in: window, on: screen) + // We need to set our window level to a high value. In testing, only + // popUpMenu and above do what we want. This gets it above the menu bar + // and lets us render off screen. + window.level = .popUpMenu + // Move it to the visible position since animation requires this DispatchQueue.main.async { window.makeKeyAndOrderFront(nil) @@ -248,6 +253,11 @@ class QuickTerminalController: BaseTerminalController { // If we canceled our animation in we do nothing guard self.visible else { return } + // After animating in, we reset the window level to a value that + // is above other windows but not as high as popUpMenu. This allows + // things like IME dropdowns to appear properly. + window.level = .floating + // Now that the window is visible, sync our appearance. This function // requires the window is visible. self.syncAppearance() @@ -339,6 +349,11 @@ class QuickTerminalController: BaseTerminalController { } } + // We need to set our window level to a high value. In testing, only + // popUpMenu and above do what we want. This gets it above the menu bar + // and lets us render off screen. + window.level = .popUpMenu + NSAnimationContext.runAnimationGroup({ context in context.duration = derivedConfig.quickTerminalAnimationDuration context.timingFunction = .init(name: .easeIn) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift index 552b87e25..005808a23 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift @@ -28,23 +28,5 @@ class QuickTerminalWindow: NSPanel { // We don't want to activate the owning app when quick terminal is triggered. self.styleMask.insert(.nonactivatingPanel) - - // We need to set our window level to a high value. In testing, only - // popUpMenu and above do what we want. This gets it above the menu bar - // and lets us render off screen. - self.level = .popUpMenu - - // This plus the level above was what was needed for the animation to work, - // because it gets the window off screen properly. Plus we add some fields - // we just want the behavior of. - self.collectionBehavior = [ - // We want this to be part of every space because it is a singleton. - .canJoinAllSpaces, - - // We don't want to be part of command-tilde - .ignoresCycle, - - // We want to show the window on another space if it is visible - .fullScreenAuxiliary] } } From 5cd990bec5a16653b136881af1b9834a5b1bd899 Mon Sep 17 00:00:00 2001 From: Pavlos Karakalidis Date: Sun, 12 Jan 2025 23:48:00 +0200 Subject: [PATCH 153/238] fix(window): ensure last_tab action on linux navigates to last tab Previously, the logic navigated to the second-to-last tab instead of the last tab due to an off-by-one error. This updates the implementation so that the index calculation to accurately target the last tab. In the `gotoLastTab` method there was a decrement in the number of max number of tabs and another increment in the `goToTab` method to get the actual tab index. --- src/apprt/gtk/Window.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 8f111cbc9..4301b0605 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -497,9 +497,9 @@ pub fn moveTab(self: *Window, surface: *Surface, position: c_int) void { self.notebook.moveTab(tab, position); } -/// Go to the next tab for a surface. +/// Go to the last tab for a surface. pub fn gotoLastTab(self: *Window) void { - const max = self.notebook.nPages() -| 1; + const max = self.notebook.nPages(); self.gotoTab(@intCast(max)); } From 7ac017b154251a42529f735d8cf23a4a10c3d037 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 12 Jan 2025 19:34:20 -0600 Subject: [PATCH 154/238] gtk: hide titlebar if fullscreened Partially addresses #3381 --- src/apprt/gtk/Window.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 8f111cbc9..07e582d64 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -204,6 +204,7 @@ pub fn init(self: *Window, app: *App) !void { } _ = c.g_signal_connect_data(gtk_window, "notify::decorated", c.G_CALLBACK(>kWindowNotifyDecorated), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_window, "notify::fullscreened", c.G_CALLBACK(>kWindowNotifyFullscreened), self, null, c.G_CONNECT_DEFAULT); // If we are disabling decorations then disable them right away. if (!app.config.@"window-decoration") { @@ -606,6 +607,15 @@ fn gtkWindowNotifyDecorated( } } +fn gtkWindowNotifyFullscreened( + object: *c.GObject, + _: *c.GParamSpec, + ud: ?*anyopaque, +) callconv(.C) void { + const self = userdataSelf(ud orelse return); + self.headerbar.setVisible(c.gtk_window_is_fullscreen(@ptrCast(object)) == 0); +} + // Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab // sends an undefined value. fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { From e288096c26c700247bac2c3145456fa104f376e1 Mon Sep 17 00:00:00 2001 From: Andreas Skielboe Date: Mon, 13 Jan 2025 11:49:21 +0100 Subject: [PATCH 155/238] Fix backslash comment in ghostty.h --- include/ghostty.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 29da8f37b..c722a0104 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -159,7 +159,7 @@ typedef enum { GHOSTTY_KEY_EQUAL, GHOSTTY_KEY_LEFT_BRACKET, // [ GHOSTTY_KEY_RIGHT_BRACKET, // ] - GHOSTTY_KEY_BACKSLASH, // / + GHOSTTY_KEY_BACKSLASH, // \ // control GHOSTTY_KEY_UP, From 08314d414f570bf0698b05bef92496642c1234b1 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Tue, 14 Jan 2025 00:35:08 +0800 Subject: [PATCH 156/238] Preserve full URL when pasting from clipboard --- macos/Sources/Helpers/NSPasteboard+Extension.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/NSPasteboard+Extension.swift index 7315739c6..0b71b5685 100644 --- a/macos/Sources/Helpers/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/NSPasteboard+Extension.swift @@ -15,7 +15,9 @@ extension NSPasteboard { func getOpinionatedStringContents() -> String? { if let urls = readObjects(forClasses: [NSURL.self]) as? [URL], urls.count > 0 { - return urls.map { $0.path }.joined(separator: " ") + return urls + .map { $0.isFileURL ? $0.path : $0.absoluteString } + .joined(separator: " ") } return self.string(forType: .string) From 7aed08be407ee22993974cb8eaa1a416c2c8c6bf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 13 Jan 2025 10:52:29 -0800 Subject: [PATCH 157/238] terminal: keep track of colon vs semicolon state in CSI params Fixes #5022 The CSI SGR sequence (CSI m) is unique in that its the only CSI sequence that allows colons as delimiters between some parameters, and the colon vs. semicolon changes the semantics of the parameters. Previously, Ghostty assumed that an SGR sequence was either all colons or all semicolons, and would change its behavior based on the first delimiter it encountered. This is incorrect. It is perfectly valid for an SGR sequence to have both colons and semicolons as delimiters. For example, Kakoune sends the following: ;4:3;38;2;175;175;215;58:2::190:80:70m This is equivalent to: - unset (0) - curly underline (4:3) - foreground color (38;2;175;175;215) - underline color (58:2::190:80:70) This commit changes the behavior of Ghostty to track the delimiter per parameter, rather than per sequence. It also updates the SGR parser to be more robust and handle the various edge cases that can occur. Tests were added for the new cases. --- src/terminal/Parser.zig | 124 ++++++---- src/terminal/sgr.zig | 515 ++++++++++++++++++++++++++++------------ src/terminal/stream.zig | 11 +- 3 files changed, 450 insertions(+), 200 deletions(-) diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 9aebdbd3a..a779c3350 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -6,6 +6,7 @@ const Parser = @This(); const std = @import("std"); const builtin = @import("builtin"); +const assert = std.debug.assert; const testing = std.testing; const table = @import("parse_table.zig").table; const osc = @import("osc.zig"); @@ -81,11 +82,15 @@ pub const Action = union(enum) { pub const CSI = struct { intermediates: []u8, params: []u16, + params_sep: SepList, final: u8, - sep: Sep, + + /// The list of separators used for CSI params. The value of the + /// bit can be mapped to Sep. + pub const SepList = std.StaticBitSet(MAX_PARAMS); /// The separator used for CSI params. - pub const Sep = enum { semicolon, colon }; + pub const Sep = enum(u1) { semicolon = 0, colon = 1 }; // Implement formatter for logging pub fn format( @@ -183,15 +188,6 @@ pub const Action = union(enum) { } }; -/// Keeps track of the parameter sep used for CSI params. We allow colons -/// to be used ONLY by the 'm' CSI action. -pub const ParamSepState = enum(u8) { - none = 0, - semicolon = ';', - colon = ':', - mixed = 1, -}; - /// Maximum number of intermediate characters during parsing. This is /// 4 because we also use the intermediates array for UTF8 decoding which /// can be at most 4 bytes. @@ -207,8 +203,8 @@ intermediates_idx: u8 = 0, /// Param tracking, building params: [MAX_PARAMS]u16 = undefined, +params_sep: Action.CSI.SepList = Action.CSI.SepList.initEmpty(), params_idx: u8 = 0, -params_sep: ParamSepState = .none, param_acc: u16 = 0, param_acc_idx: u8 = 0, @@ -312,13 +308,9 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { // Ignore too many parameters if (self.params_idx >= MAX_PARAMS) break :param null; - // If this is our first time seeing a parameter, we track - // the separator used so that we can't mix separators later. - if (self.params_idx == 0) self.params_sep = @enumFromInt(c); - if (@as(ParamSepState, @enumFromInt(c)) != self.params_sep) self.params_sep = .mixed; - // Set param final value self.params[self.params_idx] = self.param_acc; + if (c == ':') self.params_sep.set(self.params_idx); self.params_idx += 1; // Reset current param value to 0 @@ -359,29 +351,18 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { .csi_dispatch = .{ .intermediates = self.intermediates[0..self.intermediates_idx], .params = self.params[0..self.params_idx], + .params_sep = self.params_sep, .final = c, - .sep = switch (self.params_sep) { - .none, .semicolon => .semicolon, - .colon => .colon, - - // There is nothing that treats mixed separators specially - // afaik so we just treat it as a semicolon. - .mixed => .semicolon, - }, }, }; // We only allow colon or mixed separators for the 'm' command. - switch (self.params_sep) { - .none => {}, - .semicolon => {}, - .colon, .mixed => if (c != 'm') { - log.warn( - "CSI colon or mixed separators only allowed for 'm' command, got: {}", - .{result}, - ); - break :csi_dispatch null; - }, + if (c != 'm' and self.params_sep.count() > 0) { + log.warn( + "CSI colon or mixed separators only allowed for 'm' command, got: {}", + .{result}, + ); + break :csi_dispatch null; } break :csi_dispatch result; @@ -400,7 +381,7 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { pub fn clear(self: *Parser) void { self.intermediates_idx = 0; self.params_idx = 0; - self.params_sep = .none; + self.params_sep = Action.CSI.SepList.initEmpty(); self.param_acc = 0; self.param_acc_idx = 0; } @@ -507,10 +488,11 @@ test "csi: SGR ESC [ 38 : 2 m" { const d = a[1].?.csi_dispatch; try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); try testing.expect(d.params.len == 2); try testing.expectEqual(@as(u16, 38), d.params[0]); + try testing.expect(d.params_sep.isSet(0)); try testing.expectEqual(@as(u16, 2), d.params[1]); + try testing.expect(!d.params_sep.isSet(1)); } } @@ -581,13 +563,17 @@ test "csi: SGR ESC [ 48 : 2 m" { const d = a[1].?.csi_dispatch; try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); try testing.expect(d.params.len == 5); try testing.expectEqual(@as(u16, 48), d.params[0]); + try testing.expect(d.params_sep.isSet(0)); try testing.expectEqual(@as(u16, 2), d.params[1]); + try testing.expect(d.params_sep.isSet(1)); try testing.expectEqual(@as(u16, 240), d.params[2]); + try testing.expect(d.params_sep.isSet(2)); try testing.expectEqual(@as(u16, 143), d.params[3]); + try testing.expect(d.params_sep.isSet(3)); try testing.expectEqual(@as(u16, 104), d.params[4]); + try testing.expect(!d.params_sep.isSet(4)); } } @@ -608,10 +594,11 @@ test "csi: SGR ESC [4:3m colon" { const d = a[1].?.csi_dispatch; try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); try testing.expect(d.params.len == 2); try testing.expectEqual(@as(u16, 4), d.params[0]); + try testing.expect(d.params_sep.isSet(0)); try testing.expectEqual(@as(u16, 3), d.params[1]); + try testing.expect(!d.params_sep.isSet(1)); } } @@ -634,14 +621,71 @@ test "csi: SGR with many blank and colon" { const d = a[1].?.csi_dispatch; try testing.expect(d.final == 'm'); - try testing.expect(d.sep == .colon); try testing.expect(d.params.len == 6); try testing.expectEqual(@as(u16, 58), d.params[0]); + try testing.expect(d.params_sep.isSet(0)); try testing.expectEqual(@as(u16, 2), d.params[1]); + try testing.expect(d.params_sep.isSet(1)); try testing.expectEqual(@as(u16, 0), d.params[2]); + try testing.expect(d.params_sep.isSet(2)); try testing.expectEqual(@as(u16, 240), d.params[3]); + try testing.expect(d.params_sep.isSet(3)); try testing.expectEqual(@as(u16, 143), d.params[4]); + try testing.expect(d.params_sep.isSet(4)); try testing.expectEqual(@as(u16, 104), d.params[5]); + try testing.expect(!d.params_sep.isSet(5)); + } +} + +// This is from a Kakoune actual SGR sequence. +test "csi: SGR mixed colon and semicolon with blank" { + var p = init(); + _ = p.next(0x1B); + for ("[;4:3;38;2;175;175;215;58:2::190:80:70") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('m'); + try testing.expect(p.state == .ground); + try testing.expect(a[0] == null); + try testing.expect(a[1].? == .csi_dispatch); + try testing.expect(a[2] == null); + + const d = a[1].?.csi_dispatch; + try testing.expect(d.final == 'm'); + try testing.expectEqual(14, d.params.len); + try testing.expectEqual(@as(u16, 0), d.params[0]); + try testing.expect(!d.params_sep.isSet(0)); + try testing.expectEqual(@as(u16, 4), d.params[1]); + try testing.expect(d.params_sep.isSet(1)); + try testing.expectEqual(@as(u16, 3), d.params[2]); + try testing.expect(!d.params_sep.isSet(2)); + try testing.expectEqual(@as(u16, 38), d.params[3]); + try testing.expect(!d.params_sep.isSet(3)); + try testing.expectEqual(@as(u16, 2), d.params[4]); + try testing.expect(!d.params_sep.isSet(4)); + try testing.expectEqual(@as(u16, 175), d.params[5]); + try testing.expect(!d.params_sep.isSet(5)); + try testing.expectEqual(@as(u16, 175), d.params[6]); + try testing.expect(!d.params_sep.isSet(6)); + try testing.expectEqual(@as(u16, 215), d.params[7]); + try testing.expect(!d.params_sep.isSet(7)); + try testing.expectEqual(@as(u16, 58), d.params[8]); + try testing.expect(d.params_sep.isSet(8)); + try testing.expectEqual(@as(u16, 2), d.params[9]); + try testing.expect(d.params_sep.isSet(9)); + try testing.expectEqual(@as(u16, 0), d.params[10]); + try testing.expect(d.params_sep.isSet(10)); + try testing.expectEqual(@as(u16, 190), d.params[11]); + try testing.expect(d.params_sep.isSet(11)); + try testing.expectEqual(@as(u16, 80), d.params[12]); + try testing.expect(d.params_sep.isSet(12)); + try testing.expectEqual(@as(u16, 70), d.params[13]); + try testing.expect(!d.params_sep.isSet(13)); } } diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index cdf39657b..52bfb2c31 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -1,13 +1,17 @@ //! SGR (Select Graphic Rendition) attrinvbute parsing and types. const std = @import("std"); +const assert = std.debug.assert; const testing = std.testing; const color = @import("color.zig"); +const SepList = @import("Parser.zig").Action.CSI.SepList; /// Attribute type for SGR pub const Attribute = union(enum) { + pub const Tag = std.meta.FieldEnum(Attribute); + /// Unset all attributes - unset: void, + unset, /// Unknown attribute, the raw CSI command parameters are here. unknown: struct { @@ -19,43 +23,43 @@ pub const Attribute = union(enum) { }, /// Bold the text. - bold: void, - reset_bold: void, + bold, + reset_bold, /// Italic text. - italic: void, - reset_italic: void, + italic, + reset_italic, /// Faint/dim text. /// Note: reset faint is the same SGR code as reset bold - faint: void, + faint, /// Underline the text underline: Underline, - reset_underline: void, + reset_underline, underline_color: color.RGB, @"256_underline_color": u8, - reset_underline_color: void, + reset_underline_color, // Overline the text - overline: void, - reset_overline: void, + overline, + reset_overline, /// Blink the text - blink: void, - reset_blink: void, + blink, + reset_blink, /// Invert fg/bg colors. - inverse: void, - reset_inverse: void, + inverse, + reset_inverse, /// Invisible - invisible: void, - reset_invisible: void, + invisible, + reset_invisible, /// Strikethrough the text. - strikethrough: void, - reset_strikethrough: void, + strikethrough, + reset_strikethrough, /// Set foreground color as RGB values. direct_color_fg: color.RGB, @@ -68,8 +72,8 @@ pub const Attribute = union(enum) { @"8_fg": color.Name, /// Reset the fg/bg to their default values. - reset_fg: void, - reset_bg: void, + reset_fg, + reset_bg, /// Set the background/foreground as a named bright color attribute. @"8_bright_bg": color.Name, @@ -94,11 +98,9 @@ pub const Attribute = union(enum) { /// Parser parses the attributes from a list of SGR parameters. pub const Parser = struct { params: []const u16, + params_sep: SepList = SepList.initEmpty(), idx: usize = 0, - /// True if the separator is a colon - colon: bool = false, - /// Next returns the next attribute or null if there are no more attributes. pub fn next(self: *Parser) ?Attribute { if (self.idx > self.params.len) return null; @@ -106,220 +108,261 @@ pub const Parser = struct { // Implicitly means unset if (self.params.len == 0) { self.idx += 1; - return Attribute{ .unset = {} }; + return .unset; } const slice = self.params[self.idx..self.params.len]; + const colon = self.params_sep.isSet(self.idx); self.idx += 1; // Our last one will have an idx be the last value. if (slice.len == 0) return null; + // If we have a colon separator then we need to ensure we're + // parsing a value that allows it. + if (colon) switch (slice[0]) { + 4, 38, 48, 58 => {}, + + else => { + // Consume all the colon separated values. + const start = self.idx; + while (self.params_sep.isSet(self.idx)) self.idx += 1; + self.idx += 1; + return .{ .unknown = .{ + .full = self.params, + .partial = slice[0 .. self.idx - start + 1], + } }; + }, + }; + switch (slice[0]) { - 0 => return Attribute{ .unset = {} }, + 0 => return .unset, - 1 => return Attribute{ .bold = {} }, + 1 => return .bold, - 2 => return Attribute{ .faint = {} }, + 2 => return .faint, - 3 => return Attribute{ .italic = {} }, + 3 => return .italic, - 4 => blk: { - if (self.colon) { - switch (slice.len) { - // 0 is unreachable because we're here and we read - // an element to get here. - 0 => unreachable, + 4 => underline: { + if (colon) { + assert(slice.len >= 2); + if (self.isColon()) { + self.consumeUnknownColon(); + break :underline; + } - // 1 is possible if underline is the last element. - 1 => return Attribute{ .underline = .single }, + self.idx += 1; + switch (slice[1]) { + 0 => return .reset_underline, + 1 => return .{ .underline = .single }, + 2 => return .{ .underline = .double }, + 3 => return .{ .underline = .curly }, + 4 => return .{ .underline = .dotted }, + 5 => return .{ .underline = .dashed }, - // 2 means we have a specific underline style. - 2 => { - self.idx += 1; - switch (slice[1]) { - 0 => return Attribute{ .reset_underline = {} }, - 1 => return Attribute{ .underline = .single }, - 2 => return Attribute{ .underline = .double }, - 3 => return Attribute{ .underline = .curly }, - 4 => return Attribute{ .underline = .dotted }, - 5 => return Attribute{ .underline = .dashed }, - - // For unknown underline styles, just render - // a single underline. - else => return Attribute{ .underline = .single }, - } - }, - - // Colon-separated must only be 2. - else => break :blk, + // For unknown underline styles, just render + // a single underline. + else => return .{ .underline = .single }, } } - return Attribute{ .underline = .single }; + return .{ .underline = .single }; }, - 5 => return Attribute{ .blink = {} }, + 5 => return .blink, - 6 => return Attribute{ .blink = {} }, + 6 => return .blink, - 7 => return Attribute{ .inverse = {} }, + 7 => return .inverse, - 8 => return Attribute{ .invisible = {} }, + 8 => return .invisible, - 9 => return Attribute{ .strikethrough = {} }, + 9 => return .strikethrough, - 21 => return Attribute{ .underline = .double }, + 21 => return .{ .underline = .double }, - 22 => return Attribute{ .reset_bold = {} }, + 22 => return .reset_bold, - 23 => return Attribute{ .reset_italic = {} }, + 23 => return .reset_italic, - 24 => return Attribute{ .reset_underline = {} }, + 24 => return .reset_underline, - 25 => return Attribute{ .reset_blink = {} }, + 25 => return .reset_blink, - 27 => return Attribute{ .reset_inverse = {} }, + 27 => return .reset_inverse, - 28 => return Attribute{ .reset_invisible = {} }, + 28 => return .reset_invisible, - 29 => return Attribute{ .reset_strikethrough = {} }, + 29 => return .reset_strikethrough, - 30...37 => return Attribute{ + 30...37 => return .{ .@"8_fg" = @enumFromInt(slice[0] - 30), }, 38 => if (slice.len >= 2) switch (slice[1]) { // `2` indicates direct-color (r, g, b). // We need at least 3 more params for this to make sense. - 2 => if (slice.len >= 5) { - self.idx += 4; - // When a colon separator is used, there may or may not be - // a color space identifier as the third param, which we - // need to ignore (it has no standardized behavior). - const rgb = if (slice.len == 5 or !self.colon) - slice[2..5] - else rgb: { - self.idx += 1; - break :rgb slice[3..6]; - }; + 2 => if (self.parseDirectColor( + .direct_color_fg, + slice, + colon, + )) |v| return v, - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .direct_color_fg = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - }, // `5` indicates indexed color. 5 => if (slice.len >= 3) { self.idx += 2; - return Attribute{ + return .{ .@"256_fg" = @truncate(slice[2]), }; }, else => {}, }, - 39 => return Attribute{ .reset_fg = {} }, + 39 => return .reset_fg, - 40...47 => return Attribute{ + 40...47 => return .{ .@"8_bg" = @enumFromInt(slice[0] - 40), }, 48 => if (slice.len >= 2) switch (slice[1]) { // `2` indicates direct-color (r, g, b). // We need at least 3 more params for this to make sense. - 2 => if (slice.len >= 5) { - self.idx += 4; - // When a colon separator is used, there may or may not be - // a color space identifier as the third param, which we - // need to ignore (it has no standardized behavior). - const rgb = if (slice.len == 5 or !self.colon) - slice[2..5] - else rgb: { - self.idx += 1; - break :rgb slice[3..6]; - }; + 2 => if (self.parseDirectColor( + .direct_color_bg, + slice, + colon, + )) |v| return v, - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .direct_color_bg = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - }, // `5` indicates indexed color. 5 => if (slice.len >= 3) { self.idx += 2; - return Attribute{ + return .{ .@"256_bg" = @truncate(slice[2]), }; }, else => {}, }, - 49 => return Attribute{ .reset_bg = {} }, + 49 => return .reset_bg, - 53 => return Attribute{ .overline = {} }, - 55 => return Attribute{ .reset_overline = {} }, + 53 => return .overline, + 55 => return .reset_overline, 58 => if (slice.len >= 2) switch (slice[1]) { // `2` indicates direct-color (r, g, b). // We need at least 3 more params for this to make sense. - 2 => if (slice.len >= 5) { - self.idx += 4; - // When a colon separator is used, there may or may not be - // a color space identifier as the third param, which we - // need to ignore (it has no standardized behavior). - const rgb = if (slice.len == 5 or !self.colon) - slice[2..5] - else rgb: { - self.idx += 1; - break :rgb slice[3..6]; - }; + 2 => if (self.parseDirectColor( + .underline_color, + slice, + colon, + )) |v| return v, - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .underline_color = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - }, // `5` indicates indexed color. 5 => if (slice.len >= 3) { self.idx += 2; - return Attribute{ + return .{ .@"256_underline_color" = @truncate(slice[2]), }; }, else => {}, }, - 59 => return Attribute{ .reset_underline_color = {} }, + 59 => return .reset_underline_color, - 90...97 => return Attribute{ + 90...97 => return .{ // 82 instead of 90 to offset to "bright" colors .@"8_bright_fg" = @enumFromInt(slice[0] - 82), }, - 100...107 => return Attribute{ + 100...107 => return .{ .@"8_bright_bg" = @enumFromInt(slice[0] - 92), }, else => {}, } - return Attribute{ .unknown = .{ .full = self.params, .partial = slice } }; + return .{ .unknown = .{ .full = self.params, .partial = slice } }; + } + + fn parseDirectColor( + self: *Parser, + comptime tag: Attribute.Tag, + slice: []const u16, + colon: bool, + ) ?Attribute { + // Any direct color style must have at least 5 values. + if (slice.len < 5) return null; + + // Only used for direct color sets (38, 48, 58) and subparam 2. + assert(slice[1] == 2); + + // Note: We use @truncate because the value should be 0 to 255. If + // it isn't, the behavior is undefined so we just... truncate it. + + // If we don't have a colon, then we expect exactly 3 semicolon + // separated values. + if (!colon) { + self.idx += 4; + return @unionInit(Attribute, @tagName(tag), .{ + .r = @truncate(slice[2]), + .g = @truncate(slice[3]), + .b = @truncate(slice[4]), + }); + } + + // We have a colon, we might have either 5 or 6 values depending + // on if the colorspace is present. + const count = self.countColon(); + switch (count) { + 3 => { + self.idx += 4; + return @unionInit(Attribute, @tagName(tag), .{ + .r = @truncate(slice[2]), + .g = @truncate(slice[3]), + .b = @truncate(slice[4]), + }); + }, + + 4 => { + self.idx += 5; + return @unionInit(Attribute, @tagName(tag), .{ + .r = @truncate(slice[3]), + .g = @truncate(slice[4]), + .b = @truncate(slice[5]), + }); + }, + + else => { + self.consumeUnknownColon(); + return null; + }, + } + } + + /// Returns true if the present position has a colon separator. + /// This always returns false for the last value since it has no + /// separator. + fn isColon(self: *Parser) bool { + // The `- 1` here is because the last value has no separator. + if (self.idx >= self.params.len - 1) return false; + return self.params_sep.isSet(self.idx); + } + + fn countColon(self: *Parser) usize { + var count: usize = 0; + var idx = self.idx; + while (idx < self.params.len - 1 and self.params_sep.isSet(idx)) : (idx += 1) { + count += 1; + } + return count; + } + + /// Consumes all the remaining parameters separated by a colon and + /// returns an unknown attribute. + fn consumeUnknownColon(self: *Parser) void { + const count = self.countColon(); + self.idx += count + 1; } }; @@ -329,7 +372,7 @@ fn testParse(params: []const u16) Attribute { } fn testParseColon(params: []const u16) Attribute { - var p: Parser = .{ .params = params, .colon = true }; + var p: Parser = .{ .params = params, .params_sep = SepList.initFull() }; return p.next().?; } @@ -366,6 +409,35 @@ test "sgr: Parser multiple" { try testing.expect(p.next() == null); } +test "sgr: unsupported with colon" { + var p: Parser = .{ + .params = &[_]u16{ 0, 4, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(0); + break :sep list; + }, + }; + try testing.expect(p.next().? == .unknown); + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + +test "sgr: unsupported with multiple colon" { + var p: Parser = .{ + .params = &[_]u16{ 0, 4, 2, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(0); + list.set(1); + break :sep list; + }, + }; + try testing.expect(p.next().? == .unknown); + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + test "sgr: bold" { { const v = testParse(&[_]u16{1}); @@ -439,6 +511,37 @@ test "sgr: underline styles" { } } +test "sgr: underline style with more" { + var p: Parser = .{ + .params = &[_]u16{ 4, 2, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(0); + break :sep list; + }, + }; + + try testing.expect(p.next().? == .underline); + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + +test "sgr: underline style with too many colons" { + var p: Parser = .{ + .params = &[_]u16{ 4, 2, 3, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(0); + list.set(1); + break :sep list; + }, + }; + + try testing.expect(p.next().? == .unknown); + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + test "sgr: blink" { { const v = testParse(&[_]u16{5}); @@ -592,13 +695,13 @@ test "sgr: underline, bg, and fg" { test "sgr: direct color fg missing color" { // This used to crash - var p: Parser = .{ .params = &[_]u16{ 38, 5 }, .colon = false }; + var p: Parser = .{ .params = &[_]u16{ 38, 5 } }; while (p.next()) |_| {} } test "sgr: direct color bg missing color" { // This used to crash - var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false }; + var p: Parser = .{ .params = &[_]u16{ 48, 5 } }; while (p.next()) |_| {} } @@ -608,7 +711,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" { // Colon version should skip the optional color space identifier { // 3 8 : 2 : Pi : Pr : Pg : Pb - const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 }); + const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3 }); try testing.expect(v == .direct_color_fg); try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r); try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g); @@ -616,7 +719,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" { } { // 4 8 : 2 : Pi : Pr : Pg : Pb - const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 }); + const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3 }); try testing.expect(v == .direct_color_bg); try testing.expectEqual(@as(u8, 1), v.direct_color_bg.r); try testing.expectEqual(@as(u8, 2), v.direct_color_bg.g); @@ -624,7 +727,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" { } { // 5 8 : 2 : Pi : Pr : Pg : Pb - const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 }); + const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3 }); try testing.expect(v == .underline_color); try testing.expectEqual(@as(u8, 1), v.underline_color.r); try testing.expectEqual(@as(u8, 2), v.underline_color.g); @@ -634,7 +737,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" { // Semicolon version should not parse optional color space identifier { // 3 8 ; 2 ; Pr ; Pg ; Pb - const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 }); + const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3 }); try testing.expect(v == .direct_color_fg); try testing.expectEqual(@as(u8, 0), v.direct_color_fg.r); try testing.expectEqual(@as(u8, 1), v.direct_color_fg.g); @@ -642,7 +745,7 @@ test "sgr: direct fg/bg/underline ignore optional color space" { } { // 4 8 ; 2 ; Pr ; Pg ; Pb - const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 }); + const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3 }); try testing.expect(v == .direct_color_bg); try testing.expectEqual(@as(u8, 0), v.direct_color_bg.r); try testing.expectEqual(@as(u8, 1), v.direct_color_bg.g); @@ -650,10 +753,114 @@ test "sgr: direct fg/bg/underline ignore optional color space" { } { // 5 8 ; 2 ; Pr ; Pg ; Pb - const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 }); + const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3 }); try testing.expect(v == .underline_color); try testing.expectEqual(@as(u8, 0), v.underline_color.r); try testing.expectEqual(@as(u8, 1), v.underline_color.g); try testing.expectEqual(@as(u8, 2), v.underline_color.b); } } + +test "sgr: direct fg colon with too many colons" { + var p: Parser = .{ + .params = &[_]u16{ 38, 2, 0, 1, 2, 3, 4, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + for (0..6) |idx| list.set(idx); + break :sep list; + }, + }; + + try testing.expect(p.next().? == .unknown); + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + +test "sgr: direct fg colon with colorspace and extra param" { + var p: Parser = .{ + .params = &[_]u16{ 38, 2, 0, 1, 2, 3, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + for (0..5) |idx| list.set(idx); + break :sep list; + }, + }; + + { + const v = p.next().?; + std.log.warn("WHAT={}", .{v}); + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b); + } + + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + +test "sgr: direct fg colon no colorspace and extra param" { + var p: Parser = .{ + .params = &[_]u16{ 38, 2, 1, 2, 3, 1 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + for (0..4) |idx| list.set(idx); + break :sep list; + }, + }; + + { + const v = p.next().?; + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b); + } + + try testing.expect(p.next().? == .bold); + try testing.expect(p.next() == null); +} + +// Kakoune sent this complex SGR sequence that caused invalid behavior. +test "sgr: kakoune input" { + // This used to crash + var p: Parser = .{ + .params = &[_]u16{ 0, 4, 3, 38, 2, 175, 175, 215, 58, 2, 0, 190, 80, 70 }, + .params_sep = sep: { + var list = SepList.initEmpty(); + list.set(1); + list.set(8); + list.set(9); + list.set(10); + list.set(11); + list.set(12); + break :sep list; + }, + }; + + { + const v = p.next().?; + try testing.expect(v == .unset); + } + { + const v = p.next().?; + try testing.expect(v == .underline); + try testing.expectEqual(Attribute.Underline.curly, v.underline); + } + { + const v = p.next().?; + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 175), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 175), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 215), v.direct_color_fg.b); + } + { + const v = p.next().?; + try testing.expect(v == .underline_color); + try testing.expectEqual(@as(u8, 190), v.underline_color.r); + try testing.expectEqual(@as(u8, 80), v.underline_color.g); + try testing.expectEqual(@as(u8, 70), v.underline_color.b); + } + + //try testing.expect(p.next() == null); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 5657d63f4..eb5ab2c65 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -253,15 +253,11 @@ pub fn Stream(comptime Handler: type) type { // A parameter separator: ':', ';' => if (self.parser.params_idx < 16) { self.parser.params[self.parser.params_idx] = self.parser.param_acc; + if (c == ':') self.parser.params_sep.set(self.parser.params_idx); self.parser.params_idx += 1; self.parser.param_acc = 0; self.parser.param_acc_idx = 0; - - // Keep track of separator state. - const sep: Parser.ParamSepState = @enumFromInt(c); - if (self.parser.params_idx == 1) self.parser.params_sep = sep; - if (self.parser.params_sep != sep) self.parser.params_sep = .mixed; }, // Explicitly ignored: 0x7F => {}, @@ -937,7 +933,10 @@ pub fn Stream(comptime Handler: type) type { 'm' => switch (input.intermediates.len) { 0 => if (@hasDecl(T, "setAttribute")) { // log.info("parse SGR params={any}", .{action.params}); - var p: sgr.Parser = .{ .params = input.params, .colon = input.sep == .colon }; + var p: sgr.Parser = .{ + .params = input.params, + .params_sep = input.params_sep, + }; while (p.next()) |attr| { // log.info("SGR attribute: {}", .{attr}); try self.handler.setAttribute(attr); From 5cf7575967e9075d0389e8c0b547aa2e6ce49170 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 9 Jan 2025 23:39:40 -0500 Subject: [PATCH 158/238] fix(PageList): when cloning, explicitly set cols Otherwise pages may have the wrong width if they were resized down with a fast path that just chanes the size without adjusting capacity at all. --- src/terminal/PageList.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 260733b94..b838332b0 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -520,6 +520,7 @@ pub fn clone( assert(node.data.capacity.rows >= chunk.end - chunk.start); defer node.data.assertIntegrity(); node.data.size.rows = chunk.end - chunk.start; + node.data.size.cols = chunk.node.data.size.cols; try node.data.cloneFrom( &chunk.node.data, chunk.start, From fca336c32d6e6659b04803c7e3a1f1ad1378b840 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 10 Jan 2025 13:43:02 -0500 Subject: [PATCH 159/238] Metal: blend in Display P3 color space, add option for linear blending This commit is quite large because it's fairly interconnected and can't be split up in a logical way. The main part of this commit is that alpha blending is now always done in the Display P3 color space, and depending on the configured `window-colorspace` colors will be converted from sRGB or assumed to already be Display P3 colors. In addition, a config option `text-blending` has been added which allows the user to configure linear blending (AKA "gamma correction"). Linear alpha blending also applies to images and makes custom shaders receive linear colors rather than sRGB. In addition, an experimental option has been added which corrects linear blending's tendency to make dark text look too thin and bright text look too thick. Essentially it's a correction curve on the alpha channel that depends on the luminance of the glyph being drawn. --- .../QuickTerminalController.swift | 16 - .../Terminal/TerminalController.swift | 13 - macos/Sources/Ghostty/Ghostty.Config.swift | 9 - pkg/macos/graphics/color_space.zig | 63 ++++ src/config/Config.zig | 40 +++ src/font/face/coretext.zig | 7 +- src/renderer/Metal.zig | 320 ++++++++++++------ src/renderer/metal/api.zig | 1 + src/renderer/metal/shaders.zig | 75 ++-- src/renderer/shaders/cell.metal | 293 +++++++++++++--- 10 files changed, 631 insertions(+), 206 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index c23aad755..bc89022f5 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -375,19 +375,6 @@ class QuickTerminalController: BaseTerminalController { // Some APIs such as window blur have no effect unless the window is visible. guard window.isVisible else { return } - // Terminals typically operate in sRGB color space and macOS defaults - // to "native" which is typically P3. There is a lot more resources - // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 - // Ghostty defaults to sRGB but this can be overridden. - switch (self.derivedConfig.windowColorspace) { - case "display-p3": - window.colorSpace = .displayP3 - case "srgb": - fallthrough - default: - window.colorSpace = .sRGB - } - // If we have window transparency then set it transparent. Otherwise set it opaque. if (self.derivedConfig.backgroundOpacity < 1) { window.isOpaque = false @@ -457,7 +444,6 @@ class QuickTerminalController: BaseTerminalController { let quickTerminalAnimationDuration: Double let quickTerminalAutoHide: Bool let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior - let windowColorspace: String let backgroundOpacity: Double init() { @@ -465,7 +451,6 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalAnimationDuration = 0.2 self.quickTerminalAutoHide = true self.quickTerminalSpaceBehavior = .move - self.windowColorspace = "" self.backgroundOpacity = 1.0 } @@ -474,7 +459,6 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration self.quickTerminalAutoHide = config.quickTerminalAutoHide self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior - self.windowColorspace = config.windowColorspace self.backgroundOpacity = config.backgroundOpacity } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 08306a854..89da6bfeb 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -366,19 +366,6 @@ class TerminalController: BaseTerminalController { // If window decorations are disabled, remove our title if (!config.windowDecorations) { window.styleMask.remove(.titled) } - // Terminals typically operate in sRGB color space and macOS defaults - // to "native" which is typically P3. There is a lot more resources - // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 - // Ghostty defaults to sRGB but this can be overridden. - switch (config.windowColorspace) { - case "display-p3": - window.colorSpace = .displayP3 - case "srgb": - fallthrough - default: - window.colorSpace = .sRGB - } - // If we have only a single surface (no splits) and that surface requested // an initial size then we set it here now. if case let .leaf(leaf) = surfaceTree { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 1b3263fc3..d6e1710ae 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -132,15 +132,6 @@ extension Ghostty { return v } - var windowColorspace: String { - guard let config = self.config else { return "" } - var v: UnsafePointer? = nil - let key = "window-colorspace" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } - guard let ptr = v else { return "" } - return String(cString: ptr) - } - var windowSaveState: String { guard let config = self.config else { return "" } var v: UnsafePointer? = nil diff --git a/pkg/macos/graphics/color_space.zig b/pkg/macos/graphics/color_space.zig index 459f06302..16960591b 100644 --- a/pkg/macos/graphics/color_space.zig +++ b/pkg/macos/graphics/color_space.zig @@ -18,9 +18,72 @@ pub const ColorSpace = opaque { ) orelse Allocator.Error.OutOfMemory; } + pub fn createNamed(name: Name) Allocator.Error!*ColorSpace { + return @as( + ?*ColorSpace, + @ptrFromInt(@intFromPtr(c.CGColorSpaceCreateWithName(name.cfstring()))), + ) orelse Allocator.Error.OutOfMemory; + } + pub fn release(self: *ColorSpace) void { c.CGColorSpaceRelease(@ptrCast(self)); } + + pub const Name = enum { + /// This color space uses the DCI P3 primaries, a D65 white point, and + /// the sRGB transfer function. + displayP3, + /// The Display P3 color space with a linear transfer function and + /// extended-range values. + extendedLinearDisplayP3, + /// The sRGB colorimetry and non-linear transfer function are specified + /// in IEC 61966-2-1. + sRGB, + /// This color space has the same colorimetry as `sRGB`, but uses a + /// linear transfer function. + linearSRGB, + /// This color space has the same colorimetry as `sRGB`, but you can + /// encode component values below `0.0` and above `1.0`. Negative values + /// are encoded as the signed reflection of the original encoding + /// function, as shown in the formula below: + /// ``` + /// extendedTransferFunction(x) = sign(x) * sRGBTransferFunction(abs(x)) + /// ``` + extendedSRGB, + /// This color space has the same colorimetry as `sRGB`; in addition, + /// you may encode component values below `0.0` and above `1.0`. + extendedLinearSRGB, + /// ... + genericGrayGamma2_2, + /// ... + linearGray, + /// This color space has the same colorimetry as `genericGrayGamma2_2`, + /// but you can encode component values below `0.0` and above `1.0`. + /// Negative values are encoded as the signed reflection of the + /// original encoding function, as shown in the formula below: + /// ``` + /// extendedGrayTransferFunction(x) = sign(x) * gamma22Function(abs(x)) + /// ``` + extendedGray, + /// This color space has the same colorimetry as `linearGray`; in + /// addition, you may encode component values below `0.0` and above `1.0`. + extendedLinearGray, + + fn cfstring(self: Name) c.CFStringRef { + return switch (self) { + .displayP3 => c.kCGColorSpaceDisplayP3, + .extendedLinearDisplayP3 => c.kCGColorSpaceExtendedLinearDisplayP3, + .sRGB => c.kCGColorSpaceSRGB, + .extendedSRGB => c.kCGColorSpaceExtendedSRGB, + .linearSRGB => c.kCGColorSpaceLinearSRGB, + .extendedLinearSRGB => c.kCGColorSpaceExtendedLinearSRGB, + .genericGrayGamma2_2 => c.kCGColorSpaceGenericGrayGamma2_2, + .extendedGray => c.kCGColorSpaceExtendedGray, + .linearGray => c.kCGColorSpaceLinearGray, + .extendedLinearGray => c.kCGColorSpaceExtendedLinearGray, + }; + } + }; }; test { diff --git a/src/config/Config.zig b/src/config/Config.zig index 6c5b64316..eabae9052 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -248,6 +248,40 @@ const c = @cImport({ /// This is currently only supported on macOS. @"font-thicken-strength": u8 = 255, +/// What color space to use when performing alpha blending. +/// +/// This affects how text looks for different background/foreground color pairs. +/// +/// Valid values: +/// +/// * `native` - Perform alpha blending in the native color space for the OS. +/// On macOS this corresponds to Display P3, and on Linux it's sRGB. +/// +/// * `linear` - Perform alpha blending in linear space. This will eliminate +/// the darkening artifacts around the edges of text that are very visible +/// when certain color combinations are used (e.g. red / green), but makes +/// dark text look much thinner than normal and light text much thicker. +/// This is also sometimes known as "gamma correction". +/// (Currently only supported on macOS. Has no effect on Linux.) +/// +/// To prevent the uneven thickness caused by linear blending, you can use +/// the `experimental-linear-correction` option which applies a correction +/// curve to the text alpha depending on its brightness, which compensates +/// for the thinning and makes the weight of most text appear very similar +/// to when it's blendeded non-linearly. +/// +/// Note: This setting affects more than just text, images will also be blended +/// in the selected color space, and custom shaders will receive colors in that +/// color space as well. +@"text-blending": TextBlending = .native, + +/// Apply a correction curve to text alpha to compensate for uneven apparent +/// thickness of different colors of text, roughly matching the appearance of +/// text rendered with non-linear blending. +/// +/// Has no effect if not using linear `text-blending`. +@"experimental-linear-correction": bool = false, + /// All of the configurations behavior adjust various metrics determined by the /// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%, /// etc.). In each case, the values represent the amount to change the original @@ -5749,6 +5783,12 @@ pub const GraphemeWidthMethod = enum { unicode, }; +/// See text-blending +pub const TextBlending = enum { + native, + linear, +}; + /// See freetype-load-flag pub const FreetypeLoadFlags = packed struct { // The defaults here at the time of writing this match the defaults diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 6661295f3..3749b4824 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -343,13 +343,12 @@ pub const Face = struct { } = if (!self.isColorGlyph(glyph_index)) .{ .color = false, .depth = 1, - .space = try macos.graphics.ColorSpace.createDeviceGray(), - .context_opts = @intFromEnum(macos.graphics.BitmapInfo.alpha_mask) & - @intFromEnum(macos.graphics.ImageAlphaInfo.only), + .space = try macos.graphics.ColorSpace.createNamed(.linearGray), + .context_opts = @intFromEnum(macos.graphics.ImageAlphaInfo.only), } else .{ .color = true, .depth = 4, - .space = try macos.graphics.ColorSpace.createDeviceRGB(), + .space = try macos.graphics.ColorSpace.createNamed(.displayP3), .context_opts = @intFromEnum(macos.graphics.BitmapInfo.byte_order_32_little) | @intFromEnum(macos.graphics.ImageAlphaInfo.premultiplied_first), }; diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 09dafd1fc..707fe8e46 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -21,6 +21,7 @@ const renderer = @import("../renderer.zig"); const math = @import("../math.zig"); const Surface = @import("../Surface.zig"); const link = @import("link.zig"); +const graphics = macos.graphics; const fgMode = @import("cell.zig").fgMode; const isCovering = @import("cell.zig").isCovering; const shadertoy = @import("shadertoy.zig"); @@ -105,10 +106,6 @@ default_cursor_color: ?terminal.color.RGB, /// foreground color as the cursor color. cursor_invert: bool, -/// The current frame background color. This is only updated during -/// the updateFrame method. -current_background_color: terminal.color.RGB, - /// The current set of cells to render. This is rebuilt on every frame /// but we keep this around so that we don't reallocate. Each set of /// cells goes into a separate shader. @@ -390,6 +387,9 @@ pub const DerivedConfig = struct { custom_shaders: configpkg.RepeatablePath, links: link.Set, vsync: bool, + colorspace: configpkg.Config.WindowColorspace, + blending: configpkg.Config.TextBlending, + experimental_linear_correction: bool, pub fn init( alloc_gpa: Allocator, @@ -460,6 +460,9 @@ pub const DerivedConfig = struct { .custom_shaders = custom_shaders, .links = links, .vsync = config.@"window-vsync", + .colorspace = config.@"window-colorspace", + .blending = config.@"text-blending", + .experimental_linear_correction = config.@"text-blending" == .linear and config.@"experimental-linear-correction", .arena = arena, }; @@ -490,10 +493,6 @@ pub fn surfaceInit(surface: *apprt.Surface) !void { } pub fn init(alloc: Allocator, options: renderer.Options) !Metal { - var arena = ArenaAllocator.init(alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - const ViewInfo = struct { view: objc.Object, scaleFactor: f64, @@ -512,7 +511,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { nswindow.getProperty(?*anyopaque, "contentView").?, ); const scaleFactor = nswindow.getProperty( - macos.graphics.c.CGFloat, + graphics.c.CGFloat, "backingScaleFactor", ); @@ -553,6 +552,29 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { layer.setProperty("opaque", options.config.background_opacity >= 1); layer.setProperty("displaySyncEnabled", options.config.vsync); + // Set our layer's pixel format appropriately. + layer.setProperty( + "pixelFormat", + // Using an `*_srgb` pixel format makes Metal gamma encode + // the pixels written to it *after* blending, which means + // we get linear alpha blending rather than gamma-incorrect + // blending. + if (options.config.blending == .linear) + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) + else + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), + ); + + // Set our layer's color space to Display P3. + // This allows us to have "Apple-style" alpha blending, + // since it seems to be the case that Apple apps like + // Terminal and TextEdit render text in the display's + // color space using converted colors, which reduces, + // but does not fully eliminate blending artifacts. + const colorspace = try graphics.ColorSpace.createNamed(.displayP3); + errdefer colorspace.release(); + layer.setProperty("colorspace", colorspace); + // Make our view layer-backed with our Metal layer. On iOS views are // always layer backed so we don't need to do this. But on iOS the // caller MUST be sure to set the layerClass to CAMetalLayer. @@ -578,54 +600,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { }); errdefer font_shaper.deinit(); - // Load our custom shaders - const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles( - arena_alloc, - options.config.custom_shaders, - .msl, - ) catch |err| err: { - log.warn("error loading custom shaders err={}", .{err}); - break :err &.{}; - }; - - // If we have custom shaders then setup our state - var custom_shader_state: ?CustomShaderState = state: { - if (custom_shaders.len == 0) break :state null; - - // Build our sampler for our texture - var sampler = try mtl_sampler.Sampler.init(gpu_state.device); - errdefer sampler.deinit(); - - break :state .{ - // Resolution and screen textures will be fixed up by first - // call to setScreenSize. Draw calls will bail out early if - // the screen size hasn't been set yet, so it won't error. - .front_texture = undefined, - .back_texture = undefined, - .sampler = sampler, - .uniforms = .{ - .resolution = .{ 0, 0, 1 }, - .time = 1, - .time_delta = 1, - .frame_rate = 1, - .frame = 1, - .channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, - .channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, - .mouse = .{ 0, 0, 0, 0 }, - .date = .{ 0, 0, 0, 0 }, - .sample_rate = 1, - }, - - .first_frame_time = try std.time.Instant.now(), - .last_frame_time = try std.time.Instant.now(), - }; - }; - errdefer if (custom_shader_state) |*state| state.deinit(); - - // Initialize our shaders - var shaders = try Shaders.init(alloc, gpu_state.device, custom_shaders); - errdefer shaders.deinit(alloc); - // Initialize all the data that requires a critical font section. const font_critical: struct { metrics: font.Metrics, @@ -661,7 +635,6 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { .cursor_color = null, .default_cursor_color = options.config.cursor_color, .cursor_invert = options.config.cursor_invert, - .current_background_color = options.config.background, // Render state .cells = .{}, @@ -674,7 +647,16 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { .min_contrast = options.config.min_contrast, .cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) }, .cursor_color = undefined, + .bg_color = .{ + options.config.background.r, + options.config.background.g, + options.config.background.b, + @intFromFloat(@round(options.config.background_opacity * 255.0)), + }, .cursor_wide = false, + .use_display_p3 = options.config.colorspace == .@"display-p3", + .use_linear_blending = options.config.blending == .linear, + .use_experimental_linear_correction = options.config.experimental_linear_correction, }, // Fonts @@ -682,16 +664,18 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { .font_shaper = font_shaper, .font_shaper_cache = font.ShaperCache.init(), - // Shaders - .shaders = shaders, + // Shaders (initialized below) + .shaders = undefined, // Metal stuff .layer = layer, .display_link = display_link, - .custom_shader_state = custom_shader_state, + .custom_shader_state = null, .gpu_state = gpu_state, }; + try result.initShaders(); + // Do an initialize screen size setup to ensure our undefined values // above are initialized. try result.setScreenSize(result.size); @@ -723,11 +707,82 @@ pub fn deinit(self: *Metal) void { } self.image_placements.deinit(self.alloc); + self.deinitShaders(); + + self.* = undefined; +} + +fn deinitShaders(self: *Metal) void { if (self.custom_shader_state) |*state| state.deinit(); self.shaders.deinit(self.alloc); +} - self.* = undefined; +fn initShaders(self: *Metal) !void { + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + + // Load our custom shaders + const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles( + arena_alloc, + self.config.custom_shaders, + .msl, + ) catch |err| err: { + log.warn("error loading custom shaders err={}", .{err}); + break :err &.{}; + }; + + var custom_shader_state: ?CustomShaderState = state: { + if (custom_shaders.len == 0) break :state null; + + // Build our sampler for our texture + var sampler = try mtl_sampler.Sampler.init(self.gpu_state.device); + errdefer sampler.deinit(); + + break :state .{ + // Resolution and screen textures will be fixed up by first + // call to setScreenSize. Draw calls will bail out early if + // the screen size hasn't been set yet, so it won't error. + .front_texture = undefined, + .back_texture = undefined, + .sampler = sampler, + .uniforms = .{ + .resolution = .{ 0, 0, 1 }, + .time = 1, + .time_delta = 1, + .frame_rate = 1, + .frame = 1, + .channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, + .channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4, + .mouse = .{ 0, 0, 0, 0 }, + .date = .{ 0, 0, 0, 0 }, + .sample_rate = 1, + }, + + .first_frame_time = try std.time.Instant.now(), + .last_frame_time = try std.time.Instant.now(), + }; + }; + errdefer if (custom_shader_state) |*state| state.deinit(); + + var shaders = try Shaders.init( + self.alloc, + self.gpu_state.device, + custom_shaders, + // Using an `*_srgb` pixel format makes Metal gamma encode + // the pixels written to it *after* blending, which means + // we get linear alpha blending rather than gamma-incorrect + // blending. + if (self.config.blending == .linear) + mtl.MTLPixelFormat.bgra8unorm_srgb + else + mtl.MTLPixelFormat.bgra8unorm, + ); + errdefer shaders.deinit(self.alloc); + + self.shaders = shaders; + self.custom_shader_state = custom_shader_state; } /// This is called just prior to spinning up the renderer thread for @@ -1111,7 +1166,12 @@ pub fn updateFrame( self.cells_viewport = critical.viewport_pin; // Update our background color - self.current_background_color = critical.bg; + self.uniforms.bg_color = .{ + critical.bg.r, + critical.bg.g, + critical.bg.b, + @intFromFloat(@round(self.config.background_opacity * 255.0)), + }; // Go through our images and see if we need to setup any textures. { @@ -1233,10 +1293,10 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void { attachment.setProperty("storeAction", @intFromEnum(mtl.MTLStoreAction.store)); attachment.setProperty("texture", screen_texture.value); attachment.setProperty("clearColor", mtl.MTLClearColor{ - .red = @as(f32, @floatFromInt(self.current_background_color.r)) / 255 * self.config.background_opacity, - .green = @as(f32, @floatFromInt(self.current_background_color.g)) / 255 * self.config.background_opacity, - .blue = @as(f32, @floatFromInt(self.current_background_color.b)) / 255 * self.config.background_opacity, - .alpha = self.config.background_opacity, + .red = 0.0, + .green = 0.0, + .blue = 0.0, + .alpha = 0.0, }); } @@ -1252,19 +1312,19 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void { defer encoder.msgSend(void, objc.sel("endEncoding"), .{}); // Draw background images first - try self.drawImagePlacements(encoder, self.image_placements.items[0..self.image_bg_end]); + try self.drawImagePlacements(encoder, frame, self.image_placements.items[0..self.image_bg_end]); // Then draw background cells try self.drawCellBgs(encoder, frame); // Then draw images under text - try self.drawImagePlacements(encoder, self.image_placements.items[self.image_bg_end..self.image_text_end]); + try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_bg_end..self.image_text_end]); // Then draw fg cells try self.drawCellFgs(encoder, frame, fg_count); // Then draw remaining images - try self.drawImagePlacements(encoder, self.image_placements.items[self.image_text_end..]); + try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_text_end..]); } // If we have custom shaders, then we render them. @@ -1457,6 +1517,7 @@ fn drawPostShader( fn drawImagePlacements( self: *Metal, encoder: objc.Object, + frame: *const FrameState, placements: []const mtl_image.Placement, ) !void { if (placements.len == 0) return; @@ -1468,15 +1529,16 @@ fn drawImagePlacements( .{self.shaders.image_pipeline.value}, ); - // Set our uniform, which is the only shared buffer + // Set our uniforms encoder.msgSend( void, - objc.sel("setVertexBytes:length:atIndex:"), - .{ - @as(*const anyopaque, @ptrCast(&self.uniforms)), - @as(c_ulong, @sizeOf(@TypeOf(self.uniforms))), - @as(c_ulong, 1), - }, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, + ); + encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, ); for (placements) |placement| { @@ -1588,6 +1650,11 @@ fn drawCellBgs( ); // Set our buffers + encoder.msgSend( + void, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, + ); encoder.msgSend( void, objc.sel("setFragmentBuffer:offset:atIndex:"), @@ -1647,18 +1714,17 @@ fn drawCellFgs( encoder.msgSend( void, objc.sel("setFragmentTexture:atIndex:"), - .{ - frame.grayscale.value, - @as(c_ulong, 0), - }, + .{ frame.grayscale.value, @as(c_ulong, 0) }, ); encoder.msgSend( void, objc.sel("setFragmentTexture:atIndex:"), - .{ - frame.color.value, - @as(c_ulong, 1), - }, + .{ frame.color.value, @as(c_ulong, 1) }, + ); + encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 2) }, ); encoder.msgSend( @@ -2003,17 +2069,48 @@ pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void { // Set our new minimum contrast self.uniforms.min_contrast = config.min_contrast; + // Set our new color space and blending + self.uniforms.use_display_p3 = config.colorspace == .@"display-p3"; + self.uniforms.use_linear_blending = config.blending == .linear; + + self.uniforms.use_experimental_linear_correction = config.experimental_linear_correction; + // Set our new colors self.default_background_color = config.background; self.default_foreground_color = config.foreground; self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; self.cursor_invert = config.cursor_invert; + const old_blending = self.config.blending; + const old_custom_shaders = self.config.custom_shaders; + self.config.deinit(); self.config = config.*; // Reset our viewport to force a rebuild, in case of a font change. self.cells_viewport = null; + + // We reinitialize our shaders if our + // blending or custom shaders changed. + if (old_blending != config.blending or + !old_custom_shaders.equal(config.custom_shaders)) + { + self.deinitShaders(); + try self.initShaders(); + // We call setScreenSize to reinitialize + // the textures used for custom shaders. + if (self.custom_shader_state != null) { + try self.setScreenSize(self.size); + } + // And we update our layer's pixel format appropriately. + self.layer.setProperty( + "pixelFormat", + if (config.blending == .linear) + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) + else + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), + ); + } } /// Resize the screen. @@ -2057,7 +2154,7 @@ pub fn setScreenSize( } // Set the size of the drawable surface to the bounds - self.layer.setProperty("drawableSize", macos.graphics.Size{ + self.layer.setProperty("drawableSize", graphics.Size{ .width = @floatFromInt(size.screen.width), .height = @floatFromInt(size.screen.height), }); @@ -2089,7 +2186,11 @@ pub fn setScreenSize( .min_contrast = old.min_contrast, .cursor_pos = old.cursor_pos, .cursor_color = old.cursor_color, + .bg_color = old.bg_color, .cursor_wide = old.cursor_wide, + .use_display_p3 = old.use_display_p3, + .use_linear_blending = old.use_linear_blending, + .use_experimental_linear_correction = old.use_experimental_linear_correction, }; // Reset our cell contents if our grid size has changed. @@ -2124,7 +2225,17 @@ pub fn setScreenSize( const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); break :init id_init; }; - desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm)); + desc.setProperty( + "pixelFormat", + // Using an `*_srgb` pixel format makes Metal gamma encode + // the pixels written to it *after* blending, which means + // we get linear alpha blending rather than gamma-incorrect + // blending. + if (self.config.blending == .linear) + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) + else + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), + ); desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width))); desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height))); desc.setProperty( @@ -2154,7 +2265,17 @@ pub fn setScreenSize( const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); break :init id_init; }; - desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.bgra8unorm)); + desc.setProperty( + "pixelFormat", + // Using an `*_srgb` pixel format makes Metal gamma encode + // the pixels written to it *after* blending, which means + // we get linear alpha blending rather than gamma-incorrect + // blending. + if (self.config.blending == .linear) + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) + else + @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), + ); desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width))); desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height))); desc.setProperty( @@ -2466,8 +2587,10 @@ fn rebuildCells( // Foreground alpha for this cell. const alpha: u8 = if (style.flags.faint) 175 else 255; - // If the cell has a background color, set it. - if (bg) |rgb| { + // Set the cell's background color. + { + const rgb = bg orelse self.background_color orelse self.default_background_color; + // Determine our background alpha. If we have transparency configured // then this is dynamic depending on some situations. This is all // in an attempt to make transparency look the best for various @@ -2477,23 +2600,20 @@ fn rebuildCells( if (self.config.background_opacity >= 1) break :bg_alpha default; - // If we're selected, we do not apply background opacity + // Cells that are selected should be fully opaque. if (selected) break :bg_alpha default; - // If we're reversed, do not apply background opacity + // Cells that are reversed should be fully opaque. if (style.flags.inverse) break :bg_alpha default; - // If we have a background and its not the default background - // then we apply background opacity - if (style.bg(cell, color_palette) != null and !rgb.eql(self.background_color orelse self.default_background_color)) { + // Cells that have an explicit bg color, which does not + // match the current surface bg, should be fully opaque. + if (bg != null and !rgb.eql(self.background_color orelse self.default_background_color)) { break :bg_alpha default; } - // We apply background opacity. - var bg_alpha: f64 = @floatFromInt(default); - bg_alpha *= self.config.background_opacity; - bg_alpha = @ceil(bg_alpha); - break :bg_alpha @intFromFloat(bg_alpha); + // Otherwise, we use the configured background opacity. + break :bg_alpha @intFromFloat(@round(self.config.background_opacity * 255.0)); }; self.cells.bgCell(y, x).* = .{ diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index 48056ae5e..6ab42bbd6 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -74,6 +74,7 @@ pub const MTLPixelFormat = enum(c_ulong) { rgba8unorm = 70, rgba8uint = 73, bgra8unorm = 80, + bgra8unorm_srgb = 81, }; /// https://developer.apple.com/documentation/metal/mtlpurgeablestate?language=objc diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index b909a2f2a..62d363173 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -13,9 +13,7 @@ const log = std.log.scoped(.metal); pub const Shaders = struct { library: objc.Object, - /// The cell shader is the shader used to render the terminal cells. - /// It is a single shader that is used for both the background and - /// foreground. + /// Renders cell foreground elements (text, decorations). cell_text_pipeline: objc.Object, /// The cell background shader is the shader used to render the @@ -40,17 +38,18 @@ pub const Shaders = struct { alloc: Allocator, device: objc.Object, post_shaders: []const [:0]const u8, + pixel_format: mtl.MTLPixelFormat, ) !Shaders { const library = try initLibrary(device); errdefer library.msgSend(void, objc.sel("release"), .{}); - const cell_text_pipeline = try initCellTextPipeline(device, library); + const cell_text_pipeline = try initCellTextPipeline(device, library, pixel_format); errdefer cell_text_pipeline.msgSend(void, objc.sel("release"), .{}); - const cell_bg_pipeline = try initCellBgPipeline(device, library); + const cell_bg_pipeline = try initCellBgPipeline(device, library, pixel_format); errdefer cell_bg_pipeline.msgSend(void, objc.sel("release"), .{}); - const image_pipeline = try initImagePipeline(device, library); + const image_pipeline = try initImagePipeline(device, library, pixel_format); errdefer image_pipeline.msgSend(void, objc.sel("release"), .{}); const post_pipelines: []const objc.Object = initPostPipelines( @@ -58,6 +57,7 @@ pub const Shaders = struct { device, library, post_shaders, + pixel_format, ) catch |err| err: { // If an error happens while building postprocess shaders we // want to just not use any postprocess shaders since we don't @@ -137,9 +137,29 @@ pub const Uniforms = extern struct { cursor_pos: [2]u16 align(4), cursor_color: [4]u8 align(4), - // Whether the cursor is 2 cells wide. + /// The background color for the whole surface. + bg_color: [4]u8 align(4), + + /// Whether the cursor is 2 cells wide. cursor_wide: bool align(1), + /// Indicates that colors provided to the shader are already in + /// the P3 color space, so they don't need to be converted from + /// sRGB. + use_display_p3: bool align(1), + + /// Indicates that the color attachments for the shaders have + /// an `*_srgb` pixel format, which means the shaders need to + /// output linear RGB colors rather than gamma encoded colors, + /// since blending will be performed in linear space and then + /// Metal itself will re-encode the colors for storage. + use_linear_blending: bool align(1), + + /// Enables a weight correction step that makes text rendered + /// with linear alpha blending have a similar apparent weight + /// (thickness) to gamma-incorrect blending. + use_experimental_linear_correction: bool align(1) = false, + const PaddingExtend = packed struct(u8) { left: bool = false, right: bool = false, @@ -201,6 +221,7 @@ fn initPostPipelines( device: objc.Object, library: objc.Object, shaders: []const [:0]const u8, + pixel_format: mtl.MTLPixelFormat, ) ![]const objc.Object { // If we have no shaders, do nothing. if (shaders.len == 0) return &.{}; @@ -220,7 +241,12 @@ fn initPostPipelines( // Build each shader. Note we don't use "0.." to build our index // because we need to keep track of our length to clean up above. for (shaders) |source| { - pipelines[i] = try initPostPipeline(device, library, source); + pipelines[i] = try initPostPipeline( + device, + library, + source, + pixel_format, + ); i += 1; } @@ -232,6 +258,7 @@ fn initPostPipeline( device: objc.Object, library: objc.Object, data: [:0]const u8, + pixel_format: mtl.MTLPixelFormat, ) !objc.Object { // Create our library which has the shader source const post_library = library: { @@ -301,8 +328,7 @@ fn initPostPipeline( .{@as(c_ulong, 0)}, ); - // Value is MTLPixelFormatBGRA8Unorm - attachment.setProperty("pixelFormat", @as(c_ulong, 80)); + attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); } // Make our state @@ -343,7 +369,11 @@ pub const CellText = extern struct { }; /// Initialize the cell render pipeline for our shader library. -fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object { +fn initCellTextPipeline( + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, +) !objc.Object { // Get our vertex and fragment functions const func_vert = func_vert: { const str = try macos.foundation.String.createWithBytes( @@ -427,8 +457,7 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object .{@as(c_ulong, 0)}, ); - // Value is MTLPixelFormatBGRA8Unorm - attachment.setProperty("pixelFormat", @as(c_ulong, 80)); + attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); // Blending. This is required so that our text we render on top // of our drawable properly blends into the bg. @@ -458,11 +487,15 @@ fn initCellTextPipeline(device: objc.Object, library: objc.Object) !objc.Object pub const CellBg = [4]u8; /// Initialize the cell background render pipeline for our shader library. -fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object { +fn initCellBgPipeline( + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, +) !objc.Object { // Get our vertex and fragment functions const func_vert = func_vert: { const str = try macos.foundation.String.createWithBytes( - "full_screen_vertex", + "cell_bg_vertex", .utf8, false, ); @@ -507,8 +540,7 @@ fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object { .{@as(c_ulong, 0)}, ); - // Value is MTLPixelFormatBGRA8Unorm - attachment.setProperty("pixelFormat", @as(c_ulong, 80)); + attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); // Blending. This is required so that our text we render on top // of our drawable properly blends into the bg. @@ -535,7 +567,11 @@ fn initCellBgPipeline(device: objc.Object, library: objc.Object) !objc.Object { } /// Initialize the image render pipeline for our shader library. -fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object { +fn initImagePipeline( + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, +) !objc.Object { // Get our vertex and fragment functions const func_vert = func_vert: { const str = try macos.foundation.String.createWithBytes( @@ -619,8 +655,7 @@ fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object { .{@as(c_ulong, 0)}, ); - // Value is MTLPixelFormatBGRA8Unorm - attachment.setProperty("pixelFormat", @as(c_ulong, 80)); + attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); // Blending. This is required so that our text we render on top // of our drawable properly blends into the bg. diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index 2a107402b..58dd13755 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -18,7 +18,11 @@ struct Uniforms { float min_contrast; ushort2 cursor_pos; uchar4 cursor_color; + uchar4 bg_color; bool cursor_wide; + bool use_display_p3; + bool use_linear_blending; + bool use_experimental_linear_correction; }; //------------------------------------------------------------------- @@ -26,40 +30,82 @@ struct Uniforms { //------------------------------------------------------------------- #pragma mark - Colors -// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef -float luminance_component(float c) { - if (c <= 0.03928f) { - return c / 12.92f; - } else { - return pow((c + 0.055f) / 1.055f, 2.4f); - } +// D50-adapted sRGB to XYZ conversion matrix. +// http://www.brucelindbloom.com/Eqn_RGB_XYZ_Matrix.html +constant float3x3 sRGB_XYZ = transpose(float3x3( + 0.4360747, 0.3850649, 0.1430804, + 0.2225045, 0.7168786, 0.0606169, + 0.0139322, 0.0971045, 0.7141733 +)); +// XYZ to Display P3 conversion matrix. +// http://endavid.com/index.php?entry=79 +constant float3x3 XYZ_DP3 = transpose(float3x3( + 2.40414768,-0.99010704,-0.39759019, + -0.84239098, 1.79905954, 0.01597023, + 0.04838763,-0.09752546, 1.27393636 +)); +// By composing the two above matrices we get +// our sRGB to Display P3 conversion matrix. +constant float3x3 sRGB_DP3 = XYZ_DP3 * sRGB_XYZ; + +// Converts a color in linear sRGB to linear Display P3 +// +// TODO: The color matrix should probably be computed +// dynamically and passed as a uniform, rather +// than being hard coded above. +float3 srgb_to_display_p3(float3 srgb) { + return sRGB_DP3 * srgb; } -float relative_luminance(float3 color) { - color.r = luminance_component(color.r); - color.g = luminance_component(color.g); - color.b = luminance_component(color.b); - float3 weights = float3(0.2126f, 0.7152f, 0.0722f); - return dot(color, weights); +// Converts a color from sRGB gamma encoding to linear. +float4 linearize(float4 srgb) { + bool3 cutoff = srgb.rgb <= 0.04045; + float3 lower = srgb.rgb / 12.92; + float3 higher = pow((srgb.rgb + 0.055) / 1.055, 2.4); + srgb.rgb = mix(higher, lower, float3(cutoff)); + + return srgb; +} + +// Converts a color from linear to sRGB gamma encoding. +float4 unlinearize(float4 linear) { + bool3 cutoff = linear.rgb <= 0.0031308; + float3 lower = linear.rgb * 12.92; + float3 higher = pow(linear.rgb, 1.0 / 2.4) * 1.055 - 0.055; + linear.rgb = mix(higher, lower, float3(cutoff)); + + return linear; +} + +// Compute the luminance of the provided color. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float luminance(float3 color) { + return dot(color, float3(0.2126f, 0.7152f, 0.0722f)); } // https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. float contrast_ratio(float3 color1, float3 color2) { - float l1 = relative_luminance(color1); - float l2 = relative_luminance(color2); + float l1 = luminance(color1); + float l2 = luminance(color2); return (max(l1, l2) + 0.05f) / (min(l1, l2) + 0.05f); } // Return the fg if the contrast ratio is greater than min, otherwise // return a color that satisfies the contrast ratio. Currently, the color // is always white or black, whichever has the highest contrast ratio. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. float4 contrasted_color(float min, float4 fg, float4 bg) { - float3 fg_premult = fg.rgb * fg.a; - float3 bg_premult = bg.rgb * bg.a; - float ratio = contrast_ratio(fg_premult, bg_premult); + float ratio = contrast_ratio(fg.rgb, bg.rgb); if (ratio < min) { - float white_ratio = contrast_ratio(float3(1.0f), bg_premult); - float black_ratio = contrast_ratio(float3(0.0f), bg_premult); + float white_ratio = contrast_ratio(float3(1.0f), bg.rgb); + float black_ratio = contrast_ratio(float3(0.0f), bg.rgb); if (white_ratio > black_ratio) { return float4(1.0f); } else { @@ -70,6 +116,62 @@ float4 contrasted_color(float min, float4 fg, float4 bg) { return fg; } +// Load a 4 byte RGBA non-premultiplied color and linearize +// and convert it as necessary depending on the provided info. +// +// Returns a color in the Display P3 color space. +// +// If `display_p3` is true, then the provided color is assumed to +// already be in the Display P3 color space, otherwise it's treated +// as an sRGB color and is appropriately converted to Display P3. +// +// `linear` controls whether the returned color is linear or gamma encoded. +float4 load_color( + uchar4 in_color, + bool display_p3, + bool linear +) { + // 0 .. 255 -> 0.0 .. 1.0 + float4 color = float4(in_color) / 255.0f; + + // If our color is already in Display P3 and + // we aren't doing linear blending, then we + // already have the correct color here and + // can premultiply and return it. + if (display_p3 && !linear) { + color *= color.a; + return color; + } + + // The color is in either the sRGB or Display P3 color space, + // so in either case, it's a color space which uses the sRGB + // transfer function, so we can use one function in order to + // linearize it in either case. + // + // Even if we aren't doing linear blending, the color + // needs to be in linear space to convert color spaces. + color = linearize(color); + + // If we're *NOT* using display P3 colors, then we're dealing + // with an sRGB color, in which case we need to convert it in + // to the Display P3 color space, since our output is always + // Display P3. + if (!display_p3) { + color.rgb = srgb_to_display_p3(color.rgb); + } + + // If we're not doing linear blending, then we need to + // unlinearize after doing the color space conversion. + if (!linear) { + color = unlinearize(color); + } + + // Premultiply our color by its alpha. + color *= color.a; + + return color; +} + //------------------------------------------------------------------- // Full Screen Vertex Shader //------------------------------------------------------------------- @@ -112,25 +214,54 @@ vertex FullScreenVertexOut full_screen_vertex( //------------------------------------------------------------------- #pragma mark - Cell BG Shader +struct CellBgVertexOut { + float4 position [[position]]; + float4 bg_color; +}; + +vertex CellBgVertexOut cell_bg_vertex( + uint vid [[vertex_id]], + constant Uniforms& uniforms [[buffer(1)]] +) { + CellBgVertexOut out; + + float4 position; + position.x = (vid == 2) ? 3.0 : -1.0; + position.y = (vid == 0) ? -3.0 : 1.0; + position.zw = 1.0; + out.position = position; + + // Convert the background color to Display P3 + out.bg_color = load_color( + uniforms.bg_color, + uniforms.use_display_p3, + uniforms.use_linear_blending + ); + + return out; +} + fragment float4 cell_bg_fragment( - FullScreenVertexOut in [[stage_in]], + CellBgVertexOut in [[stage_in]], constant uchar4 *cells [[buffer(0)]], constant Uniforms& uniforms [[buffer(1)]] ) { int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size)); + float4 bg = in.bg_color; + // Clamp x position, extends edge bg colors in to padding on sides. if (grid_pos.x < 0) { if (uniforms.padding_extend & EXTEND_LEFT) { grid_pos.x = 0; } else { - return float4(0.0); + return bg; } } else if (grid_pos.x > uniforms.grid_size.x - 1) { if (uniforms.padding_extend & EXTEND_RIGHT) { grid_pos.x = uniforms.grid_size.x - 1; } else { - return float4(0.0); + return bg; } } @@ -139,18 +270,32 @@ fragment float4 cell_bg_fragment( if (uniforms.padding_extend & EXTEND_UP) { grid_pos.y = 0; } else { - return float4(0.0); + return bg; } } else if (grid_pos.y > uniforms.grid_size.y - 1) { if (uniforms.padding_extend & EXTEND_DOWN) { grid_pos.y = uniforms.grid_size.y - 1; } else { - return float4(0.0); + return bg; } } - // Retrieve color for cell and return it. - return float4(cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]) / 255.0; + // We load the color for the cell, converting it appropriately, and return it. + // + // TODO: We may want to blend the color with the background + // color, rather than purely replacing it, this needs + // some consideration about config options though. + // + // TODO: It might be a good idea to do a pass before this + // to convert all of the bg colors, so we don't waste + // a bunch of work converting the cell color in every + // fragment of each cell. It's not the most epxensive + // operation, but it is still wasted work. + return load_color( + cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x], + uniforms.use_display_p3, + uniforms.use_linear_blending + ); } //------------------------------------------------------------------- @@ -222,7 +367,6 @@ vertex CellTextVertexOut cell_text_vertex( CellTextVertexOut out; out.mode = in.mode; - out.color = float4(in.color) / 255.0f; // === Grid Cell === // +X @@ -277,6 +421,14 @@ vertex CellTextVertexOut cell_text_vertex( // be sampled with pixel coordinate mode. out.tex_coord = float2(in.glyph_pos) + float2(in.glyph_size) * corner; + // Get our color. We always fetch a linearized version to + // make it easier to handle minimum contrast calculations. + out.color = load_color( + in.color, + uniforms.use_display_p3, + true + ); + // If we have a minimum contrast, we need to check if we need to // change the color of the text to ensure it has enough contrast // with the background. @@ -285,7 +437,13 @@ vertex CellTextVertexOut cell_text_vertex( // and Powerline glyphs to be unaffected (else parts of the line would // have different colors as some parts are displayed via background colors). if (uniforms.min_contrast > 1.0f && in.mode == MODE_TEXT) { - float4 bg_color = float4(bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x]) / 255.0f; + // Get the BG color + float4 bg_color = load_color( + bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x], + uniforms.use_display_p3, + true + ); + // Ensure our minimum contrast out.color = contrasted_color(uniforms.min_contrast, out.color, bg_color); } @@ -308,7 +466,8 @@ vertex CellTextVertexOut cell_text_vertex( fragment float4 cell_text_fragment( CellTextVertexOut in [[stage_in]], texture2d textureGrayscale [[texture(0)]], - texture2d textureColor [[texture(1)]] + texture2d textureColor [[texture(1)]], + constant Uniforms& uniforms [[buffer(2)]] ) { constexpr sampler textureSampler( coord::pixel, @@ -322,20 +481,63 @@ fragment float4 cell_text_fragment( case MODE_TEXT_CONSTRAINED: case MODE_TEXT_POWERLINE: case MODE_TEXT: { - // We premult the alpha to our whole color since our blend function - // uses One/OneMinusSourceAlpha to avoid blurry edges. - // We first premult our given color. - float4 premult = float4(in.color.rgb * in.color.a, in.color.a); + // Our input color is always linear. + float4 color = in.color; - // Then premult the texture color + // If we're not doing linear blending, then we need to + // re-apply the gamma encoding to our color manually. + // + // We do it BEFORE premultiplying the alpha because + // we want to produce the effect of not linearizing + // it in the first place in order to match the look + // of software that never does this. + if (!uniforms.use_linear_blending) { + color = unlinearize(color); + } + + // Fetch our alpha mask for this pixel. float a = textureGrayscale.sample(textureSampler, in.tex_coord).r; - premult = premult * a; - return premult; + // Experimental linear blending weight correction. + if (uniforms.use_experimental_linear_correction) { + float l = luminance(color.rgb); + + // TODO: This is a dynamic dilation term that biases + // the alpha adjustment for small font sizes; + // it should be computed by dividing the font + // size in `pt`s by `13.0` and using that if + // it's less than `1.0`, but for now it's + // hard coded at 1.0, which has no effect. + float d = 13.0 / 13.0; + + a += pow(a, d + d * l) - pow(a, d + 1.0 - d * l); + } + + // Multiply our whole color by the alpha mask. + // Since we use premultiplied alpha, this is + // the correct way to apply the mask. + color *= a; + + return color; } case MODE_TEXT_COLOR: { - return textureColor.sample(textureSampler, in.tex_coord); + // For now, we assume that color glyphs are + // already premultiplied Display P3 colors. + float4 color = textureColor.sample(textureSampler, in.tex_coord); + + // If we aren't doing linear blending, we can return this right away. + if (!uniforms.use_linear_blending) { + return color; + } + + // Otherwise we need to linearize the color. Since the alpha is + // premultiplied, we need to divide it out before linearizing. + color.rgb /= color.a; + color = linearize(color); + color.rgb *= color.a; + + return color; } } } @@ -409,7 +611,8 @@ vertex ImageVertexOut image_vertex( fragment float4 image_fragment( ImageVertexOut in [[stage_in]], - texture2d image [[texture(0)]] + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] ) { constexpr sampler textureSampler(address::clamp_to_edge, filter::linear); @@ -418,10 +621,12 @@ fragment float4 image_fragment( // our texture to BGRA8Unorm. uint4 rgba = image.sample(textureSampler, in.tex_coord); - // Convert to float4 and premultiply the alpha. We should also probably - // premultiply the alpha in the texture. - float4 result = float4(rgba) / 255.0f; - result.rgb *= result.a; - return result; + return load_color( + uchar4(rgba), + // We assume all images are sRGB regardless of the configured colorspace + // TODO: Maybe support wide gamut images? + false, + uniforms.use_linear_blending + ); } From f24d70b7ecb51b3bcd4ed609cab3ee15f0ecade2 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 13 Jan 2025 10:08:41 -0600 Subject: [PATCH 160/238] gtk: add config entry to hide titlebar when the window is maximized Fixes #3381 Note that #4936 will need to be merged or you'll need to rely on Gnome's default keybinding for unmaximizing a window (super+down). --- src/apprt/gtk/Window.zig | 17 +++++++++++++++++ src/config/Config.zig | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 514c81e41..3c8c2c2e7 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -204,6 +204,7 @@ pub fn init(self: *Window, app: *App) !void { } _ = c.g_signal_connect_data(gtk_window, "notify::decorated", c.G_CALLBACK(>kWindowNotifyDecorated), self, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_window, "notify::maximized", c.G_CALLBACK(>kWindowNotifyMaximized), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(gtk_window, "notify::fullscreened", c.G_CALLBACK(>kWindowNotifyFullscreened), self, null, c.G_CONNECT_DEFAULT); // If we are disabling decorations then disable them right away. @@ -601,6 +602,22 @@ fn gtkRealize(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { return true; } +fn gtkWindowNotifyMaximized( + _: *c.GObject, + _: *c.GParamSpec, + ud: ?*anyopaque, +) callconv(.C) void { + const self = userdataSelf(ud orelse return); + const maximized = c.gtk_window_is_maximized(self.window) != 0; + if (!maximized) { + self.headerbar.setVisible(true); + return; + } + if (self.app.config.@"gtk-titlebar-hide-when-maximized") { + self.headerbar.setVisible(false); + } +} + fn gtkWindowNotifyDecorated( object: *c.GObject, _: *c.GParamSpec, diff --git a/src/config/Config.zig b/src/config/Config.zig index 6c5b64316..a06dcaccc 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2032,6 +2032,10 @@ keybind: Keybinds = .{}, /// title bar, or you can switch tabs with keybinds. @"gtk-tabs-location": GtkTabsLocation = .top, +/// If this is `true`, the titlebar will be hidden when the window is maximized, +/// and shown when the titlebar is unmaximized. GTK only. +@"gtk-titlebar-hide-when-maximized": bool = false, + /// Determines the appearance of the top and bottom bars when using the /// Adwaita tab bar. This requires `gtk-adwaita` to be enabled (it is /// by default). From a8b9c5bea5e3cfcd821cc2eed5dbe172db59d605 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 13 Jan 2025 13:59:29 -0800 Subject: [PATCH 161/238] config: remove experimental linear and merge into text-blending --- src/config/Config.zig | 24 ++++++++++++------------ src/renderer/Metal.zig | 22 +++++++++------------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index eabae9052..be63c1027 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -264,24 +264,16 @@ const c = @cImport({ /// This is also sometimes known as "gamma correction". /// (Currently only supported on macOS. Has no effect on Linux.) /// -/// To prevent the uneven thickness caused by linear blending, you can use -/// the `experimental-linear-correction` option which applies a correction -/// curve to the text alpha depending on its brightness, which compensates -/// for the thinning and makes the weight of most text appear very similar -/// to when it's blendeded non-linearly. +/// * `linear-corrected` - Corrects the thinning/thickening effect of linear +/// by applying a correction curve to the text alpha depending on its +/// brightness. This compensates for the thinning and makes the weight of +/// most text appear very similar to when it's blended non-linearly. /// /// Note: This setting affects more than just text, images will also be blended /// in the selected color space, and custom shaders will receive colors in that /// color space as well. @"text-blending": TextBlending = .native, -/// Apply a correction curve to text alpha to compensate for uneven apparent -/// thickness of different colors of text, roughly matching the appearance of -/// text rendered with non-linear blending. -/// -/// Has no effect if not using linear `text-blending`. -@"experimental-linear-correction": bool = false, - /// All of the configurations behavior adjust various metrics determined by the /// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%, /// etc.). In each case, the values represent the amount to change the original @@ -5787,6 +5779,14 @@ pub const GraphemeWidthMethod = enum { pub const TextBlending = enum { native, linear, + @"linear-corrected", + + pub fn isLinear(self: TextBlending) bool { + return switch (self) { + .native => false, + .linear, .@"linear-corrected" => true, + }; + } }; /// See freetype-load-flag diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 707fe8e46..83cf4a5c6 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -389,7 +389,6 @@ pub const DerivedConfig = struct { vsync: bool, colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.TextBlending, - experimental_linear_correction: bool, pub fn init( alloc_gpa: Allocator, @@ -462,8 +461,6 @@ pub const DerivedConfig = struct { .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", .blending = config.@"text-blending", - .experimental_linear_correction = config.@"text-blending" == .linear and config.@"experimental-linear-correction", - .arena = arena, }; } @@ -559,7 +556,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { // the pixels written to it *after* blending, which means // we get linear alpha blending rather than gamma-incorrect // blending. - if (options.config.blending == .linear) + if (options.config.blending.isLinear()) @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) else @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), @@ -655,8 +652,8 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { }, .cursor_wide = false, .use_display_p3 = options.config.colorspace == .@"display-p3", - .use_linear_blending = options.config.blending == .linear, - .use_experimental_linear_correction = options.config.experimental_linear_correction, + .use_linear_blending = options.config.blending.isLinear(), + .use_experimental_linear_correction = options.config.blending == .@"linear-corrected", }, // Fonts @@ -774,7 +771,7 @@ fn initShaders(self: *Metal) !void { // the pixels written to it *after* blending, which means // we get linear alpha blending rather than gamma-incorrect // blending. - if (self.config.blending == .linear) + if (self.config.blending.isLinear()) mtl.MTLPixelFormat.bgra8unorm_srgb else mtl.MTLPixelFormat.bgra8unorm, @@ -2071,9 +2068,8 @@ pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void { // Set our new color space and blending self.uniforms.use_display_p3 = config.colorspace == .@"display-p3"; - self.uniforms.use_linear_blending = config.blending == .linear; - - self.uniforms.use_experimental_linear_correction = config.experimental_linear_correction; + self.uniforms.use_linear_blending = config.blending.isLinear(); + self.uniforms.use_experimental_linear_correction = config.blending == .@"linear-corrected"; // Set our new colors self.default_background_color = config.background; @@ -2105,7 +2101,7 @@ pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void { // And we update our layer's pixel format appropriately. self.layer.setProperty( "pixelFormat", - if (config.blending == .linear) + if (config.blending.isLinear()) @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) else @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), @@ -2231,7 +2227,7 @@ pub fn setScreenSize( // the pixels written to it *after* blending, which means // we get linear alpha blending rather than gamma-incorrect // blending. - if (self.config.blending == .linear) + if (self.config.blending.isLinear()) @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) else @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), @@ -2271,7 +2267,7 @@ pub fn setScreenSize( // the pixels written to it *after* blending, which means // we get linear alpha blending rather than gamma-incorrect // blending. - if (self.config.blending == .linear) + if (self.config.blending.isLinear()) @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb) else @intFromEnum(mtl.MTLPixelFormat.bgra8unorm), From 39bb94997393966fbe65e44c93824ee0c067ae23 Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Tue, 14 Jan 2025 00:01:37 +0100 Subject: [PATCH 162/238] fix: Ensure file paths derived from pasteboard operations are properly escaped --- macos/Sources/Helpers/NSPasteboard+Extension.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/NSPasteboard+Extension.swift index 0b71b5685..11815fbc8 100644 --- a/macos/Sources/Helpers/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/NSPasteboard+Extension.swift @@ -9,14 +9,14 @@ extension NSPasteboard { /// Gets the contents of the pasteboard as a string following a specific set of semantics. /// Does these things in order: - /// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one. + /// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one and ensures the file path is properly escaped. /// - Tries to get any string from the pasteboard. /// If all of the above fail, returns None. func getOpinionatedStringContents() -> String? { if let urls = readObjects(forClasses: [NSURL.self]) as? [URL], urls.count > 0 { return urls - .map { $0.isFileURL ? $0.path : $0.absoluteString } + .map { $0.isFileURL ? Ghostty.Shell.escape($0.path) : $0.absoluteString } .joined(separator: " ") } From 95debc59d1b422e0c544a5b80286dfdadda5512b Mon Sep 17 00:00:00 2001 From: otomist Date: Tue, 14 Jan 2025 12:04:43 -0500 Subject: [PATCH 163/238] add and use flag for selecting empty lines in the selectLine function --- src/Surface.zig | 25 +++++++++++++------------ src/terminal/Screen.zig | 6 ++++++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 5a1d8c01d..a8fd4a817 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3563,22 +3563,23 @@ fn dragLeftClickTriple( const screen = &self.io.terminal.screen; const click_pin = self.mouse.left_click_pin.?.*; - // Get the word under our current point. If there isn't a word, do nothing. - const word = screen.selectLine(.{ .pin = drag_pin }) orelse return; + // Get the line selection under our current drag point. If there isn't a line, do nothing. + const line = screen.selectLine(.{ .pin = drag_pin }) orelse return; - // Get our selection to grow it. If we don't have a selection, start it now. - // We may not have a selection if we started our dbl-click in an area - // that had no data, then we dragged our mouse into an area with data. - var sel = screen.selectLine(.{ .pin = click_pin }) orelse { - try self.setSelection(word); - return; - }; + // get the selection under our click point. + var sel_ = screen.selectLine(.{ .pin = click_pin }); - // Grow our selection + // We may not have a selection if we started our triple-click in an area + // that had no data, in this case recall selectLine with allow_empty_lines. + if (sel_ == null) { + sel_ = screen.selectLine(.{ .pin = click_pin, .allow_empty_lines = true }); + } + + var sel = sel_ orelse return; if (drag_pin.before(click_pin)) { - sel.startPtr().* = word.start(); + sel.startPtr().* = line.start(); } else { - sel.endPtr().* = word.end(); + sel.endPtr().* = line.end(); } try self.setSelection(sel); } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index eb70d32d0..db890ad3f 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2215,6 +2215,7 @@ pub const SelectLine = struct { /// state changing a boundary. State changing is ANY state /// change. semantic_prompt_boundary: bool = true, + allow_empty_lines: bool = false, }; /// Select the line under the given point. This will select across soft-wrapped @@ -2292,6 +2293,11 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { return null; }; + // If we allow empty lines, we don't need to do any further checks. + if (opts.allow_empty_lines) { + return Selection.init(start_pin, end_pin, false); + } + // Go forward from the start to find the first non-whitespace character. const start: Pin = start: { const whitespace = opts.whitespace orelse break :start start_pin; From 4e0d9b1b277aac2e60e253750a175e80ff2ab973 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Mon, 6 Jan 2025 21:58:22 +0100 Subject: [PATCH 164/238] gtk(wayland): implement server-side decorations --- macos/Sources/Ghostty/Ghostty.Config.swift | 24 +++- src/apprt/gtk/App.zig | 2 +- src/apprt/gtk/Surface.zig | 8 +- src/apprt/gtk/Window.zig | 104 ++++++++++------- src/apprt/gtk/headerbar.zig | 3 - src/apprt/gtk/winproto.zig | 8 +- src/apprt/gtk/winproto/noop.zig | 8 ++ src/apprt/gtk/winproto/wayland.zig | 75 ++++++++++++- src/apprt/gtk/winproto/x11.zig | 9 ++ src/build/SharedDeps.zig | 4 + src/config/Config.zig | 125 +++++++++++++++++---- 11 files changed, 294 insertions(+), 76 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index d6e1710ae..9d45ea9bb 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -165,11 +165,14 @@ extension Ghostty { } var windowDecorations: Bool { - guard let config = self.config else { return true } - var v = false; + let defaultValue = true + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil let key = "window-decoration" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) - return v; + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + let str = String(cString: ptr) + return WindowDecoration(rawValue: str)?.enabled() ?? defaultValue } var windowTheme: String? { @@ -554,4 +557,17 @@ extension Ghostty.Config { } } } + + enum WindowDecoration: String { + case none + case client + case server + + func enabled() -> Bool { + switch self { + case .client, .server: return true + case .none: return false + } + } + } } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index f49d275de..96275684e 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1881,7 +1881,7 @@ fn initContextMenu(self: *App) void { c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); } - if (!self.config.@"window-decoration") { + if (!self.config.@"window-decoration".isCSD()) { const section = c.g_menu_new(); defer c.g_object_unref(section); const submenu = c.g_menu_new(); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index c5a001f34..61866dcec 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1384,11 +1384,9 @@ fn gtkResize(area: *c.GtkGLArea, width: c.gint, height: c.gint, ud: ?*anyopaque) }; if (self.container.window()) |window| { - if (window.winproto) |*winproto| { - winproto.resizeEvent() catch |err| { - log.warn("failed to notify window protocol of resize={}", .{err}); - }; - } + window.winproto.resizeEvent() catch |err| { + log.warn("failed to notify window protocol of resize={}", .{err}); + }; } self.resize_overlay.maybeShow(); diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 3c8c2c2e7..3e972ca02 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -57,7 +57,7 @@ toast_overlay: ?*c.GtkWidget, adw_tab_overview_focus_timer: ?c.guint = null, /// State and logic for windowing protocol for a window. -winproto: ?winproto.Window, +winproto: winproto.Window, pub fn create(alloc: Allocator, app: *App) !*Window { // Allocate a fixed pointer for our window. We try to minimize @@ -83,7 +83,7 @@ pub fn init(self: *Window, app: *App) !void { .notebook = undefined, .context_menu = undefined, .toast_overlay = undefined, - .winproto = null, + .winproto = .none, }; // Create the window @@ -207,11 +207,6 @@ pub fn init(self: *Window, app: *App) !void { _ = c.g_signal_connect_data(gtk_window, "notify::maximized", c.G_CALLBACK(>kWindowNotifyMaximized), self, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(gtk_window, "notify::fullscreened", c.G_CALLBACK(>kWindowNotifyFullscreened), self, null, c.G_CONNECT_DEFAULT); - // If we are disabling decorations then disable them right away. - if (!app.config.@"window-decoration") { - c.gtk_window_set_decorated(gtk_window, 0); - } - // 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 (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { @@ -379,7 +374,11 @@ pub fn updateConfig( self: *Window, config: *const configpkg.Config, ) !void { - if (self.winproto) |*v| try v.updateConfigEvent(config); + self.winproto.updateConfigEvent(config) catch |err| { + // We want to continue attempting to make the other config + // changes necessary so we just log the error and continue. + log.warn("failed to update window protocol config error={}", .{err}); + }; // We always resync our appearance whenever the config changes. try self.syncAppearance(config); @@ -391,16 +390,52 @@ pub fn updateConfig( /// TODO: Many of the initial style settings in `create` could possibly be made /// reactive by moving them here. pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { - if (config.@"background-opacity" < 1) { - c.gtk_widget_remove_css_class(@ptrCast(self.window), "background"); - } else { - c.gtk_widget_add_css_class(@ptrCast(self.window), "background"); - } - - // Window protocol specific appearance updates - if (self.winproto) |*v| v.syncAppearance() catch |err| { - log.warn("failed to sync window protocol appearance error={}", .{err}); + self.winproto.syncAppearance() catch |err| { + log.warn("failed to sync winproto appearance error={}", .{err}); }; + + toggleCssClass( + @ptrCast(self.window), + "background", + config.@"background-opacity" >= 1, + ); + + // If we are disabling CSDs then disable them right away. + const csd_enabled = self.winproto.clientSideDecorationEnabled(); + c.gtk_window_set_decorated(self.window, @intFromBool(csd_enabled)); + + // If we are not decorated then we hide the titlebar. + self.headerbar.setVisible(config.@"gtk-titlebar" and csd_enabled); + + // Disable the title buttons (close, maximize, minimize, ...) + // *inside* the tab overview if CSDs are disabled. + // We do spare the search button, though. + if ((comptime adwaita.versionAtLeast(0, 0, 0)) and + adwaita.enabled(&self.app.config)) + { + if (self.tab_overview) |tab_overview| { + c.adw_tab_overview_set_show_start_title_buttons( + @ptrCast(tab_overview), + @intFromBool(csd_enabled), + ); + c.adw_tab_overview_set_show_end_title_buttons( + @ptrCast(tab_overview), + @intFromBool(csd_enabled), + ); + } + } +} + +fn toggleCssClass( + widget: *c.GtkWidget, + class: [:0]const u8, + v: bool, +) void { + if (v) { + c.gtk_widget_add_css_class(widget, class); + } else { + c.gtk_widget_remove_css_class(widget, class); + } } /// Sets up the GTK actions for the window scope. Actions are how GTK handles @@ -440,7 +475,7 @@ fn initActions(self: *Window) void { pub fn deinit(self: *Window) void { c.gtk_widget_unparent(@ptrCast(self.context_menu)); - if (self.winproto) |*v| v.deinit(self.app.core_app.alloc); + self.winproto.deinit(self.app.core_app.alloc); if (self.adw_tab_overview_focus_timer) |timer| { _ = c.g_source_remove(timer); @@ -548,15 +583,11 @@ pub fn toggleFullscreen(self: *Window) void { /// Toggle the window decorations for this window. pub fn toggleWindowDecorations(self: *Window) void { - const old_decorated = c.gtk_window_get_decorated(self.window) == 1; - const new_decorated = !old_decorated; - c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated)); - - // If we have a titlebar, then we also show/hide it depending on the - // decorated state. GTK tends to consider the titlebar part of the frame - // and hides it with decorations, but libadwaita doesn't. This makes it - // explicit. - self.headerbar.setVisible(new_decorated); + self.app.config.@"window-decoration" = switch (self.app.config.@"window-decoration") { + .client, .server => .none, + .none => .server, + }; + self.updateConfig(&self.app.config) catch {}; } /// Grabs focus on the currently selected tab. @@ -623,17 +654,14 @@ fn gtkWindowNotifyDecorated( _: *c.GParamSpec, _: ?*anyopaque, ) callconv(.C) void { - if (c.gtk_window_get_decorated(@ptrCast(object)) == 1) { - c.gtk_widget_remove_css_class(@ptrCast(object), "ssd"); - c.gtk_widget_remove_css_class(@ptrCast(object), "no-border-radius"); - } else { - // 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. - c.gtk_widget_add_css_class(@ptrCast(object), "ssd"); - c.gtk_widget_add_css_class(@ptrCast(object), "no-border-radius"); - } + const is_decorated = c.gtk_window_get_decorated(@ptrCast(object)) == 1; + + // 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(@ptrCast(object), "ssd", !is_decorated); + toggleCssClass(@ptrCast(object), "no-border-radius", !is_decorated); } fn gtkWindowNotifyFullscreened( diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig index 2b47ea4b7..0f7f15bf8 100644 --- a/src/apprt/gtk/headerbar.zig +++ b/src/apprt/gtk/headerbar.zig @@ -18,9 +18,6 @@ pub const HeaderBar = union(enum) { } else { HeaderBarGtk.init(self); } - - if (!window.app.config.@"gtk-titlebar" or !window.app.config.@"window-decoration") - self.setVisible(false); } pub fn setVisible(self: HeaderBar, visible: bool) void { diff --git a/src/apprt/gtk/winproto.zig b/src/apprt/gtk/winproto.zig index cb873fe01..e6020f49e 100644 --- a/src/apprt/gtk/winproto.zig +++ b/src/apprt/gtk/winproto.zig @@ -62,7 +62,7 @@ pub const App = union(Protocol) { /// Per-Window state for the underlying windowing protocol. /// -/// In both X and Wayland, the terminology used is "Surface" and this is +/// 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. @@ -125,4 +125,10 @@ pub const Window = union(Protocol) { inline else => |*v| try v.syncAppearance(), } } + + pub fn clientSideDecorationEnabled(self: Window) bool { + return switch (self) { + inline else => |v| v.clientSideDecorationEnabled(), + }; + } }; diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig index 14f3dc6a7..38703aecb 100644 --- a/src/apprt/gtk/winproto/noop.zig +++ b/src/apprt/gtk/winproto/noop.zig @@ -53,4 +53,12 @@ pub const Window = struct { 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; + } }; diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 3f7ad0068..efe0d89cd 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -18,6 +18,10 @@ pub const App = struct { 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, }; pub fn init( @@ -89,6 +93,14 @@ pub const App = struct { )) |blur_manager| { context.kde_blur_manager = blur_manager; break :global; + } else if (registryBind( + org.KdeKwinServerDecorationManager, + registry, + global, + 1, + )) |deco_manager| { + context.kde_decoration_manager = deco_manager; + break :global; } }, @@ -97,6 +109,12 @@ pub const App = struct { } } + /// Bind a Wayland interface to a global object. Returns non-null + /// if the binding was successful, otherwise null. + /// + /// The type T is the Wayland interface type that we're requesting. + /// This function will verify that the global object is the correct + /// interface and version before binding. fn registryBind( comptime T: type, registry: *wl.Registry, @@ -130,14 +148,20 @@ pub const Window = struct { app_context: *App.Context, /// A token that, when present, indicates that the window is blurred. - blur_token: ?*org.KdeKwinBlur = null, + blur_token: ?*org.KdeKwinBlur, + + /// Object that controls the decoration mode (client/server/auto) + /// of the window. + decoration: ?*org.KdeKwinServerDecoration, const DerivedConfig = struct { blur: bool, + window_decoration: Config.WindowDecoration, pub fn init(config: *const Config) DerivedConfig { return .{ .blur = config.@"background-blur-radius".enabled(), + .window_decoration = config.@"window-decoration", }; } }; @@ -165,19 +189,41 @@ pub const Window = struct { gdk_surface, ) 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; + }; + return .{ .config = DerivedConfig.init(config), .surface = wl_surface, .app_context = app.context, + .blur_token = null, + .decoration = deco, }; } pub fn deinit(self: Window, alloc: Allocator) void { _ = alloc; if (self.blur_token) |blur| blur.release(); + if (self.decoration) |deco| deco.release(); } - pub fn updateConfigEvent(self: *Window, config: *const Config) !void { + pub fn updateConfigEvent( + self: *Window, + config: *const Config, + ) !void { self.config = DerivedConfig.init(config); } @@ -185,6 +231,17 @@ pub const Window = struct { pub fn syncAppearance(self: *Window) !void { try self.syncBlur(); + try self.syncDecoration(); + } + + pub fn clientSideDecorationEnabled(self: Window) bool { + // Note: we should change this to being the actual mode + // state emitted by the decoration manager. + + // We are CSD if we don't support the SSD Wayland protocol + // or if we do but we're in CSD mode. + return self.decoration == null or + self.config.window_decoration.isCSD(); } /// Update the blur state of the window. @@ -208,4 +265,18 @@ pub const Window = struct { } } } + + fn syncDecoration(self: *Window) !void { + const deco = self.decoration orelse return; + + const mode: org.KdeKwinServerDecoration.Mode = switch (self.config.window_decoration) { + .client => .Client, + .server => .Server, + .none => .None, + }; + + // The protocol requests uint instead of enum so we have + // to convert it. + deco.requestMode(@intCast(@intFromEnum(mode))); + } }; diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 4eac9cdf3..fe3b9218d 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -161,10 +161,15 @@ pub const Window = struct { const DerivedConfig = struct { blur: bool, + has_decoration: bool, pub fn init(config: *const Config) DerivedConfig { return .{ .blur = config.@"background-blur-radius".enabled(), + .has_decoration = switch (config.@"window-decoration") { + .none => false, + .client, .server => true, + }, }; } }; @@ -239,6 +244,10 @@ pub const Window = struct { try self.syncBlur(); } + pub fn clientSideDecorationEnabled(self: Window) bool { + return self.config.has_decoration; + } + 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 diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 16e7381fa..64068658d 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -450,10 +450,14 @@ pub fn add( .target = target, .optimize = optimize, }); + + // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/blur.xml")); + scanner.addCustomProtocol(plasma_wayland_protocols.path("src/protocols/server-decoration.xml")); scanner.generate("wl_compositor", 1); scanner.generate("org_kde_kwin_blur_manager", 1); + scanner.generate("org_kde_kwin_server_decoration_manager", 1); step.root_module.addImport("wayland", wayland); step.linkSystemLibrary2("wayland-client", dynamic_link_opts); diff --git a/src/config/Config.zig b/src/config/Config.zig index 36b2a8494..6ae8a353e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -282,7 +282,7 @@ const c = @cImport({ /// For example, a value of `1` increases the value by 1; it does not set it to /// literally 1. A value of `20%` increases the value by 20%. And so on. /// -/// There is little to no validation on these values so the wrong values (i.e. +/// There is little to no validation on these values so the wrong values (e.g. /// `-100%`) can cause the terminal to be unusable. Use with caution and reason. /// /// Some values are clamped to minimum or maximum values. This can make it @@ -467,7 +467,7 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// 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 (i.e. black on black). This value is the contrast ratio as defined +/// contrast (e.g. black on black). This value is the contrast ratio as defined /// by the [WCAG 2.0 specification](https://www.w3.org/TR/WCAG20/). /// /// If you want to avoid invisible text (same color as background), a value of @@ -722,7 +722,7 @@ command: ?[]const u8 = null, /// injecting any configured shell integration into the command's /// environment. With `-e` its highly unlikely that you're executing a /// shell and forced shell integration is likely to cause problems -/// (i.e. by wrapping your command in a shell, setting env vars, etc.). +/// (e.g. by wrapping your command in a shell, setting env vars, etc.). /// This is a safety measure to prevent unexpected behavior. If you want /// shell integration with a `-e`-executed command, you must either /// name your binary appropriately or source the shell integration script @@ -770,7 +770,7 @@ command: ?[]const u8 = null, /// Match a regular expression against the terminal text and associate clicking /// it with an action. This can be used to match URLs, file paths, etc. Actions -/// can be opening using the system opener (i.e. `open` or `xdg-open`) or +/// can be opening using the system opener (e.g. `open` or `xdg-open`) or /// executing any arbitrary binding action. /// /// Links that are configured earlier take precedence over links that are @@ -876,7 +876,7 @@ class: ?[:0]const u8 = null, /// Valid keys are currently only listed in the /// [Ghostty source code](https://github.com/ghostty-org/ghostty/blob/d6e76858164d52cff460fedc61ddf2e560912d71/src/input/key.zig#L255). /// This is a documentation limitation and we will improve this in the future. -/// A common gotcha is that numeric keys are written as words: i.e. `one`, +/// A common gotcha is that numeric keys are written as words: e.g. `one`, /// `two`, `three`, etc. and not `1`, `2`, `3`. This will also be improved in /// the future. /// @@ -919,7 +919,7 @@ class: ?[:0]const u8 = null, /// * Ghostty will wait an indefinite amount of time for the next key in /// the sequence. There is no way to specify a timeout. The only way to /// force the output of a prefix key is to assign another keybind to -/// specifically output that key (i.e. `ctrl+a>ctrl+a=text:foo`) or +/// specifically output that key (e.g. `ctrl+a>ctrl+a=text:foo`) or /// press an unbound key which will send both keys to the program. /// /// * If a prefix in a sequence is previously bound, the sequence will @@ -949,13 +949,13 @@ class: ?[:0]const u8 = null, /// including `physical:`-prefixed triggers without specifying the /// prefix. /// -/// * `csi:text` - Send a CSI sequence. i.e. `csi:A` sends "cursor up". +/// * `csi:text` - Send a CSI sequence. e.g. `csi:A` sends "cursor up". /// -/// * `esc:text` - Send an escape sequence. i.e. `esc:d` deletes to the +/// * `esc:text` - Send an escape sequence. e.g. `esc:d` deletes to the /// end of the word to the right. /// /// * `text:text` - Send a string. Uses Zig string literal syntax. -/// i.e. `text:\x15` sends Ctrl-U. +/// e.g. `text:\x15` sends Ctrl-U. /// /// * All other actions can be found in the documentation or by using the /// `ghostty +list-actions` command. @@ -981,12 +981,12 @@ class: ?[:0]const u8 = null, /// keybinds only apply to the focused terminal surface. If this is true, /// then the keybind will be sent to all terminal surfaces. This only /// applies to actions that are surface-specific. For actions that -/// are already global (i.e. `quit`), this prefix has no effect. +/// are already global (e.g. `quit`), this prefix has no effect. /// /// * `global:` - Make the keybind global. By default, keybinds only work /// within Ghostty and under the right conditions (application focused, /// sometimes terminal focused, etc.). If you want a keybind to work -/// globally across your system (i.e. even when Ghostty is not focused), +/// globally across your system (e.g. even when Ghostty is not focused), /// specify this prefix. This prefix implies `all:`. Note: this does not /// work in all environments; see the additional notes below for more /// information. @@ -1087,7 +1087,7 @@ keybind: Keybinds = .{}, /// any of the heuristics that disable extending noted below. /// /// The "extend" value will be disabled in certain scenarios. On primary -/// screen applications (i.e. not something like Neovim), the color will not +/// screen applications (e.g. not something like Neovim), the color will not /// be extended vertically if any of the following are true: /// /// * The nearest row has any cells that have the default background color. @@ -1127,21 +1127,46 @@ keybind: Keybinds = .{}, /// configuration `font-size` will be used. @"window-inherit-font-size": bool = true, +/// Configure a preference for window decorations. This setting specifies +/// a _preference_; the actual OS, desktop environment, window manager, etc. +/// may override this preference. Ghostty will do its best to respect this +/// preference but it may not always be possible. +/// /// Valid values: /// -/// * `true` -/// * `false` - windows won't have native decorations, i.e. titlebar and -/// borders. On macOS this also disables tabs and tab overview. +/// * `none` - All window decorations will be disabled. Titlebar, +/// borders, etc. will not be shown. On macOS, this will also disable +/// tabs (enforced by the system). +/// +/// * `client` - Prefer client-side decorations. This is the default. +/// +/// * `server` - Prefer server-side decorations. This is only relevant +/// on Linux with GTK. This currently only works on Linux with Wayland +/// and the `org_kde_kwin_server_decoration` protocol available (e.g. +/// KDE Plasma, but almost any non-Gnome desktop supports this protocol). +/// +/// If `server` is set but the environment doesn't support server-side +/// decorations, client-side decorations will be used instead. +/// +/// The default value is `client`. +/// +/// This setting also accepts boolean true and false values. If set to `true`, +/// this is equivalent to `client`. If set to `false`, this is equivalent to +/// `none`. This is a convenience for users who live primarily on systems +/// that don't differentiate between client and server-side decorations +/// (e.g. macOS and Windows). /// /// The "toggle_window_decorations" keybind action can be used to create -/// a keybinding to toggle this setting at runtime. +/// a keybinding to toggle this setting at runtime. This will always toggle +/// back to "server" if the current value is "none" (this is an issue +/// that will be fixed in the future). /// /// Changing this configuration in your configuration and reloading will /// only affect new windows. Existing windows will not be affected. /// /// macOS: To hide the titlebar without removing the native window borders /// or rounded corners, use `macos-titlebar-style = hidden` instead. -@"window-decoration": bool = true, +@"window-decoration": WindowDecoration = .client, /// The font that will be used for the application's window and tab titles. /// @@ -1364,7 +1389,7 @@ keybind: Keybinds = .{}, @"resize-overlay-duration": Duration = .{ .duration = 750 * std.time.ns_per_ms }, /// If true, when there are multiple split panes, the mouse selects the pane -/// that is focused. This only applies to the currently focused window; i.e. +/// that is focused. This only applies to the currently focused window; e.g. /// mousing over a split in an unfocused window will not focus that split /// and bring the window to front. /// @@ -1408,7 +1433,7 @@ keybind: Keybinds = .{}, /// and a minor amount of user interaction). @"title-report": bool = false, -/// The total amount of bytes that can be used for image data (i.e. the Kitty +/// The total amount of bytes that can be used for image data (e.g. the Kitty /// image protocol) per terminal screen. The maximum value is 4,294,967,295 /// (4GiB). The default is 320MB. If this is set to zero, then all image /// protocols will be disabled. @@ -1668,7 +1693,7 @@ keybind: Keybinds = .{}, /// /// * `none` - OSC 4/10/11 queries receive no reply /// -/// * `8-bit` - Color components are return unscaled, i.e. `rr/gg/bb` +/// * `8-bit` - Color components are return unscaled, e.g. `rr/gg/bb` /// /// * `16-bit` - Color components are returned scaled, e.g. `rrrr/gggg/bbbb` /// @@ -1767,7 +1792,7 @@ keybind: Keybinds = .{}, /// typical for a macOS application and may not work well with all themes. /// /// The "transparent" style will also update in real-time to dynamic -/// changes to the window background color, i.e. via OSC 11. To make this +/// changes to the window background color, e.g. via OSC 11. To make this /// more aesthetically pleasing, this only happens if the terminal is /// a window, tab, or split that borders the top of the window. This /// avoids a disjointed appearance where the titlebar color changes @@ -1834,7 +1859,7 @@ keybind: Keybinds = .{}, /// - U.S. International /// /// Note that if an *Option*-sequence doesn't produce a printable character, it -/// will be treated as *Alt* regardless of this setting. (i.e. `alt+ctrl+a`). +/// will be treated as *Alt* regardless of this setting. (e.g. `alt+ctrl+a`). /// /// Explicit values that can be set: /// @@ -5890,6 +5915,62 @@ pub const BackgroundBlur = union(enum) { } }; +/// See window-decoration +pub const WindowDecoration = enum { + client, + server, + none, + + pub fn parseCLI(input: ?[]const u8) !WindowDecoration { + const input_ = input orelse return .client; + + return if (cli.args.parseBool(input_)) |b| + if (b) .client else .none + else |_| if (std.mem.eql(u8, input_, "server")) + .server + else + error.InvalidValue; + } + + /// Returns true if the window decoration setting results in + /// CSD (client-side decorations). Note that this only returns the + /// user requested behavior. Depending on available APIs (e.g. + /// Wayland protocols), the actual behavior may differ and the apprt + /// should rely on actual windowing APIs to determine the actual + /// status. + pub fn isCSD(self: WindowDecoration) bool { + return switch (self) { + .client => true, + .server, .none => false, + }; + } + + test "parse WindowDecoration" { + const testing = std.testing; + + { + const v = try WindowDecoration.parseCLI(null); + try testing.expectEqual(WindowDecoration.client, v); + } + { + const v = try WindowDecoration.parseCLI("true"); + try testing.expectEqual(WindowDecoration.client, v); + } + { + const v = try WindowDecoration.parseCLI("false"); + try testing.expectEqual(WindowDecoration.none, v); + } + { + const v = try WindowDecoration.parseCLI("server"); + try testing.expectEqual(WindowDecoration.server, v); + } + { + try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI("")); + try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI("aaaa")); + } + } +}; + /// See theme pub const Theme = struct { light: []const u8, From 08a0423b78b1d5493b616fa240fd5babb7eb5491 Mon Sep 17 00:00:00 2001 From: Michael Himing Date: Wed, 15 Jan 2025 08:33:48 +1100 Subject: [PATCH 165/238] fix: building on systems with older adwaita --- src/apprt/gtk/Window.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 3e972ca02..10af25101 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -410,7 +410,7 @@ pub fn syncAppearance(self: *Window, config: *const configpkg.Config) !void { // Disable the title buttons (close, maximize, minimize, ...) // *inside* the tab overview if CSDs are disabled. // We do spare the search button, though. - if ((comptime adwaita.versionAtLeast(0, 0, 0)) and + if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&self.app.config)) { if (self.tab_overview) |tab_overview| { From f5670d81d4365afbc3fe818f6bed26fa4122e5eb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 14 Jan 2025 15:40:43 -0800 Subject: [PATCH 166/238] config: fix window-decoration enum parsing to allow client, none --- src/config/Config.zig | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6ae8a353e..4aba8ce32 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -5921,13 +5921,13 @@ pub const WindowDecoration = enum { server, none, - pub fn parseCLI(input: ?[]const u8) !WindowDecoration { - const input_ = input orelse return .client; + pub fn parseCLI(input_: ?[]const u8) !WindowDecoration { + const input = input_ orelse return .client; - return if (cli.args.parseBool(input_)) |b| + return if (cli.args.parseBool(input)) |b| if (b) .client else .none - else |_| if (std.mem.eql(u8, input_, "server")) - .server + else |_| if (std.meta.stringToEnum(WindowDecoration, input)) |v| + v else error.InvalidValue; } @@ -5964,6 +5964,14 @@ pub const WindowDecoration = enum { const v = try WindowDecoration.parseCLI("server"); try testing.expectEqual(WindowDecoration.server, v); } + { + const v = try WindowDecoration.parseCLI("client"); + try testing.expectEqual(WindowDecoration.client, v); + } + { + const v = try WindowDecoration.parseCLI("none"); + try testing.expectEqual(WindowDecoration.none, v); + } { try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI("")); try testing.expectError(error.InvalidValue, WindowDecoration.parseCLI("aaaa")); From 34abe2ceba9c42bed6737143a051701501ccd515 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 14 Jan 2025 20:23:21 -0500 Subject: [PATCH 167/238] fix(macos): prevent transparency leakage/flash in new/resized surfaces By using the `CAMetalLayer`'s `backgroundColor` property instead of drawing the background color in our shader, it is always stretched to cover the full surface, even when live-resizing, and it doesn't require us to draw a frame for it to be initialized so there's no transparent flash when a new surface is created (as in a new split/tab). This commit also allows for hot reload of `background-opacity`, `window-vsync`, and `window-colorspace`. --- src/renderer/Metal.zig | 71 ++++++++++++++++++++++++++++++++- src/renderer/shaders/cell.metal | 21 +++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 83cf4a5c6..45d8f84c2 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -148,6 +148,9 @@ layer: objc.Object, // CAMetalLayer /// a display link. display_link: ?DisplayLink = null, +/// The `CGColorSpace` that represents our current terminal color space +terminal_colorspace: *graphics.ColorSpace, + /// Custom shader state. This is only set if we have custom shaders. custom_shader_state: ?CustomShaderState = null, @@ -569,9 +572,20 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { // color space using converted colors, which reduces, // but does not fully eliminate blending artifacts. const colorspace = try graphics.ColorSpace.createNamed(.displayP3); - errdefer colorspace.release(); + defer colorspace.release(); layer.setProperty("colorspace", colorspace); + // Create a colorspace the represents our terminal colors + // this will allow us to create e.g. `CGColor`s for things + // like the current background color. + const terminal_colorspace = try graphics.ColorSpace.createNamed( + switch (options.config.colorspace) { + .@"display-p3" => .displayP3, + .srgb => .sRGB, + }, + ); + errdefer terminal_colorspace.release(); + // Make our view layer-backed with our Metal layer. On iOS views are // always layer backed so we don't need to do this. But on iOS the // caller MUST be sure to set the layerClass to CAMetalLayer. @@ -667,6 +681,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { // Metal stuff .layer = layer, .display_link = display_link, + .terminal_colorspace = terminal_colorspace, .custom_shader_state = null, .gpu_state = gpu_state, }; @@ -690,6 +705,8 @@ pub fn deinit(self: *Metal) void { } } + self.terminal_colorspace.release(); + self.cells.deinit(self.alloc); self.font_shaper.deinit(); @@ -1170,6 +1187,32 @@ pub fn updateFrame( @intFromFloat(@round(self.config.background_opacity * 255.0)), }; + // Update the background color on our layer + // + // TODO: Is this expensive? Should we be checking if our + // bg color has changed first before doing this work? + { + const color = graphics.c.CGColorCreate( + @ptrCast(self.terminal_colorspace), + &[4]f64{ + @as(f64, @floatFromInt(critical.bg.r)) / 255.0, + @as(f64, @floatFromInt(critical.bg.g)) / 255.0, + @as(f64, @floatFromInt(critical.bg.b)) / 255.0, + self.config.background_opacity, + }, + ); + defer graphics.c.CGColorRelease(color); + + // We use a CATransaction so that Core Animation knows that we + // updated the background color property. Otherwise it behaves + // weird, not updating the color until we resize. + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, "begin", .{}); + defer CATransaction.msgSend(void, "commit", .{}); + + self.layer.setProperty("backgroundColor", color); + } + // Go through our images and see if we need to setup any textures. { var image_it = self.images.iterator(); @@ -2077,6 +2120,32 @@ pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void { self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; self.cursor_invert = config.cursor_invert; + // Update our layer's opaqueness and display sync in case they changed. + { + // We use a CATransaction so that Core Animation knows that we + // updated the opaque property. Otherwise it behaves weird, not + // properly going from opaque to transparent unless we resize. + const CATransaction = objc.getClass("CATransaction").?; + CATransaction.msgSend(void, "begin", .{}); + defer CATransaction.msgSend(void, "commit", .{}); + + self.layer.setProperty("opaque", config.background_opacity >= 1); + self.layer.setProperty("displaySyncEnabled", config.vsync); + } + + // Update our terminal colorspace if it changed + if (self.config.colorspace != config.colorspace) { + const terminal_colorspace = try graphics.ColorSpace.createNamed( + switch (config.colorspace) { + .@"display-p3" => .displayP3, + .srgb => .sRGB, + }, + ); + errdefer terminal_colorspace.release(); + self.terminal_colorspace.release(); + self.terminal_colorspace = terminal_colorspace; + } + const old_blending = self.config.blending; const old_custom_shaders = self.config.custom_shaders; diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index 58dd13755..a4e4837b6 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -280,7 +280,24 @@ fragment float4 cell_bg_fragment( } } - // We load the color for the cell, converting it appropriately, and return it. + // Load the color for the cell. + uchar4 cell_color = cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]; + + // We have special case handling for when the cell color matches the bg color. + if (all(cell_color == uniforms.bg_color)) { + // If we have any background transparency then we render bg-colored cells as + // fully transparent, since the background is handled by the layer bg color + // and we don't want to double up our bg color, but if our bg color is fully + // opaque then our layer is opaque and can't handle transparency, so we need + // to return the bg color directly instead. + if (uniforms.bg_color.a == 255) { + return bg; + } else { + return float4(0.0); + } + } + + // Convert the color and return it. // // TODO: We may want to blend the color with the background // color, rather than purely replacing it, this needs @@ -292,7 +309,7 @@ fragment float4 cell_bg_fragment( // fragment of each cell. It's not the most epxensive // operation, but it is still wasted work. return load_color( - cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x], + cell_color, uniforms.use_display_p3, uniforms.use_linear_blending ); From 0b456d14a47b5807d5a69e09e0f96a971f9e8af4 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 4 Jan 2025 22:26:07 -0600 Subject: [PATCH 168/238] nix: vms for testing ghostty --- .gitignore | 1 + flake.nix | 97 ++++++++++++++++++++++++++--------- nix/vm/common.nix | 33 ++++++++++++ nix/vm/wayland-gnome.nix | 107 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 25 deletions(-) create mode 100644 nix/vm/common.nix create mode 100644 nix/vm/wayland-gnome.nix diff --git a/.gitignore b/.gitignore index 0e301f8c4..db8457e1f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ test/cases/**/*.actual.png glad.zip /Box_test.ppm /Box_test_diff.ppm +/ghostty.qcow2 diff --git a/flake.nix b/flake.nix index 83d4af414..0b8a2f424 100644 --- a/flake.nix +++ b/flake.nix @@ -31,37 +31,84 @@ zig, ... }: - builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (builtins.map (system: let - pkgs-stable = nixpkgs-stable.legacyPackages.${system}; - pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; - in { - devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { - zig = zig.packages.${system}."0.13.0"; - wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; - }; + builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} ( + builtins.map ( + system: let + pkgs-stable = nixpkgs-stable.legacyPackages.${system}; + pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; + in { + devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { + zig = zig.packages.${system}."0.13.0"; + wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; + }; - packages.${system} = let - mkArgs = optimize: { - inherit optimize; + packages.${system} = let + mkArgs = optimize: { + inherit optimize; - revision = self.shortRev or self.dirtyShortRev or "dirty"; - }; - in rec { - ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug"); - ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); - ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); + revision = self.shortRev or self.dirtyShortRev or "dirty"; + }; + in rec { + ghostty-debug = pkgs-stable.callPackage ./nix/package.nix (mkArgs "Debug"); + ghostty-releasesafe = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe"); + ghostty-releasefast = pkgs-stable.callPackage ./nix/package.nix (mkArgs "ReleaseFast"); - ghostty = ghostty-releasefast; - default = ghostty; - }; + ghostty = ghostty-releasefast; + default = ghostty; + }; - formatter.${system} = pkgs-stable.alejandra; + formatter.${system} = pkgs-stable.alejandra; - # Our supported systems are the same supported systems as the Zig binaries. - }) (builtins.attrNames zig.packages)) + nixosConfigurations = let + makeVM = ( + path: + nixpkgs-stable.lib.nixosSystem { + inherit system; + modules = [ + { + nixpkgs.overlays = [ + self.overlays.debug + ]; + } + ./nix/vm/common.nix + path + ]; + } + ); + in { + "wayland-gnome-${system}" = makeVM ./nix/vm/wayland-gnome.nix; + }; + + apps.${system} = let + wrapVM = ( + name: let + program = pkgs-stable.writeShellScript "run-ghostty-vm" '' + SHARED_DIR=$(pwd) + export SHARED_DIR + + ${self.nixosConfigurations."${name}-${system}".config.system.build.vm}/bin/run-ghostty-vm + ''; + in { + type = "app"; + program = "${program}"; + } + ); + in { + wayland-gnome = wrapVM "wayland-gnome"; + }; + } + # Our supported systems are the same supported systems as the Zig binaries. + ) (builtins.attrNames zig.packages) + ) // { - overlays.default = final: prev: { - ghostty = self.packages.${prev.system}.default; + overlays = { + default = self.overlays.releasefast; + releasefast = final: prev: { + ghostty = self.packages.${prev.system}.ghostty-releasefast; + }; + debug = final: prev: { + ghostty = self.packages.${prev.system}.ghostty-debug; + }; }; }; diff --git a/nix/vm/common.nix b/nix/vm/common.nix new file mode 100644 index 000000000..08ee19e5b --- /dev/null +++ b/nix/vm/common.nix @@ -0,0 +1,33 @@ +{pkgs, ...}: { + boot.loader.systemd-boot.enable = true; + boot.loader.efi.canTouchEfiVariables = true; + + networking.hostName = "ghostty"; + networking.domain = "mitchellh.com"; + + virtualisation.vmVariant = { + virtualisation.memorySize = 2048; + }; + + users.mutableUsers = true; + + users.groups.ghostty = { + gid = 1000; + }; + + users.users.ghostty = { + description = "Ghostty"; + uid = 1000; + group = "ghostty"; + extraGroups = ["wheel"]; + isNormalUser = true; + initialPassword = "ghostty"; + }; + + environment.systemPackages = [ + pkgs.kitty + pkgs.ghostty + ]; + + system.stateVersion = "24.11"; +} diff --git a/nix/vm/wayland-gnome.nix b/nix/vm/wayland-gnome.nix new file mode 100644 index 000000000..99e38808f --- /dev/null +++ b/nix/vm/wayland-gnome.nix @@ -0,0 +1,107 @@ +{ + config, + lib, + pkgs, + ... +}: { + services.displayManager = { + autoLogin = { + user = "ghostty"; + }; + }; + + services.xserver = { + enable = true; + displayManager = { + gdm = { + enable = true; + autoSuspend = false; + }; + }; + desktopManager = { + gnome = { + enable = true; + }; + }; + }; + + environment.etc = { + "xdg/autostart/com.mitchellh.ghostty.desktop" = { + source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop"; + }; + }; + + environment.systemPackages = [ + pkgs.gnomeExtensions.no-overview + ]; + + environment.gnome.excludePackages = with pkgs; [ + atomix + cheese + epiphany + geary + gnome-music + gnome-photos + gnome-tour + hitori + iagno + tali + ]; + + system.activationScripts = { + face = { + text = '' + mkdir -p /var/lib/AccountsService/{icons,users} + + cp ${pkgs.ghostty}/share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png /var/lib/AccountsService/icons/ghostty + + echo -e "[User]\nIcon=/var/lib/AccountsService/icons/ghostty\n" > /var/lib/AccountsService/users/ghostty + + chown root:root /var/lib/AccountsService/users/ghostty + chmod 0600 /var/lib/AccountsService/users/ghostty + + chown root:root /var/lib/AccountsService/icons/ghostty + chmod 0444 /var/lib/AccountsService/icons/ghostty + ''; + }; + }; + + programs.dconf = { + enable = true; + profiles.user.databases = [ + { + settings = with lib.gvariant; { + "org/gnome/desktop/background" = { + picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; + picture-uri-dark = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; + picture-options = "centered"; + primary-color = "#000000000000"; + secondary-color = "#000000000000"; + }; + "org/gnome/desktop/desktop" = { + interface = "prefer-dark"; + }; + "org/gnome/desktop/notifications" = { + show-in-lock-screen = false; + }; + "org/gnome/desktop/screensaver" = { + lock-enabled = false; + picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; + picture-options = "centered"; + primary-color = "#000000000000"; + secondary-color = "#000000000000"; + }; + "org/gnome/desktop/session" = { + idle-delay = mkUint32 0; + }; + "org/gnome/shell" = { + disable-user-extensions = false; + enabled-extensions = builtins.map (x: x.extensionUuid) ( + lib.filter (p: p ? extensionUuid) config.environment.systemPackages + ); + }; + }; + } + ]; + }; +} From 4ff7f6df06bfb5996d620d3492892df61d0655cd Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Jan 2025 01:31:30 -0600 Subject: [PATCH 169/238] nix: fix dark mode setting --- nix/vm/wayland-gnome.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nix/vm/wayland-gnome.nix b/nix/vm/wayland-gnome.nix index 99e38808f..ada48d9c2 100644 --- a/nix/vm/wayland-gnome.nix +++ b/nix/vm/wayland-gnome.nix @@ -78,8 +78,8 @@ primary-color = "#000000000000"; secondary-color = "#000000000000"; }; - "org/gnome/desktop/desktop" = { - interface = "prefer-dark"; + "org/gnome/desktop/interface" = { + color-scheme = "prefer-dark"; }; "org/gnome/desktop/notifications" = { show-in-lock-screen = false; From 4bfb1f616cae47053de2acebc312ba01067abaab Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Jan 2025 01:31:52 -0600 Subject: [PATCH 170/238] nix: disable geary --- nix/vm/wayland-gnome.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nix/vm/wayland-gnome.nix b/nix/vm/wayland-gnome.nix index ada48d9c2..5d82b16d0 100644 --- a/nix/vm/wayland-gnome.nix +++ b/nix/vm/wayland-gnome.nix @@ -104,4 +104,6 @@ } ]; }; + + programs.geary.enable = false; } From dddc2a50a8585a4a6dd12aa807770089e37f0faa Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Jan 2025 02:07:51 -0600 Subject: [PATCH 171/238] nix vm: more slimming --- flake.nix | 2 +- nix/vm/common.nix | 2 ++ nix/vm/wayland-gnome.nix | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 0b8a2f424..f49dddf8d 100644 --- a/flake.nix +++ b/flake.nix @@ -67,7 +67,7 @@ modules = [ { nixpkgs.overlays = [ - self.overlays.debug + self.overlays.releasefast ]; } ./nix/vm/common.nix diff --git a/nix/vm/common.nix b/nix/vm/common.nix index 08ee19e5b..3a19fe841 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -2,6 +2,8 @@ boot.loader.systemd-boot.enable = true; boot.loader.efi.canTouchEfiVariables = true; + documentation.nixos.enable = false; + networking.hostName = "ghostty"; networking.domain = "mitchellh.com"; diff --git a/nix/vm/wayland-gnome.nix b/nix/vm/wayland-gnome.nix index 5d82b16d0..b480dc439 100644 --- a/nix/vm/wayland-gnome.nix +++ b/nix/vm/wayland-gnome.nix @@ -37,17 +37,53 @@ environment.gnome.excludePackages = with pkgs; [ atomix + baobab cheese epiphany + evince + file-roller geary + gnome-backgrounds + gnome-calculator + gnome-calendar + gnome-clocks + gnome-connections + gnome-contacts + gnome-disk-utility + gnome-extension-manager + gnome-logs + gnome-maps gnome-music gnome-photos + gnome-software + gnome-system-monitor + gnome-text-editor + gnome-themes-extra gnome-tour + gnome-user-docs + gnome-weather hitori iagno + loupe + nautilus + orca + seahorse + simple-scan + snapshot + sushi tali + totem + yelp ]; + services.gnome = { + gnome-browser-connector.enable = false; + gnome-initial-setup.enable = false; + gnome-online-accounts.enable = false; + gnome-remote-desktop.enable = false; + rygel.enable = false; + }; + system.activationScripts = { face = { text = '' From 0b16c1eeba49a68c106d0fc2bcf3007505fcc926 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Jan 2025 10:08:04 -0600 Subject: [PATCH 172/238] nix vm: maybe get vms working on darwin --- flake.nix | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index f49dddf8d..acbfb7a9f 100644 --- a/flake.nix +++ b/flake.nix @@ -63,9 +63,12 @@ makeVM = ( path: nixpkgs-stable.lib.nixosSystem { - inherit system; + system = builtins.replaceStrings ["darwin"] ["linux"] system; modules = [ { + virtualisation.vmVariant = { + virtualisation.host.pkgs = pkgs-stable; + }; nixpkgs.overlays = [ self.overlays.releasefast ]; From 6be0902c095c534f875009b0c2b42e355d51f9bb Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Jan 2025 23:34:41 -0600 Subject: [PATCH 173/238] nix vm: add documentation, add Gnome/X11 VM --- CONTRIBUTING.md | 55 +++++++++++++++ flake.nix | 2 + nix/vm/common-gnome.nix | 133 +++++++++++++++++++++++++++++++++++ nix/vm/common.nix | 29 ++++++++ nix/vm/wayland-gnome.nix | 148 ++------------------------------------- nix/vm/x11-gnome.nix | 9 +++ 6 files changed, 234 insertions(+), 142 deletions(-) create mode 100644 nix/vm/common-gnome.nix create mode 100644 nix/vm/x11-gnome.nix diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af3c30be7..25a7c532c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,3 +77,58 @@ pull request will be accepted with a high degree of certainty. > **Pull requests are NOT a place to discuss feature design.** Please do > not open a WIP pull request to discuss a feature. Instead, use a discussion > and link to your branch. + +## Nix Virtual Machines + +Several Nix virtual machine definitions are provided by the project for testing +and developing Ghostty against multiple different Linux desktop environments. + +Running these requires a working Nix installation, either Nix on your +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Futher +requirements for macOS are detailed below. + +VMs should only be run on your local desktop and then powered off when not in +use, which will discard any changes to the VM. + +The VM definitions provide minimal software "out of the box" but additional +software can be installed by using standard Nix mechanisms like `nix run nixpkgs#`. + +### Linux + +1. Check out the Ghostty source and change to the directory. +2. Run `nix run .#`. `` can be any of the VMs defined in the + `nix/vm` directory (without the `.nix` suffix) excluding any file prefixed + with `common`. +3. The VM will build and then launch. Depending on the speed of your system, this + can take a while, but eventually you should get a new VM window. +4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending + on what UID and GID of the user that you launched the VM as, `/tmp/shared` _may_ be + writable by the VM user, so be careful! + +### macOS + +1. To run the VMs on macOS you will need to enable the Linux builder in your `nix-darwin` + config. This _should_ be as simple as adding `nix.linux-builder.enable=true` to your + configuration and then rebuilding. See [this](https://nixcademy.com/posts/macos-linux-builder/) + blog post for more information about the Linux builder and how to tune the performance. +2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions + above to launch a VM. + +### Contributing new VM definitions + +#### Acceptance Criteria + +We welcome the contribution of new VM definitions, as long as they meet the following criteria: + +1. The should be different enough from existing VM definitions that they represent a distinct + user (and developer) experience. +2. There's a significant Ghostty user population that uses a similar environment. +3. The VMs can be built using only packages from the current stable NixOS release. + +#### VM Definition Criteria + +1. VMs should be as minimal as possible so that they build and launch quickly. + Additonal software can be added at runtime with a command like `nix run nixpkgs#`. +2. VMs should not expose any services to the network, or run any remote access + software like SSH, VNC or RDP. +3. VMs should auto-login using the "ghostty" user. diff --git a/flake.nix b/flake.nix index acbfb7a9f..c0fdd42c3 100644 --- a/flake.nix +++ b/flake.nix @@ -80,6 +80,7 @@ ); in { "wayland-gnome-${system}" = makeVM ./nix/vm/wayland-gnome.nix; + "x11-gnome-${system}" = makeVM ./nix/vm/x11-gnome.nix; }; apps.${system} = let @@ -98,6 +99,7 @@ ); in { wayland-gnome = wrapVM "wayland-gnome"; + x11-gnome = wrapVM "x11-gnome"; }; } # Our supported systems are the same supported systems as the Zig binaries. diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix new file mode 100644 index 000000000..992a10d3a --- /dev/null +++ b/nix/vm/common-gnome.nix @@ -0,0 +1,133 @@ +{ + config, + lib, + pkgs, + ... +}: { + services.xserver = { + enable = true; + displayManager = { + gdm = { + enable = true; + autoSuspend = false; + }; + }; + desktopManager = { + gnome = { + enable = true; + }; + }; + }; + + environment.systemPackages = [ + pkgs.gnomeExtensions.no-overview + ]; + + environment.gnome.excludePackages = with pkgs; [ + atomix + baobab + cheese + epiphany + evince + file-roller + geary + gnome-backgrounds + gnome-calculator + gnome-calendar + gnome-clocks + gnome-connections + gnome-contacts + gnome-disk-utility + gnome-extension-manager + gnome-logs + gnome-maps + gnome-music + gnome-photos + gnome-software + gnome-system-monitor + gnome-text-editor + gnome-themes-extra + gnome-tour + gnome-user-docs + gnome-weather + hitori + iagno + loupe + nautilus + orca + seahorse + simple-scan + snapshot + sushi + tali + totem + yelp + ]; + + services.gnome = { + gnome-browser-connector.enable = false; + gnome-initial-setup.enable = false; + gnome-online-accounts.enable = false; + gnome-remote-desktop.enable = false; + rygel.enable = false; + }; + + system.activationScripts = { + face = { + text = '' + mkdir -p /var/lib/AccountsService/{icons,users} + + cp ${pkgs.ghostty}/share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png /var/lib/AccountsService/icons/ghostty + + echo -e "[User]\nIcon=/var/lib/AccountsService/icons/ghostty\n" > /var/lib/AccountsService/users/ghostty + + chown root:root /var/lib/AccountsService/users/ghostty + chmod 0600 /var/lib/AccountsService/users/ghostty + + chown root:root /var/lib/AccountsService/icons/ghostty + chmod 0444 /var/lib/AccountsService/icons/ghostty + ''; + }; + }; + + programs.dconf = { + enable = true; + profiles.user.databases = [ + { + settings = with lib.gvariant; { + "org/gnome/desktop/background" = { + picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; + picture-uri-dark = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; + picture-options = "centered"; + primary-color = "#000000000000"; + secondary-color = "#000000000000"; + }; + "org/gnome/desktop/interface" = { + color-scheme = "prefer-dark"; + }; + "org/gnome/desktop/notifications" = { + show-in-lock-screen = false; + }; + "org/gnome/desktop/screensaver" = { + lock-enabled = false; + picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; + picture-options = "centered"; + primary-color = "#000000000000"; + secondary-color = "#000000000000"; + }; + "org/gnome/desktop/session" = { + idle-delay = mkUint32 0; + }; + "org/gnome/shell" = { + disable-user-extensions = false; + enabled-extensions = builtins.map (x: x.extensionUuid) ( + lib.filter (p: p ? extensionUuid) config.environment.systemPackages + ); + }; + }; + } + ]; + }; + + programs.geary.enable = false; +} diff --git a/nix/vm/common.nix b/nix/vm/common.nix index 3a19fe841..9e05cce4a 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -11,6 +11,18 @@ virtualisation.memorySize = 2048; }; + nix = { + settings = { + trusted-users = [ + "root" + "ghostty" + ]; + }; + extraOptions = '' + experimental-features = nix-command flakes + ''; + }; + users.mutableUsers = true; users.groups.ghostty = { @@ -26,10 +38,27 @@ initialPassword = "ghostty"; }; + environment.etc = { + "xdg/autostart/com.mitchellh.ghostty.desktop" = { + source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop"; + }; + }; + environment.systemPackages = [ pkgs.kitty pkgs.ghostty + pkgs.zig_0_13 ]; + services.displayManager = { + autoLogin = { + user = "ghostty"; + }; + }; + + services.xserver = { + enable = true; + }; + system.stateVersion = "24.11"; } diff --git a/nix/vm/wayland-gnome.nix b/nix/vm/wayland-gnome.nix index b480dc439..eb277d5d1 100644 --- a/nix/vm/wayland-gnome.nix +++ b/nix/vm/wayland-gnome.nix @@ -1,145 +1,9 @@ -{ - config, - lib, - pkgs, - ... -}: { +{...}: { + imports = [ + ./common-gnome.nix + ]; + services.displayManager = { - autoLogin = { - user = "ghostty"; - }; + defaultSession = "gnome"; }; - - services.xserver = { - enable = true; - displayManager = { - gdm = { - enable = true; - autoSuspend = false; - }; - }; - desktopManager = { - gnome = { - enable = true; - }; - }; - }; - - environment.etc = { - "xdg/autostart/com.mitchellh.ghostty.desktop" = { - source = "${pkgs.ghostty}/share/applications/com.mitchellh.ghostty.desktop"; - }; - }; - - environment.systemPackages = [ - pkgs.gnomeExtensions.no-overview - ]; - - environment.gnome.excludePackages = with pkgs; [ - atomix - baobab - cheese - epiphany - evince - file-roller - geary - gnome-backgrounds - gnome-calculator - gnome-calendar - gnome-clocks - gnome-connections - gnome-contacts - gnome-disk-utility - gnome-extension-manager - gnome-logs - gnome-maps - gnome-music - gnome-photos - gnome-software - gnome-system-monitor - gnome-text-editor - gnome-themes-extra - gnome-tour - gnome-user-docs - gnome-weather - hitori - iagno - loupe - nautilus - orca - seahorse - simple-scan - snapshot - sushi - tali - totem - yelp - ]; - - services.gnome = { - gnome-browser-connector.enable = false; - gnome-initial-setup.enable = false; - gnome-online-accounts.enable = false; - gnome-remote-desktop.enable = false; - rygel.enable = false; - }; - - system.activationScripts = { - face = { - text = '' - mkdir -p /var/lib/AccountsService/{icons,users} - - cp ${pkgs.ghostty}/share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png /var/lib/AccountsService/icons/ghostty - - echo -e "[User]\nIcon=/var/lib/AccountsService/icons/ghostty\n" > /var/lib/AccountsService/users/ghostty - - chown root:root /var/lib/AccountsService/users/ghostty - chmod 0600 /var/lib/AccountsService/users/ghostty - - chown root:root /var/lib/AccountsService/icons/ghostty - chmod 0444 /var/lib/AccountsService/icons/ghostty - ''; - }; - }; - - programs.dconf = { - enable = true; - profiles.user.databases = [ - { - settings = with lib.gvariant; { - "org/gnome/desktop/background" = { - picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; - picture-uri-dark = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; - picture-options = "centered"; - primary-color = "#000000000000"; - secondary-color = "#000000000000"; - }; - "org/gnome/desktop/interface" = { - color-scheme = "prefer-dark"; - }; - "org/gnome/desktop/notifications" = { - show-in-lock-screen = false; - }; - "org/gnome/desktop/screensaver" = { - lock-enabled = false; - picture-uri = "file://${pkgs.ghostty}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png"; - picture-options = "centered"; - primary-color = "#000000000000"; - secondary-color = "#000000000000"; - }; - "org/gnome/desktop/session" = { - idle-delay = mkUint32 0; - }; - "org/gnome/shell" = { - disable-user-extensions = false; - enabled-extensions = builtins.map (x: x.extensionUuid) ( - lib.filter (p: p ? extensionUuid) config.environment.systemPackages - ); - }; - }; - } - ]; - }; - - programs.geary.enable = false; } diff --git a/nix/vm/x11-gnome.nix b/nix/vm/x11-gnome.nix new file mode 100644 index 000000000..1994aea82 --- /dev/null +++ b/nix/vm/x11-gnome.nix @@ -0,0 +1,9 @@ +{...}: { + imports = [ + ./common-gnome.nix + ]; + + services.displayManager = { + defaultSession = "gnome-xorg"; + }; +} From 26f6b3ea8226a231949087827ff5bb92b932f350 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 5 Jan 2025 23:39:36 -0600 Subject: [PATCH 174/238] fix typo --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25a7c532c..5013ced8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,7 +84,7 @@ Several Nix virtual machine definitions are provided by the project for testing and developing Ghostty against multiple different Linux desktop environments. Running these requires a working Nix installation, either Nix on your -favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Futher +favorite Linux distribution, NixOS, or macOS with nix-darwin installed. Further requirements for macOS are detailed below. VMs should only be run on your local desktop and then powered off when not in @@ -128,7 +128,7 @@ We welcome the contribution of new VM definitions, as long as they meet the foll #### VM Definition Criteria 1. VMs should be as minimal as possible so that they build and launch quickly. - Additonal software can be added at runtime with a command like `nix run nixpkgs#`. + Additional software can be added at runtime with a command like `nix run nixpkgs#`. 2. VMs should not expose any services to the network, or run any remote access software like SSH, VNC or RDP. 3. VMs should auto-login using the "ghostty" user. From 450c019b4e50a1a79d36858866eab0f71c6d6d5c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 6 Jan 2025 23:42:23 -0600 Subject: [PATCH 175/238] nix vm: add plasma and cinnamon vms --- flake.nix | 8 ++++++++ nix/vm/common-cinnamon.nix | 14 ++++++++++++++ nix/vm/common-gnome.nix | 1 - nix/vm/common-plasma6.nix | 17 +++++++++++++++++ nix/vm/common.nix | 22 ++++++++++++++++++++++ nix/vm/wayland-cinnamon.nix | 7 +++++++ nix/vm/wayland-plasma6.nix | 6 ++++++ nix/vm/x11-cinnamon.nix | 7 +++++++ nix/vm/x11-plasma6.nix | 6 ++++++ 9 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 nix/vm/common-cinnamon.nix create mode 100644 nix/vm/common-plasma6.nix create mode 100644 nix/vm/wayland-cinnamon.nix create mode 100644 nix/vm/wayland-plasma6.nix create mode 100644 nix/vm/x11-cinnamon.nix create mode 100644 nix/vm/x11-plasma6.nix diff --git a/flake.nix b/flake.nix index c0fdd42c3..38aea5b80 100644 --- a/flake.nix +++ b/flake.nix @@ -79,8 +79,12 @@ } ); in { + "wayland-cinnamon-${system}" = makeVM ./nix/vm/wayland-cinnamon.nix; "wayland-gnome-${system}" = makeVM ./nix/vm/wayland-gnome.nix; + "wayland-plasma6-${system}" = makeVM ./nix/vm/wayland-plasma6.nix; + "x11-cinnamon-${system}" = makeVM ./nix/vm/x11-cinnamon.nix; "x11-gnome-${system}" = makeVM ./nix/vm/x11-gnome.nix; + "x11-plasma6-${system}" = makeVM ./nix/vm/x11-plasma6.nix; }; apps.${system} = let @@ -98,8 +102,12 @@ } ); in { + wayland-cinnamon = wrapVM "wayland-cinnamon"; wayland-gnome = wrapVM "wayland-gnome"; + wayland-plasma6 = wrapVM "wayland-plasma6"; + x11-cinnamon = wrapVM "x11-cinnamon"; x11-gnome = wrapVM "x11-gnome"; + x11-plasma6 = wrapVM "x11-plasma6"; }; } # Our supported systems are the same supported systems as the Zig binaries. diff --git a/nix/vm/common-cinnamon.nix b/nix/vm/common-cinnamon.nix new file mode 100644 index 000000000..a551321cf --- /dev/null +++ b/nix/vm/common-cinnamon.nix @@ -0,0 +1,14 @@ +{...}: { + services.xserver = { + displayManager = { + lightdm = { + enable = true; + }; + }; + desktopManager = { + cinnamon = { + enable = true; + }; + }; + }; +} diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix index 992a10d3a..d5018b457 100644 --- a/nix/vm/common-gnome.nix +++ b/nix/vm/common-gnome.nix @@ -5,7 +5,6 @@ ... }: { services.xserver = { - enable = true; displayManager = { gdm = { enable = true; diff --git a/nix/vm/common-plasma6.nix b/nix/vm/common-plasma6.nix new file mode 100644 index 000000000..3b280184c --- /dev/null +++ b/nix/vm/common-plasma6.nix @@ -0,0 +1,17 @@ +{...}: { + services = { + displayManager = { + sddm = { + enable = true; + wayland = { + enable = true; + }; + }; + }; + desktopManager = { + plasma6 = { + enable = true; + }; + }; + }; +} diff --git a/nix/vm/common.nix b/nix/vm/common.nix index 9e05cce4a..fa0305ad0 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -47,15 +47,37 @@ environment.systemPackages = [ pkgs.kitty pkgs.ghostty + pkgs.helix + pkgs.neovim pkgs.zig_0_13 ]; + security.polkit = { + enable = true; + }; + + services.dbus = { + enable = true; + }; + services.displayManager = { autoLogin = { user = "ghostty"; }; }; + services.libinput = { + enable = true; + }; + + services.qemuGuest = { + enable = true; + }; + + services.spice-vdagentd = { + enable = true; + }; + services.xserver = { enable = true; }; diff --git a/nix/vm/wayland-cinnamon.nix b/nix/vm/wayland-cinnamon.nix new file mode 100644 index 000000000..531c882b6 --- /dev/null +++ b/nix/vm/wayland-cinnamon.nix @@ -0,0 +1,7 @@ +{...}: { + imports = [ + ./common-cinnamon.nix + ]; + + services.displayManager.defaultSession = "cinnamon-wayland"; +} diff --git a/nix/vm/wayland-plasma6.nix b/nix/vm/wayland-plasma6.nix new file mode 100644 index 000000000..6e5a253b8 --- /dev/null +++ b/nix/vm/wayland-plasma6.nix @@ -0,0 +1,6 @@ +{...}: { + imports = [ + ./common-plasma6.nix + ]; + services.displayManager.defaultSession = "plasma"; +} diff --git a/nix/vm/x11-cinnamon.nix b/nix/vm/x11-cinnamon.nix new file mode 100644 index 000000000..636f235a2 --- /dev/null +++ b/nix/vm/x11-cinnamon.nix @@ -0,0 +1,7 @@ +{...}: { + imports = [ + ./common-cinnamon.nix + ]; + + services.displayManager.defaultSession = "cinnamon"; +} diff --git a/nix/vm/x11-plasma6.nix b/nix/vm/x11-plasma6.nix new file mode 100644 index 000000000..7818a80ca --- /dev/null +++ b/nix/vm/x11-plasma6.nix @@ -0,0 +1,6 @@ +{...}: { + imports = [ + ./common-plasma6.nix + ]; + services.displayManager.defaultSession = "plasmax11"; +} From 268fc1a0405e159359d6f3f246707fbf80c04b0b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 7 Jan 2025 08:42:30 -0600 Subject: [PATCH 176/238] nix vm: simplify vm definition --- CONTRIBUTING.md | 4 ++-- flake.nix | 45 ++++++++++++++++++----------------- nix/vm/common-gnome.nix | 52 ++++++++++++++++++++--------------------- nix/vm/common.nix | 8 ++----- 4 files changed, 53 insertions(+), 56 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5013ced8a..7415825f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,7 @@ software can be installed by using standard Nix mechanisms like `nix run nixpkgs ### Contributing new VM definitions -#### Acceptance Criteria +#### VM Acceptance Criteria We welcome the contribution of new VM definitions, as long as they meet the following criteria: @@ -130,5 +130,5 @@ We welcome the contribution of new VM definitions, as long as they meet the foll 1. VMs should be as minimal as possible so that they build and launch quickly. Additional software can be added at runtime with a command like `nix run nixpkgs#`. 2. VMs should not expose any services to the network, or run any remote access - software like SSH, VNC or RDP. + software like SSH daemons, VNC or RDP. 3. VMs should auto-login using the "ghostty" user. diff --git a/flake.nix b/flake.nix index 38aea5b80..81e9b422c 100644 --- a/flake.nix +++ b/flake.nix @@ -59,9 +59,9 @@ formatter.${system} = pkgs-stable.alejandra; - nixosConfigurations = let + apps.${system} = let makeVM = ( - path: + path: system: uid: gid: nixpkgs-stable.lib.nixosSystem { system = builtins.replaceStrings ["darwin"] ["linux"] system; modules = [ @@ -69,32 +69,33 @@ virtualisation.vmVariant = { virtualisation.host.pkgs = pkgs-stable; }; + nixpkgs.overlays = [ - self.overlays.releasefast + self.overlays.debug ]; + + users.groups.ghostty = { + gid = gid; + }; + + users.users.ghostty = { + uid = gid; + }; + + system.stateVersion = "24.11"; } ./nix/vm/common.nix path ]; } ); - in { - "wayland-cinnamon-${system}" = makeVM ./nix/vm/wayland-cinnamon.nix; - "wayland-gnome-${system}" = makeVM ./nix/vm/wayland-gnome.nix; - "wayland-plasma6-${system}" = makeVM ./nix/vm/wayland-plasma6.nix; - "x11-cinnamon-${system}" = makeVM ./nix/vm/x11-cinnamon.nix; - "x11-gnome-${system}" = makeVM ./nix/vm/x11-gnome.nix; - "x11-plasma6-${system}" = makeVM ./nix/vm/x11-plasma6.nix; - }; - - apps.${system} = let - wrapVM = ( - name: let + runVM = ( + path: let program = pkgs-stable.writeShellScript "run-ghostty-vm" '' SHARED_DIR=$(pwd) export SHARED_DIR - ${self.nixosConfigurations."${name}-${system}".config.system.build.vm}/bin/run-ghostty-vm + ${(makeVM path system 1000 1000).config.system.build.vm}/bin/run-ghostty-vm ''; in { type = "app"; @@ -102,12 +103,12 @@ } ); in { - wayland-cinnamon = wrapVM "wayland-cinnamon"; - wayland-gnome = wrapVM "wayland-gnome"; - wayland-plasma6 = wrapVM "wayland-plasma6"; - x11-cinnamon = wrapVM "x11-cinnamon"; - x11-gnome = wrapVM "x11-gnome"; - x11-plasma6 = wrapVM "x11-plasma6"; + wayland-cinnamon = runVM ./nix/vm/wayland-cinnamon.nix; + wayland-gnome = runVM ./nix/vm/wayland-gnome.nix; + wayland-plasma6 = runVM ./nix/vm/wayland-plasma6.nix; + x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix; + x11-gnome = runVM ./nix/vm/x11-gnome.nix; + x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix; }; } # Our supported systems are the same supported systems as the Zig binaries. diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix index d5018b457..d43f5dc9e 100644 --- a/nix/vm/common-gnome.nix +++ b/nix/vm/common-gnome.nix @@ -63,32 +63,6 @@ yelp ]; - services.gnome = { - gnome-browser-connector.enable = false; - gnome-initial-setup.enable = false; - gnome-online-accounts.enable = false; - gnome-remote-desktop.enable = false; - rygel.enable = false; - }; - - system.activationScripts = { - face = { - text = '' - mkdir -p /var/lib/AccountsService/{icons,users} - - cp ${pkgs.ghostty}/share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png /var/lib/AccountsService/icons/ghostty - - echo -e "[User]\nIcon=/var/lib/AccountsService/icons/ghostty\n" > /var/lib/AccountsService/users/ghostty - - chown root:root /var/lib/AccountsService/users/ghostty - chmod 0600 /var/lib/AccountsService/users/ghostty - - chown root:root /var/lib/AccountsService/icons/ghostty - chmod 0444 /var/lib/AccountsService/icons/ghostty - ''; - }; - }; - programs.dconf = { enable = true; profiles.user.databases = [ @@ -129,4 +103,30 @@ }; programs.geary.enable = false; + + services.gnome = { + gnome-browser-connector.enable = false; + gnome-initial-setup.enable = false; + gnome-online-accounts.enable = false; + gnome-remote-desktop.enable = false; + rygel.enable = false; + }; + + system.activationScripts = { + face = { + text = '' + mkdir -p /var/lib/AccountsService/{icons,users} + + cp ${pkgs.ghostty}/share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png /var/lib/AccountsService/icons/ghostty + + echo -e "[User]\nIcon=/var/lib/AccountsService/icons/ghostty\n" > /var/lib/AccountsService/users/ghostty + + chown root:root /var/lib/AccountsService/users/ghostty + chmod 0600 /var/lib/AccountsService/users/ghostty + + chown root:root /var/lib/AccountsService/icons/ghostty + chmod 0444 /var/lib/AccountsService/icons/ghostty + ''; + }; + }; } diff --git a/nix/vm/common.nix b/nix/vm/common.nix index fa0305ad0..6e80dd5b6 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -25,13 +25,10 @@ users.mutableUsers = true; - users.groups.ghostty = { - gid = 1000; - }; + users.groups.ghostty = {}; users.users.ghostty = { description = "Ghostty"; - uid = 1000; group = "ghostty"; extraGroups = ["wheel"]; isNormalUser = true; @@ -49,6 +46,7 @@ pkgs.ghostty pkgs.helix pkgs.neovim + pkgs.xterm pkgs.zig_0_13 ]; @@ -81,6 +79,4 @@ services.xserver = { enable = true; }; - - system.stateVersion = "24.11"; } From e1e2f94681d0b11a99c1a713a61fbfd36e6f9bb6 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 7 Jan 2025 21:46:21 -0600 Subject: [PATCH 177/238] nix vm: try and make vm creation more re-usable --- nix/vm/create.nix | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 nix/vm/create.nix diff --git a/nix/vm/create.nix b/nix/vm/create.nix new file mode 100644 index 000000000..8aea5d19d --- /dev/null +++ b/nix/vm/create.nix @@ -0,0 +1,41 @@ +{ + system, + nixpkgs, + overlay, + path, + uid ? 1000, + gid ? 1000, +}: let + pkgs = import nixpkgs { + inherit system; + overlays = [ + overlay + ]; + }; +in + nixpkgs.lib.nixosSystem { + system = builtins.replaceStrings ["darwin"] ["linux"] system; + modules = [ + { + virtualisation.vmVariant = { + virtualisation.host.pkgs = pkgs; + }; + + nixpkgs.overlays = [ + overlay + ]; + + users.groups.ghostty = { + gid = gid; + }; + + users.users.ghostty = { + uid = uid; + }; + + system.stateVersion = "24.11"; + } + ./common.nix + path + ]; + } From 321119e0010290fb6f702b87ca251e61a3899eb9 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 7 Jan 2025 22:03:04 -0600 Subject: [PATCH 178/238] nix vm: more reusability --- flake.nix | 40 ++++++++++---------------------------- nix/vm/common-cinnamon.nix | 4 ++++ nix/vm/common-gnome.nix | 4 ++++ nix/vm/common-plasma6.nix | 4 ++++ nix/vm/create-cinnamon.nix | 12 ++++++++++++ nix/vm/create-gnome.nix | 12 ++++++++++++ nix/vm/create-plasma6.nix | 12 ++++++++++++ nix/vm/create.nix | 5 +++-- 8 files changed, 61 insertions(+), 32 deletions(-) create mode 100644 nix/vm/create-cinnamon.nix create mode 100644 nix/vm/create-gnome.nix create mode 100644 nix/vm/create-plasma6.nix diff --git a/flake.nix b/flake.nix index 81e9b422c..b3cd77087 100644 --- a/flake.nix +++ b/flake.nix @@ -60,42 +60,18 @@ formatter.${system} = pkgs-stable.alejandra; apps.${system} = let - makeVM = ( - path: system: uid: gid: - nixpkgs-stable.lib.nixosSystem { - system = builtins.replaceStrings ["darwin"] ["linux"] system; - modules = [ - { - virtualisation.vmVariant = { - virtualisation.host.pkgs = pkgs-stable; - }; - - nixpkgs.overlays = [ - self.overlays.debug - ]; - - users.groups.ghostty = { - gid = gid; - }; - - users.users.ghostty = { - uid = gid; - }; - - system.stateVersion = "24.11"; - } - ./nix/vm/common.nix - path - ]; - } - ); runVM = ( path: let + vm = import ./nix/vm/create.nix { + inherit system path; + nixpkgs = nixpkgs-stable; + overlay = self.overlays.debug; + }; program = pkgs-stable.writeShellScript "run-ghostty-vm" '' SHARED_DIR=$(pwd) export SHARED_DIR - ${(makeVM path system 1000 1000).config.system.build.vm}/bin/run-ghostty-vm + ${vm.config.system.build.vm}/bin/run-ghostty-vm ''; in { type = "app"; @@ -124,6 +100,10 @@ ghostty = self.packages.${prev.system}.ghostty-debug; }; }; + create-vm = import ./nix/vm/create.nix; + create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix; + create-gnome-vm = import ./nix/vm/create-gnome.nix; + create-plasma6-vm = import ./nix/vm/create-plasma6.nix; }; nixConfig = { diff --git a/nix/vm/common-cinnamon.nix b/nix/vm/common-cinnamon.nix index a551321cf..dabe5e701 100644 --- a/nix/vm/common-cinnamon.nix +++ b/nix/vm/common-cinnamon.nix @@ -1,4 +1,8 @@ {...}: { + imports = [ + ./common.nix + ]; + services.xserver = { displayManager = { lightdm = { diff --git a/nix/vm/common-gnome.nix b/nix/vm/common-gnome.nix index d43f5dc9e..0c2bef150 100644 --- a/nix/vm/common-gnome.nix +++ b/nix/vm/common-gnome.nix @@ -4,6 +4,10 @@ pkgs, ... }: { + imports = [ + ./common.nix + ]; + services.xserver = { displayManager = { gdm = { diff --git a/nix/vm/common-plasma6.nix b/nix/vm/common-plasma6.nix index 3b280184c..e5c9bd4d8 100644 --- a/nix/vm/common-plasma6.nix +++ b/nix/vm/common-plasma6.nix @@ -1,4 +1,8 @@ {...}: { + imports = [ + ./common.nix + ]; + services = { displayManager = { sddm = { diff --git a/nix/vm/create-cinnamon.nix b/nix/vm/create-cinnamon.nix new file mode 100644 index 000000000..0efd3c72c --- /dev/null +++ b/nix/vm/create-cinnamon.nix @@ -0,0 +1,12 @@ +{ + system, + nixpkgs, + overlay, + path, + uid ? 1000, + gid ? 1000, +}: +import ./create.nix { + inherit system nixpkgs overlay path uid gid; + common = ./common-cinnamon.nix; +} diff --git a/nix/vm/create-gnome.nix b/nix/vm/create-gnome.nix new file mode 100644 index 000000000..9fb7f3914 --- /dev/null +++ b/nix/vm/create-gnome.nix @@ -0,0 +1,12 @@ +{ + system, + nixpkgs, + overlay, + path, + uid ? 1000, + gid ? 1000, +}: +import ./create.nix { + inherit system nixpkgs overlay path uid gid; + common = ./common-gnome.nix; +} diff --git a/nix/vm/create-plasma6.nix b/nix/vm/create-plasma6.nix new file mode 100644 index 000000000..47785899f --- /dev/null +++ b/nix/vm/create-plasma6.nix @@ -0,0 +1,12 @@ +{ + system, + nixpkgs, + overlay, + path, + uid ? 1000, + gid ? 1000, +}: +import ./create.nix { + inherit system nixpkgs overlay path uid gid; + common = ./common-plasma6.nix; +} diff --git a/nix/vm/create.nix b/nix/vm/create.nix index 8aea5d19d..4481d4345 100644 --- a/nix/vm/create.nix +++ b/nix/vm/create.nix @@ -3,6 +3,7 @@ nixpkgs, overlay, path, + common ? ./common.nix, uid ? 1000, gid ? 1000, }: let @@ -33,9 +34,9 @@ in uid = uid; }; - system.stateVersion = "24.11"; + system.stateVersion = nixpkgs.lib.trivial.release; } - ./common.nix + common path ]; } From 1ac56a7ac2552d2d9f2ee67f597a5c574670956a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 7 Jan 2025 22:22:38 -0600 Subject: [PATCH 179/238] nix vm: +fish +zsh -zig --- nix/vm/common.nix | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nix/vm/common.nix b/nix/vm/common.nix index 6e80dd5b6..eefd7c1c0 100644 --- a/nix/vm/common.nix +++ b/nix/vm/common.nix @@ -23,7 +23,7 @@ ''; }; - users.mutableUsers = true; + users.mutableUsers = false; users.groups.ghostty = {}; @@ -43,11 +43,12 @@ environment.systemPackages = [ pkgs.kitty + pkgs.fish pkgs.ghostty pkgs.helix pkgs.neovim pkgs.xterm - pkgs.zig_0_13 + pkgs.zsh ]; security.polkit = { From 423133bc3c4151d2f289282b9c2bdf09bc84c9dd Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 15 Jan 2025 11:56:19 -0600 Subject: [PATCH 180/238] nix: document how to create custom vms --- CONTRIBUTING.md | 44 +++++++++++++++++++++++++++++++++++++- flake.nix | 6 +++--- nix/vm/create-cinnamon.nix | 4 ++-- nix/vm/create-gnome.nix | 4 ++-- nix/vm/create-plasma6.nix | 4 ++-- nix/vm/create.nix | 4 ++-- 6 files changed, 54 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7415825f9..a7233b2c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,7 +98,7 @@ software can be installed by using standard Nix mechanisms like `nix run nixpkgs 1. Check out the Ghostty source and change to the directory. 2. Run `nix run .#`. `` can be any of the VMs defined in the `nix/vm` directory (without the `.nix` suffix) excluding any file prefixed - with `common`. + with `common` or `create`. 3. The VM will build and then launch. Depending on the speed of your system, this can take a while, but eventually you should get a new VM window. 4. The Ghostty source directory should be mounted to `/tmp/shared` in the VM. Depending @@ -114,6 +114,48 @@ software can be installed by using standard Nix mechanisms like `nix run nixpkgs 2. Once the Linux builder has been enabled, you should be able to follow the Linux instructions above to launch a VM. +### Custom VMs + +To easily create a custom VM without modifying the Ghostty source, create a new +directory, then create a file called `flake.nix` with the following text in the +new directory. + +``` +{ + inputs = { + nixpkgs.url = "nixpkgs/nixpkgs-unstable"; + ghostty.url = "github:ghostty-org/ghostty"; + }; + outputs = { + nixpkgs, + ghostty, + ... + }: { + nixosConfigurations.custom-vm = ghostty.create-gnome-vm { + nixpkgs = nixpkgs; + system = "x86_64-linux"; + overlay = ghostty.overlays.releasefast; + # module = ./configuration.nix # also works + module = {pkgs, ...}: { + environment.systemPackages = [ + pkgs.btop + ]; + }; + }; + }; +} +``` + +The custom VM can then be run with a command like this: + +``` +nix run .#nixosConfigurations.custom-vm.config.system.build.vm +``` + +A file named `ghostty.qcow2` will be created that is used to persist any changes +made in the VM. To "reset" the VM to default delete the file and it will be +recreated the next time you run the VM. + ### Contributing new VM definitions #### VM Acceptance Criteria diff --git a/flake.nix b/flake.nix index b3cd77087..d787c0609 100644 --- a/flake.nix +++ b/flake.nix @@ -61,9 +61,9 @@ apps.${system} = let runVM = ( - path: let + module: let vm = import ./nix/vm/create.nix { - inherit system path; + inherit system module; nixpkgs = nixpkgs-stable; overlay = self.overlays.debug; }; @@ -71,7 +71,7 @@ SHARED_DIR=$(pwd) export SHARED_DIR - ${vm.config.system.build.vm}/bin/run-ghostty-vm + ${pkgs-stable.lib.getExe vm.config.system.build.vm} "$@" ''; in { type = "app"; diff --git a/nix/vm/create-cinnamon.nix b/nix/vm/create-cinnamon.nix index 0efd3c72c..a9d9e44d7 100644 --- a/nix/vm/create-cinnamon.nix +++ b/nix/vm/create-cinnamon.nix @@ -2,11 +2,11 @@ system, nixpkgs, overlay, - path, + module, uid ? 1000, gid ? 1000, }: import ./create.nix { - inherit system nixpkgs overlay path uid gid; + inherit system nixpkgs overlay module uid gid; common = ./common-cinnamon.nix; } diff --git a/nix/vm/create-gnome.nix b/nix/vm/create-gnome.nix index 9fb7f3914..bcd31f2b6 100644 --- a/nix/vm/create-gnome.nix +++ b/nix/vm/create-gnome.nix @@ -2,11 +2,11 @@ system, nixpkgs, overlay, - path, + module, uid ? 1000, gid ? 1000, }: import ./create.nix { - inherit system nixpkgs overlay path uid gid; + inherit system nixpkgs overlay module uid gid; common = ./common-gnome.nix; } diff --git a/nix/vm/create-plasma6.nix b/nix/vm/create-plasma6.nix index 47785899f..ede5371f3 100644 --- a/nix/vm/create-plasma6.nix +++ b/nix/vm/create-plasma6.nix @@ -2,11 +2,11 @@ system, nixpkgs, overlay, - path, + module, uid ? 1000, gid ? 1000, }: import ./create.nix { - inherit system nixpkgs overlay path uid gid; + inherit system nixpkgs overlay module uid gid; common = ./common-plasma6.nix; } diff --git a/nix/vm/create.nix b/nix/vm/create.nix index 4481d4345..f8fe8500d 100644 --- a/nix/vm/create.nix +++ b/nix/vm/create.nix @@ -2,7 +2,7 @@ system, nixpkgs, overlay, - path, + module, common ? ./common.nix, uid ? 1000, gid ? 1000, @@ -37,6 +37,6 @@ in system.stateVersion = nixpkgs.lib.trivial.release; } common - path + module ]; } From 7716f9885665ee2501aa7a26446013aca761e040 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Wed, 15 Jan 2025 23:20:59 +0100 Subject: [PATCH 181/238] gtk(wayland): respect compositor SSD preferences Compositors can actually tell us whether they want to use CSD or SSD! --- src/apprt/gtk/App.zig | 16 ++++----- src/apprt/gtk/Window.zig | 4 +-- src/apprt/gtk/winproto/wayland.zig | 56 ++++++++++++++++++++---------- src/apprt/gtk/winproto/x11.zig | 2 +- src/config/Config.zig | 52 +++++++++++++-------------- 5 files changed, 73 insertions(+), 57 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 96275684e..63ba0a692 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1881,16 +1881,14 @@ fn initContextMenu(self: *App) void { c.g_menu_append(section, "Terminal Inspector", "win.toggle_inspector"); } - if (!self.config.@"window-decoration".isCSD()) { - const section = c.g_menu_new(); - defer c.g_object_unref(section); - const submenu = c.g_menu_new(); - defer c.g_object_unref(submenu); + const section = c.g_menu_new(); + defer c.g_object_unref(section); + const submenu = c.g_menu_new(); + defer c.g_object_unref(submenu); - initMenuContent(@ptrCast(submenu)); - c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu))); - c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); - } + initMenuContent(@ptrCast(submenu)); + c.g_menu_append_submenu(section, "Menu", @ptrCast(@alignCast(submenu))); + c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); self.context_menu = menu; } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 10af25101..03fcd05db 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -584,8 +584,8 @@ pub fn toggleFullscreen(self: *Window) void { /// Toggle the window decorations for this window. pub fn toggleWindowDecorations(self: *Window) void { self.app.config.@"window-decoration" = switch (self.app.config.@"window-decoration") { - .client, .server => .none, - .none => .server, + .auto, .client, .server => .none, + .none => .client, }; self.updateConfig(&self.app.config) catch {}; } diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index efe0d89cd..7a28fc92c 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -22,6 +22,8 @@ pub const App = struct { // FIXME: replace with `zxdg_decoration_v1` once GTK merges // https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398 kde_decoration_manager: ?*org.KdeKwinServerDecorationManager = null, + + default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, }; pub fn init( @@ -57,6 +59,12 @@ pub const App = struct { registry.setListener(*Context, registryListener, context); if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + if (context.kde_decoration_manager != null) { + // FIXME: Roundtrip again because we have to wait for the decoration + // manager to respond with the preferred default mode. Ew. + if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed; + } + return .{ .display = display, .context = context, @@ -82,25 +90,22 @@ pub const App = struct { ) void { switch (event) { // https://wayland.app/protocols/wayland#wl_registry:event:global - .global => |global| global: { + .global => |global| { log.debug("wl_registry.global: interface={s}", .{global.interface}); if (registryBind( org.KdeKwinBlurManager, registry, global, - 1, )) |blur_manager| { context.kde_blur_manager = blur_manager; - break :global; } else if (registryBind( org.KdeKwinServerDecorationManager, registry, global, - 1, )) |deco_manager| { context.kde_decoration_manager = deco_manager; - break :global; + deco_manager.setListener(*Context, decoManagerListener, context); } }, @@ -119,7 +124,6 @@ pub const App = struct { comptime T: type, registry: *wl.Registry, global: anytype, - version: u32, ) ?*T { if (std.mem.orderZ( u8, @@ -127,7 +131,7 @@ pub const App = struct { T.interface.name, ) != .eq) return null; - return registry.bind(global.name, T, version) catch |err| { + return registry.bind(global.name, T, T.generated_version) catch |err| { log.warn("error binding interface {s} error={}", .{ global.interface, err, @@ -135,6 +139,18 @@ pub const App = struct { return 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. @@ -235,13 +251,14 @@ pub const Window = struct { } pub fn clientSideDecorationEnabled(self: Window) bool { - // Note: we should change this to being the actual mode - // state emitted by the decoration manager. + // Compositor doesn't support the SSD protocol + if (self.decoration == null) return true; - // We are CSD if we don't support the SSD Wayland protocol - // or if we do but we're in CSD mode. - return self.decoration == null or - self.config.window_decoration.isCSD(); + return switch (self.getDecorationMode()) { + .Client => true, + .Server, .None => false, + else => unreachable, + }; } /// Update the blur state of the window. @@ -269,14 +286,17 @@ pub const Window = struct { fn syncDecoration(self: *Window) !void { const deco = self.decoration orelse return; - const mode: org.KdeKwinServerDecoration.Mode = switch (self.config.window_decoration) { + // 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.config.window_decoration) { + .auto => self.app_context.default_deco_mode orelse .Client, .client => .Client, .server => .Server, .none => .None, }; - - // The protocol requests uint instead of enum so we have - // to convert it. - deco.requestMode(@intCast(@intFromEnum(mode))); } }; diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index fe3b9218d..4f607d1ef 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -168,7 +168,7 @@ pub const Window = struct { .blur = config.@"background-blur-radius".enabled(), .has_decoration = switch (config.@"window-decoration") { .none => false, - .client, .server => true, + .auto, .client, .server => true, }, }; } diff --git a/src/config/Config.zig b/src/config/Config.zig index 4aba8ce32..baac2cde7 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1138,27 +1138,33 @@ keybind: Keybinds = .{}, /// borders, etc. will not be shown. On macOS, this will also disable /// tabs (enforced by the system). /// -/// * `client` - Prefer client-side decorations. This is the default. +/// * `auto` - Automatically decide to use either client-side or server-side +/// decorations based on the detected preferences of the current OS and +/// desktop environment. This option usually makes Ghostty look the most +/// "native" for your desktop. +/// +/// * `client` - Prefer client-side decorations. /// /// * `server` - Prefer server-side decorations. This is only relevant /// on Linux with GTK. This currently only works on Linux with Wayland /// and the `org_kde_kwin_server_decoration` protocol available (e.g. -/// KDE Plasma, but almost any non-Gnome desktop supports this protocol). +/// KDE Plasma, but almost any non-GNOME desktop supports this protocol). /// /// If `server` is set but the environment doesn't support server-side /// decorations, client-side decorations will be used instead. /// -/// The default value is `client`. +/// The default value is `auto`. /// -/// This setting also accepts boolean true and false values. If set to `true`, -/// this is equivalent to `client`. If set to `false`, this is equivalent to -/// `none`. This is a convenience for users who live primarily on systems -/// that don't differentiate between client and server-side decorations -/// (e.g. macOS and Windows). +/// For the sake of backwards compatibility and convenience, this setting also +/// accepts boolean true and false values. If set to `true`, this is equivalent +/// to `auto`. If set to `false`, this is equivalent to `none`. +/// This is convenient for users who live primarily on systems that don't +/// differentiate between client and server-side decorations (e.g. macOS and +/// Windows). /// /// The "toggle_window_decorations" keybind action can be used to create /// a keybinding to toggle this setting at runtime. This will always toggle -/// back to "server" if the current value is "none" (this is an issue +/// back to "auto" if the current value is "none" (this is an issue /// that will be fixed in the future). /// /// Changing this configuration in your configuration and reloading will @@ -1166,7 +1172,7 @@ keybind: Keybinds = .{}, /// /// macOS: To hide the titlebar without removing the native window borders /// or rounded corners, use `macos-titlebar-style = hidden` instead. -@"window-decoration": WindowDecoration = .client, +@"window-decoration": WindowDecoration = .auto, /// The font that will be used for the application's window and tab titles. /// @@ -5917,44 +5923,32 @@ pub const BackgroundBlur = union(enum) { /// See window-decoration pub const WindowDecoration = enum { + auto, client, server, none, pub fn parseCLI(input_: ?[]const u8) !WindowDecoration { - const input = input_ orelse return .client; + const input = input_ orelse return .auto; return if (cli.args.parseBool(input)) |b| - if (b) .client else .none + if (b) .auto else .none else |_| if (std.meta.stringToEnum(WindowDecoration, input)) |v| v else error.InvalidValue; } - /// Returns true if the window decoration setting results in - /// CSD (client-side decorations). Note that this only returns the - /// user requested behavior. Depending on available APIs (e.g. - /// Wayland protocols), the actual behavior may differ and the apprt - /// should rely on actual windowing APIs to determine the actual - /// status. - pub fn isCSD(self: WindowDecoration) bool { - return switch (self) { - .client => true, - .server, .none => false, - }; - } - test "parse WindowDecoration" { const testing = std.testing; { const v = try WindowDecoration.parseCLI(null); - try testing.expectEqual(WindowDecoration.client, v); + try testing.expectEqual(WindowDecoration.auto, v); } { const v = try WindowDecoration.parseCLI("true"); - try testing.expectEqual(WindowDecoration.client, v); + try testing.expectEqual(WindowDecoration.auto, v); } { const v = try WindowDecoration.parseCLI("false"); @@ -5968,6 +5962,10 @@ pub const WindowDecoration = enum { const v = try WindowDecoration.parseCLI("client"); try testing.expectEqual(WindowDecoration.client, v); } + { + const v = try WindowDecoration.parseCLI("auto"); + try testing.expectEqual(WindowDecoration.auto, v); + } { const v = try WindowDecoration.parseCLI("none"); try testing.expectEqual(WindowDecoration.none, v); From b1becb12c026e446fd66e637c4a243c8e8788997 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 15 Jan 2025 18:08:11 -0500 Subject: [PATCH 182/238] fix(Metal): handle non-extended padding color transparency We were returning bg colors when we shouldn't have since when we have background color transparency we need to return any bg color cells as fully transparent rather than their actual color. --- src/renderer/shaders/cell.metal | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index a4e4837b6..d4657ec28 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -248,7 +248,15 @@ fragment float4 cell_bg_fragment( ) { int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size)); - float4 bg = in.bg_color; + float4 bg = float4(0.0); + // If we have any background transparency then we render bg-colored cells as + // fully transparent, since the background is handled by the layer bg color + // and we don't want to double up our bg color, but if our bg color is fully + // opaque then our layer is opaque and can't handle transparency, so we need + // to return the bg color directly instead. + if (uniforms.bg_color.a == 255) { + bg = in.bg_color; + } // Clamp x position, extends edge bg colors in to padding on sides. if (grid_pos.x < 0) { @@ -285,16 +293,7 @@ fragment float4 cell_bg_fragment( // We have special case handling for when the cell color matches the bg color. if (all(cell_color == uniforms.bg_color)) { - // If we have any background transparency then we render bg-colored cells as - // fully transparent, since the background is handled by the layer bg color - // and we don't want to double up our bg color, but if our bg color is fully - // opaque then our layer is opaque and can't handle transparency, so we need - // to return the bg color directly instead. - if (uniforms.bg_color.a == 255) { - return bg; - } else { - return float4(0.0); - } + return bg; } // Convert the color and return it. From 3159a7bec783f6a47b05c620c4550355729298f9 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 15 Jan 2025 20:37:31 -0600 Subject: [PATCH 183/238] nix: add x11 xfce vm --- flake.nix | 2 ++ nix/vm/common-xfce.nix | 18 ++++++++++++++++++ nix/vm/create-xfce.nix | 12 ++++++++++++ nix/vm/x11-xfce.nix | 7 +++++++ 4 files changed, 39 insertions(+) create mode 100644 nix/vm/common-xfce.nix create mode 100644 nix/vm/create-xfce.nix create mode 100644 nix/vm/x11-xfce.nix diff --git a/flake.nix b/flake.nix index d787c0609..3256c7c15 100644 --- a/flake.nix +++ b/flake.nix @@ -85,6 +85,7 @@ x11-cinnamon = runVM ./nix/vm/x11-cinnamon.nix; x11-gnome = runVM ./nix/vm/x11-gnome.nix; x11-plasma6 = runVM ./nix/vm/x11-plasma6.nix; + x11-xfce = runVM ./nix/vm/x11-xfce.nix; }; } # Our supported systems are the same supported systems as the Zig binaries. @@ -104,6 +105,7 @@ create-cinnamon-vm = import ./nix/vm/create-cinnamon.nix; create-gnome-vm = import ./nix/vm/create-gnome.nix; create-plasma6-vm = import ./nix/vm/create-plasma6.nix; + create-xfce-vm = import ./nix/vm/create-xfce.nix; }; nixConfig = { diff --git a/nix/vm/common-xfce.nix b/nix/vm/common-xfce.nix new file mode 100644 index 000000000..12a20d8d8 --- /dev/null +++ b/nix/vm/common-xfce.nix @@ -0,0 +1,18 @@ +{...}: { + imports = [ + ./common.nix + ]; + + services.xserver = { + displayManager = { + lightdm = { + enable = true; + }; + }; + desktopManager = { + xfce = { + enable = true; + }; + }; + }; +} diff --git a/nix/vm/create-xfce.nix b/nix/vm/create-xfce.nix new file mode 100644 index 000000000..d1789472d --- /dev/null +++ b/nix/vm/create-xfce.nix @@ -0,0 +1,12 @@ +{ + system, + nixpkgs, + overlay, + module, + uid ? 1000, + gid ? 1000, +}: +import ./create.nix { + inherit system nixpkgs overlay module uid gid; + common = ./common-xfce.nix; +} diff --git a/nix/vm/x11-xfce.nix b/nix/vm/x11-xfce.nix new file mode 100644 index 000000000..71eb87f2f --- /dev/null +++ b/nix/vm/x11-xfce.nix @@ -0,0 +1,7 @@ +{...}: { + imports = [ + ./common-xfce.nix + ]; + + services.displayManager.defaultSession = "xfce"; +} From 6af1850ab4eb7a3720b971dc4386024a8ed986ef Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Tue, 14 Jan 2025 14:12:08 -0500 Subject: [PATCH 184/238] bash: less intrusive automatic shell integration We now use a temporary function (__ghostty_bash_startup) to perform the bash startup sequence. This gives us a local function scope in which to store some temporary values (like rcfile). This way, they won't leak into the sourced files' scopes. Also, use `~/` instead of `$HOME` for home directory paths as a simpler shorthand notation. --- src/shell-integration/bash/ghostty.bash | 72 +++++++++++++------------ 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 71c644b69..7de55f982 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -20,14 +20,13 @@ if [[ "$-" != *i* ]] ; then builtin return; fi if [ -z "$GHOSTTY_RESOURCES_DIR" ]; then builtin return; fi -# When automatic shell integration is active, we need to manually -# load the normal bash startup files based on the injected state. +# When automatic shell integration is active, we were started in POSIX +# mode and need to manually recreate the bash startup sequence. if [ -n "$GHOSTTY_BASH_INJECT" ]; then - builtin declare ghostty_bash_inject="$GHOSTTY_BASH_INJECT" - builtin unset GHOSTTY_BASH_INJECT ENV - - # At this point, we're in POSIX mode and rely on the injected - # flags to guide is through the rest of the startup sequence. + # Store a temporary copy of our startup flags and unset these global + # environment variables so we can safely handle reentrancy. + builtin declare __ghostty_bash_flags="$GHOSTTY_BASH_INJECT" + builtin unset ENV GHOSTTY_BASH_INJECT # Restore bash's default 'posix' behavior. Also reset 'inherit_errexit', # which doesn't happen as part of the 'posix' reset. @@ -40,35 +39,40 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then builtin unset GHOSTTY_BASH_UNEXPORT_HISTFILE fi - # Manually source the startup files, respecting the injected flags like - # --norc and --noprofile that we parsed with the shell integration code. - # - # See also: run_startup_files() in shell.c in the Bash source code - if builtin shopt -q login_shell; then - if [[ $ghostty_bash_inject != *"--noprofile"* ]]; then - [ -r /etc/profile ] && builtin source "/etc/profile" - for rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do - [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } - done - fi - else - if [[ $ghostty_bash_inject != *"--norc"* ]]; then - # The location of the system bashrc is determined at bash build - # time via -DSYS_BASHRC and can therefore vary across distros: - # Arch, Debian, Ubuntu use /etc/bash.bashrc - # Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC - # Void Linux uses /etc/bash/bashrc - # Nixos uses /etc/bashrc - for rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do - [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } - done - if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi - [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" - fi - fi + # Manually source the startup files. See INVOCATION in bash(1) and + # run_startup_files() in shell.c in the Bash source code. + function __ghostty_bash_startup() { + builtin local rcfile + if builtin shopt -q login_shell; then + if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then + [ -r /etc/profile ] && builtin source "/etc/profile" + for rcfile in ~/.bash_profile ~/.bash_login ~/.profile; do + [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } + done + fi + else + if [[ $__ghostty_bash_flags != *"--norc"* ]]; then + # The location of the system bashrc is determined at bash build + # time via -DSYS_BASHRC and can therefore vary across distros: + # Arch, Debian, Ubuntu use /etc/bash.bashrc + # Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC + # Void Linux uses /etc/bash/bashrc + # Nixos uses /etc/bashrc + for rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do + [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } + done + if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE=~/.bashrc; fi + [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" + fi + fi + } + + __ghostty_bash_startup + + builtin unset -f __ghostty_bash_startup + builtin unset -v __ghostty_bash_flags builtin unset GHOSTTY_BASH_RCFILE - builtin unset ghostty_bash_inject rcfile fi # Sudo From 07994d10e9e96d9bb5244ca1e355bef464e29a42 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 16 Jan 2025 08:22:40 -0500 Subject: [PATCH 185/238] bash: remove sed dependency for history processing We post-process history 1's output to extract the current command. This processing needs to strip the leading history number, an optional * character indicating whether the entry was modified (or a space), and then a space separating character. We were previously using sed(1) for this, but we can implement an equivalent transformation using bash's native parameter expansion syntax. This also results in ~4x reduction in per-prompt command overhead. --- src/shell-integration/bash/bash-preexec.sh | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/bash/bash-preexec.sh b/src/shell-integration/bash/bash-preexec.sh index 14a677888..cd0ea06c7 100644 --- a/src/shell-integration/bash/bash-preexec.sh +++ b/src/shell-integration/bash/bash-preexec.sh @@ -250,10 +250,8 @@ __bp_preexec_invoke_exec() { fi local this_command - this_command=$( - export LC_ALL=C - HISTTIMEFORMAT='' builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //' - ) + this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1) + this_command="${this_command#*[[:digit:]][* ] }" # Sanity check to make sure we have something to invoke our function with. if [[ -z "$this_command" ]]; then From df2d0b33cc46160f834ecc13acfdcdbe34b536ad Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 16 Jan 2025 08:30:27 -0500 Subject: [PATCH 186/238] bash: improve prior_trap processing We use `trap` to bootstrap our installation function (__bp_install). We remove our code upon first execution but need to restore any preexisting trap calls. We previously used `sed` to process the trap string, but that had two downsides: 1. `sed` is an external command dependency. It needs to exist on the system, and we need to invoke it in a subshell (which has some runtime cost). 2. The regular expression pattern was imperfect and didn't handle trickier cases like `'` characters in the trap string: $ (trap "echo 'hello'" DEBUG; trap -p DEBUG) hello trap -- 'echo '\''hello'\''' DEBUG This change removes the dependency on `sed` by locally evaluating the trap string and extracting any prior trap. This works reliably because we control the format our trap string, which looks like this (with newlines expanded): __bp_trap_string="$(trap -p DEBUG)" trap - DEBUG __bp_install --- src/shell-integration/bash/bash-preexec.sh | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/shell-integration/bash/bash-preexec.sh b/src/shell-integration/bash/bash-preexec.sh index 14a677888..9d3357387 100644 --- a/src/shell-integration/bash/bash-preexec.sh +++ b/src/shell-integration/bash/bash-preexec.sh @@ -297,10 +297,8 @@ __bp_install() { trap '__bp_preexec_invoke_exec "$_"' DEBUG # Preserve any prior DEBUG trap as a preexec function - local prior_trap - # we can't easily do this with variable expansion. Leaving as sed command. - # shellcheck disable=SC2001 - prior_trap=$(sed "s/[^']*'\(.*\)'[^']*/\1/" <<<"${__bp_trap_string:-}") + eval "local trap_argv=(${__bp_trap_string:-})" + local prior_trap=${trap_argv[2]:-} unset __bp_trap_string if [[ -n "$prior_trap" ]]; then eval '__bp_original_debug_trap() { From a5853c4de8226a1df2a6ea7d96ddf936e6b8876d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 16 Jan 2025 14:02:56 -0700 Subject: [PATCH 187/238] macos: respect the "auto" window decoration setting --- macos/Sources/Ghostty/Ghostty.Config.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 9d45ea9bb..2a24b0257 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -562,10 +562,11 @@ extension Ghostty.Config { case none case client case server + case auto func enabled() -> Bool { switch self { - case .client, .server: return true + case .client, .server, .auto: return true case .none: return false } } From 860f1f635cd82dbbffd1c9a9d67d20cccd5d1442 Mon Sep 17 00:00:00 2001 From: Albert Dong Date: Thu, 16 Jan 2025 14:14:48 -0800 Subject: [PATCH 188/238] Manually call orderOut on terminal close alert Allowing the alert to be automatically closed after the completion handler finishes doesn't seem to play well when the completion handler closes the window on which the alert is attached --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 2 +- macos/Sources/Ghostty/Ghostty.TerminalSplit.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index bda6d62bf..0c5b50b53 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -449,9 +449,9 @@ class BaseTerminalController: NSWindowController, alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning alert.beginSheetModal(for: window, completionHandler: { response in - self.alert = nil switch (response) { case .alertFirstButtonReturn: + alert.window.orderOut(nil) window.close() default: diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index cc3bef149..cec178245 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -205,6 +205,7 @@ extension Ghostty { alert.beginSheetModal(for: window, completionHandler: { response in switch (response) { case .alertFirstButtonReturn: + alert.window.orderOut(nil) node = nil default: From 85b1cfa44f4d70c9cc37edd3ba595e45fe642e67 Mon Sep 17 00:00:00 2001 From: Gabriel Holodak Date: Thu, 16 Jan 2025 18:42:33 -0500 Subject: [PATCH 189/238] fix(gtk): confirm tab close on close_tab action --- src/apprt/gtk/App.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 63ba0a692..193710293 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -579,7 +579,7 @@ fn closeTab(_: *App, target: apprt.Target) !void { return; }; - tab.window.closeTab(tab); + tab.closeWithConfirmation(); }, } } From 2a1b51ec94b57aecfede58af22f6896480e29417 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 16 Jan 2025 22:28:22 -0500 Subject: [PATCH 190/238] fix(Metal): fix incorrect premultiplication of colors Also make sure to divide alpha out before applying gamma encoding back to text color when not using linear blending. --- src/renderer/shaders/cell.metal | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index d4657ec28..17f811a19 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -139,7 +139,7 @@ float4 load_color( // already have the correct color here and // can premultiply and return it. if (display_p3 && !linear) { - color *= color.a; + color.rgb *= color.a; return color; } @@ -167,7 +167,7 @@ float4 load_color( } // Premultiply our color by its alpha. - color *= color.a; + color.rgb *= color.a; return color; } @@ -503,12 +503,12 @@ fragment float4 cell_text_fragment( // If we're not doing linear blending, then we need to // re-apply the gamma encoding to our color manually. // - // We do it BEFORE premultiplying the alpha because - // we want to produce the effect of not linearizing - // it in the first place in order to match the look - // of software that never does this. + // Since the alpha is premultiplied, we need to divide + // it out before unlinearizing and re-multiply it after. if (!uniforms.use_linear_blending) { + color.rgb /= color.a; color = unlinearize(color); + color.rgb *= color.a; } // Fetch our alpha mask for this pixel. From da5ac6aeeb07efbb2f3acbb5f9a73e601d8cacad Mon Sep 17 00:00:00 2001 From: Albert Dong Date: Thu, 16 Jan 2025 20:57:41 -0800 Subject: [PATCH 191/238] Set alert to nil when modal interacted with --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 0c5b50b53..bace20f05 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -449,6 +449,7 @@ class BaseTerminalController: NSWindowController, alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning alert.beginSheetModal(for: window, completionHandler: { response in + self.alert = nil switch (response) { case .alertFirstButtonReturn: alert.window.orderOut(nil) From b7d76fe26f08c3811ece6ecd88d5340c43e5b55e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 16 Jan 2025 23:51:40 -0600 Subject: [PATCH 192/238] gtk: always set the title on the underlying window when using adwaita --- src/apprt/gtk/headerbar_adw.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/headerbar_adw.zig b/src/apprt/gtk/headerbar_adw.zig index c0d622207..1ae23e6d9 100644 --- a/src/apprt/gtk/headerbar_adw.zig +++ b/src/apprt/gtk/headerbar_adw.zig @@ -65,6 +65,7 @@ pub fn packStart(self: HeaderBarAdw, widget: *c.GtkWidget) void { } pub fn setTitle(self: HeaderBarAdw, title: [:0]const u8) void { + c.gtk_window_set_title(self.window.window, title); if (comptime adwaita.versionAtLeast(0, 0, 0)) { c.adw_window_title_set_title(self.title, title); } From c2da843dfdcbe74fa1db8a676bb6e76d76621ee6 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 17 Jan 2025 13:28:14 -0500 Subject: [PATCH 193/238] fix(wuffs): don't premul alpha when loading images It seems like the raw data version of the kitty graphics transmit operation is meant to be unassociated (aka straight) alpha, though I can't find any definitive documentation either way- but in any case unassociated alpha is more common in image formats and makes the handling easier for the rest of it. Also removed a redundant call to `decode_frame_config`, since it's called implicitly when we call `decode_frame` right after. --- pkg/wuffs/src/jpeg.zig | 12 +----------- pkg/wuffs/src/png.zig | 12 +----------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/pkg/wuffs/src/jpeg.zig b/pkg/wuffs/src/jpeg.zig index 69628f582..c07278eed 100644 --- a/pkg/wuffs/src/jpeg.zig +++ b/pkg/wuffs/src/jpeg.zig @@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { c.wuffs_base__pixel_config__set( &image_config.pixcfg, - c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL, c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE, width, height, @@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { try check(log, &status); } - var frame_config: c.wuffs_base__frame_config = undefined; - { - const status = c.wuffs_jpeg__decoder__decode_frame_config( - decoder, - &frame_config, - &source_buffer, - ); - try check(log, &status); - } - { const status = c.wuffs_jpeg__decoder__decode_frame( decoder, diff --git a/pkg/wuffs/src/png.zig b/pkg/wuffs/src/png.zig index b85e4d747..1f37bb375 100644 --- a/pkg/wuffs/src/png.zig +++ b/pkg/wuffs/src/png.zig @@ -55,7 +55,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { c.wuffs_base__pixel_config__set( &image_config.pixcfg, - c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL, c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE, width, height, @@ -95,16 +95,6 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { try check(log, &status); } - var frame_config: c.wuffs_base__frame_config = undefined; - { - const status = c.wuffs_png__decoder__decode_frame_config( - decoder, - &frame_config, - &source_buffer, - ); - try check(log, &status); - } - { const status = c.wuffs_png__decoder__decode_frame( decoder, From 8ee4deddb4edf0d4b39b673b14c3d7df2ec5244d Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Tue, 14 Jan 2025 16:37:28 +0800 Subject: [PATCH 194/238] Fix `shell-integration-features` being ignored when `shell-integration` is `none` --- src/termio/Exec.zig | 6 +++++- src/termio/shell_integration.zig | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 1a3b8cad0..b1a19a6c7 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -875,7 +875,11 @@ const Subprocess = struct { }; const force: ?shell_integration.Shell = switch (cfg.shell_integration) { - .none => break :shell .{ null, default_shell_command }, + .none => { + // Even if shell integration is none, we still want to set up the feature env vars + try shell_integration.setupFeatures(&env, cfg.shell_integration_features); + break :shell .{ null, default_shell_command }; + }, .detect => null, .bash => .bash, .elvish => .elvish, diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 8cd2a92ae..85d9a8376 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -114,9 +114,7 @@ pub fn setup( }; // Setup our feature env vars - if (!features.cursor) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR", "1"); - if (!features.sudo) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_SUDO", "1"); - if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1"); + try setupFeatures(env, features); return result; } @@ -138,6 +136,17 @@ test "force shell" { } } +/// Setup shell integration feature environment variables without +/// performing full shell integration setup. +pub fn setupFeatures( + env: *EnvMap, + features: config.ShellIntegrationFeatures, +) !void { + if (!features.cursor) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR", "1"); + if (!features.sudo) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_SUDO", "1"); + if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1"); +} + /// Setup the bash automatic shell integration. This works by /// starting bash in POSIX mode and using the ENV environment /// variable to load our bash integration script. This prevents From 9c1edb544998bb64545b3a52561c6f9e43bf0005 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Tue, 14 Jan 2025 16:57:41 +0800 Subject: [PATCH 195/238] Add tests for setup shell integration features --- src/termio/shell_integration.zig | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 85d9a8376..8b12cabbe 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -147,6 +147,47 @@ pub fn setupFeatures( if (!features.title) try env.put("GHOSTTY_SHELL_INTEGRATION_NO_TITLE", "1"); } +test "setup features" { + const testing = std.testing; + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Test: all features enabled (no environment variables should be set) + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true }); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR") == null); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO") == null); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE") == null); + } + + // Test: all features disabled + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false }); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR").?); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO").?); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE").?); + } + + // Test: mixed features + { + var env = EnvMap.init(alloc); + defer env.deinit(); + + try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false }); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_CURSOR").?); + try testing.expect(env.get("GHOSTTY_SHELL_INTEGRATION_NO_SUDO") == null); + try testing.expectEqualStrings("1", env.get("GHOSTTY_SHELL_INTEGRATION_NO_TITLE").?); + } +} + /// Setup the bash automatic shell integration. This works by /// starting bash in POSIX mode and using the ENV environment /// variable to load our bash integration script. This prevents From 6853a5423f9cd546e54a1e2b37a286d24ef54db3 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Tue, 14 Jan 2025 21:32:56 +0800 Subject: [PATCH 196/238] Update the documentation to better explain that `shell-integration-features` --- src/config/Config.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index baac2cde7..386e6f923 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1670,7 +1670,9 @@ keybind: Keybinds = .{}, /// The default value is `detect`. @"shell-integration": ShellIntegration = .detect, -/// Shell integration features to enable if shell integration itself is enabled. +/// Shell integration features to enable. These require our shell integration +/// to be loaded, either automatically via shell-integration or manually. +/// /// The format of this is a list of features to enable separated by commas. If /// you prefix a feature with `no-` then it is disabled. If you omit a feature, /// its default value is used, so you must explicitly disable features you don't From ccd6fd26ecfeb652ce726ded7648dce9181a6ccc Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Wed, 15 Jan 2025 08:30:40 +0800 Subject: [PATCH 197/238] Ensure `setup_features` runs even when shell detection fails --- src/termio/shell_integration.zig | 116 +++++++++++++++++-------------- 1 file changed, 62 insertions(+), 54 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 8b12cabbe..915d5be9e 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -58,60 +58,7 @@ pub fn setup( break :exe std.fs.path.basename(command[0..idx]); }; - const result: ShellIntegration = shell: { - if (std.mem.eql(u8, "bash", exe)) { - // Apple distributes their own patched version of Bash 3.2 - // on macOS that disables the ENV-based POSIX startup path. - // This means we're unable to perform our automatic shell - // integration sequence in this specific environment. - // - // If we're running "/bin/bash" on Darwin, we can assume - // we're using Apple's Bash because /bin is non-writable - // on modern macOS due to System Integrity Protection. - if (comptime builtin.target.isDarwin()) { - if (std.mem.eql(u8, "/bin/bash", command)) { - return null; - } - } - - const new_command = try setupBash( - alloc_arena, - command, - resource_dir, - env, - ) orelse return null; - break :shell .{ - .shell = .bash, - .command = new_command, - }; - } - - if (std.mem.eql(u8, "elvish", exe)) { - try setupXdgDataDirs(alloc_arena, resource_dir, env); - break :shell .{ - .shell = .elvish, - .command = try alloc_arena.dupe(u8, command), - }; - } - - if (std.mem.eql(u8, "fish", exe)) { - try setupXdgDataDirs(alloc_arena, resource_dir, env); - break :shell .{ - .shell = .fish, - .command = try alloc_arena.dupe(u8, command), - }; - } - - if (std.mem.eql(u8, "zsh", exe)) { - try setupZsh(resource_dir, env); - break :shell .{ - .shell = .zsh, - .command = try alloc_arena.dupe(u8, command), - }; - } - - return null; - }; + const result = try setupShell(alloc_arena, resource_dir, command, env, exe); // Setup our feature env vars try setupFeatures(env, features); @@ -119,6 +66,67 @@ pub fn setup( return result; } +fn setupShell( + alloc_arena: Allocator, + resource_dir: []const u8, + command: []const u8, + env: *EnvMap, + exe: []const u8, +) !?ShellIntegration { + if (std.mem.eql(u8, "bash", exe)) { + // Apple distributes their own patched version of Bash 3.2 + // on macOS that disables the ENV-based POSIX startup path. + // This means we're unable to perform our automatic shell + // integration sequence in this specific environment. + // + // If we're running "/bin/bash" on Darwin, we can assume + // we're using Apple's Bash because /bin is non-writable + // on modern macOS due to System Integrity Protection. + if (comptime builtin.target.isDarwin()) { + if (std.mem.eql(u8, "/bin/bash", command)) { + return null; + } + } + + const new_command = try setupBash( + alloc_arena, + command, + resource_dir, + env, + ) orelse return null; + return .{ + .shell = .bash, + .command = new_command, + }; + } + + if (std.mem.eql(u8, "elvish", exe)) { + try setupXdgDataDirs(alloc_arena, resource_dir, env); + return .{ + .shell = .elvish, + .command = try alloc_arena.dupe(u8, command), + }; + } + + if (std.mem.eql(u8, "fish", exe)) { + try setupXdgDataDirs(alloc_arena, resource_dir, env); + return .{ + .shell = .fish, + .command = try alloc_arena.dupe(u8, command), + }; + } + + if (std.mem.eql(u8, "zsh", exe)) { + try setupZsh(resource_dir, env); + return .{ + .shell = .zsh, + .command = try alloc_arena.dupe(u8, command), + }; + } + + return null; +} + test "force shell" { const testing = std.testing; From 68124f60c75fde05d8d3bab6448114228ee16885 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Fri, 17 Jan 2025 20:48:03 +0100 Subject: [PATCH 198/238] gtk: don't toggle headerbar on (un)maximize while using SSDs See #5137. We should never display the header bar when using SSDs anyway --- src/apprt/gtk/Window.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 03fcd05db..59d6437d7 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -640,6 +640,11 @@ fn gtkWindowNotifyMaximized( ) callconv(.C) void { const self = userdataSelf(ud orelse return); const maximized = c.gtk_window_is_maximized(self.window) != 0; + + // Only toggle visibility of the header bar when we're using CSDs, + // and actually intend on displaying the header bar + if (!self.winproto.clientSideDecorationEnabled()) return; + if (!maximized) { self.headerbar.setVisible(true); return; From 0c2c847af37bafe3144d9c13035b921098a18567 Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Sat, 18 Jan 2025 22:47:18 +0900 Subject: [PATCH 199/238] chore: update stb_image.h exitting -> exiting --- src/stb/stb_image.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stb/stb_image.h b/src/stb/stb_image.h index 5e807a0a6..3ae1815c1 100644 --- a/src/stb/stb_image.h +++ b/src/stb/stb_image.h @@ -4962,7 +4962,7 @@ static int stbi__expand_png_palette(stbi__png *a, stbi_uc *palette, int len, int p = (stbi_uc *) stbi__malloc_mad2(pixel_count, pal_img_n, 0); if (p == NULL) return stbi__err("outofmem", "Out of memory"); - // between here and free(out) below, exitting would leak + // between here and free(out) below, exiting would leak temp_out = p; if (pal_img_n == 3) { From ecad3e75ff8aa4a14811efaad8e6b9436eb6774b Mon Sep 17 00:00:00 2001 From: Leorize Date: Sat, 18 Jan 2025 13:38:29 -0600 Subject: [PATCH 200/238] fix(flatpak): construct null-terminated array for arguments The variant format string `^aay` is said to be equivalent to g_variant_new_bytestring_array. Given that no length parameter is provided, g_variant_new assumed a null-terminated array, but the array constructed by the code was not, causing a crash as glib exceed the read boundaries to copy arbitrary memory. This commit replaces the array construction code to use its arena equivalents instead of trying to build one using glib, and make sure that the resulting array is null-terminated. --- src/os/flatpak.zig | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig index faac4bd27..09570554e 100644 --- a/src/os/flatpak.zig +++ b/src/os/flatpak.zig @@ -265,16 +265,12 @@ pub const FlatpakHostCommand = struct { } // Build our args - const args_ptr = c.g_ptr_array_new(); - { - errdefer _ = c.g_ptr_array_free(args_ptr, 1); - for (self.argv) |arg| { - const argZ = try arena.dupeZ(u8, arg); - c.g_ptr_array_add(args_ptr, argZ.ptr); - } + const args = try arena.alloc(?[*:0]u8, self.argv.len + 1); + for (0.., self.argv) |i, arg| { + const argZ = try arena.dupeZ(u8, arg); + args[i] = argZ.ptr; } - const args = c.g_ptr_array_free(args_ptr, 0); - defer c.g_free(@as(?*anyopaque, @ptrCast(args))); + args[args.len - 1] = null; // Get the cwd in case we don't have ours set. A small optimization // would be to do this only if we need it but this isn't a @@ -286,7 +282,7 @@ pub const FlatpakHostCommand = struct { const params = c.g_variant_new( "(^ay^aay@a{uh}@a{ss}u)", @as(*const anyopaque, if (self.cwd) |*cwd| cwd.ptr else g_cwd), - args, + args.ptr, c.g_variant_builder_end(fd_builder), c.g_variant_builder_end(env_builder), @as(c_int, 0), From 2ee6e005d02132d857685ad4e88c0a6e627f8fb3 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sat, 18 Jan 2025 14:29:30 -0500 Subject: [PATCH 201/238] termio: revise macOS-specific .hushlogin note login(1)'s .hushlogin logic was "fixed" in macOS Sonoma 14.4, so this comment (and our workaround) is only relevant for versions earlier than that. The relevant change to login/login.c is part of system_cmds-979.100.8. > login.c: look for .hushlogin in home directory (112854361) - https://github.com/apple-oss-distributions/system_cmds/commit/1bca46ecc5b76432f42eb23ec39fe63e8159f251 - https://github.com/apple-oss-distributions/distribution-macOS/tree/macos-144 --- src/termio/Exec.zig | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 1a3b8cad0..dd1f65305 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -971,12 +971,12 @@ const Subprocess = struct { // which we may not want. If we specify "-l" then we can avoid // this behavior but now the shell isn't a login shell. // - // There is another issue: `login(1)` only checks for ".hushlogin" - // in the working directory. This means that if we specify "-l" - // then we won't get hushlogin honored if its in the home - // directory (which is standard). To get around this, we - // check for hushlogin ourselves and if present specify the - // "-q" flag to login(1). + // There is another issue: `login(1)` on macOS 14.3 and earlier + // checked for ".hushlogin" in the working directory. This means + // that if we specify "-l" then we won't get hushlogin honored + // if its in the home directory (which is standard). To get + // around this, we check for hushlogin ourselves and if present + // specify the "-q" flag to login(1). // // So to get all the behaviors we want, we specify "-l" but // execute "bash" (which is built-in to macOS). We then use From 3f367857fc30edce9d9de32184df0789b30c8809 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 19 Jan 2025 00:58:22 +0000 Subject: [PATCH 202/238] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 4b9a3856b..09dc9847e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -79,8 +79,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/25cb3c3f52c7011cd8a599f8d144fc63f4409eb6.tar.gz", - .hash = "1220dc1096bda9721c1f5256177539bf37b41ac6fb70d58eadf0eec45359676382e5", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/0e23daf59234fc892cba949562d7bf69204594bb.tar.gz", + .hash = "12204fc99743d8232e691ac22e058519bfc6ea92de4a11c6dba59b117531c847cd6a", }, }, } diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index def5a11e3..dfc2e5f7f 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-2zXNHWSSWjnpW8oHu2sufT5+Ms4IKWaH6yRARQeMcxk=" +"sha256-H6o4Y09ATIylMUWuL9Y1fHwpuxSWyJ3Pl8fn4VeoDZo=" From 4956d36ee6f230c5f910dcb1db4fa5027e5114d8 Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Sun, 19 Jan 2025 11:04:01 +0100 Subject: [PATCH 203/238] fix: quick terminal hidden by macos menu bar ghostty#5000 changed the window level from `.popupMenu` to `.floating` to improve IME support. However, this introduced a side effect which render the Quick Terminal (QT) below the macOS menu bar, whereas previously it would cover it. When positioned on `right` and `left`, the top of the QT becomes partially hidden. This PR adjust the size of the QT to ensure it remains fully visible and stays below the menu bar. --- .../Sources/Features/QuickTerminal/QuickTerminalPosition.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index 0acbfec1b..6ba224a28 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -69,7 +69,7 @@ enum QuickTerminalPosition : String { finalSize.width = screen.frame.width case .left, .right: - finalSize.height = screen.frame.height + finalSize.height = screen.visibleFrame.height case .center: finalSize.width = screen.frame.width / 2 From bb58710fa8f4257eede4592ea56b7f18e65f4dde Mon Sep 17 00:00:00 2001 From: Bruno Bachmann Date: Sun, 19 Jan 2025 14:49:59 -0800 Subject: [PATCH 204/238] Fix typo in binding comments --- src/input/Binding.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 48725fb13..757c19c06 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -419,7 +419,7 @@ pub const Action = union(enum) { /// is preserved between appearances, so you can always press the keybinding /// to bring it back up. /// - /// To enable the quick terminally globally so that Ghostty doesn't + /// To enable the quick terminal globally so that Ghostty doesn't /// have to be focused, prefix your keybind with `global`. Example: /// /// ```ini From afa23532b66870873671ff702798ed8b49cfd6bd Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 20 Jan 2025 10:36:35 -0500 Subject: [PATCH 205/238] bash: revert automatic shell integration changes The intention of #5075 was to create a less intrusive, more hermetic environment in which to source the bash startup files. This caused problems for multiple people, and I believe that's because the general expectation is that these files are sourced at global (not function) scope. For example, when a file is sourced from within a function scope, any variables that weren't explicitly exported into the global environment won't be available outside of the scope of the function. Most system and personal startup files aren't written with that constraint because it's not how bash itself loads these files. As a small improvement over the original code, `rcfile` has been renamed to `__ghostty_rcfile`. Avoiding leaking this variable while sourcing these files was a goal of #5075, and prefixing it make it much less of a potential issue. This change also reverts the $HOME to ~/ change. While the ~/ notation is more concise, using $HOME is more common and easier to implement safely with regard to quoting. --- src/shell-integration/bash/ghostty.bash | 52 +++++++++++-------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 7de55f982..7fae435a3 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -41,37 +41,31 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then # Manually source the startup files. See INVOCATION in bash(1) and # run_startup_files() in shell.c in the Bash source code. - function __ghostty_bash_startup() { - builtin local rcfile - - if builtin shopt -q login_shell; then - if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then - [ -r /etc/profile ] && builtin source "/etc/profile" - for rcfile in ~/.bash_profile ~/.bash_login ~/.profile; do - [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } - done - fi - else - if [[ $__ghostty_bash_flags != *"--norc"* ]]; then - # The location of the system bashrc is determined at bash build - # time via -DSYS_BASHRC and can therefore vary across distros: - # Arch, Debian, Ubuntu use /etc/bash.bashrc - # Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC - # Void Linux uses /etc/bash/bashrc - # Nixos uses /etc/bashrc - for rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do - [ -r "$rcfile" ] && { builtin source "$rcfile"; break; } - done - if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE=~/.bashrc; fi - [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" - fi + if builtin shopt -q login_shell; then + if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then + [ -r /etc/profile ] && builtin source "/etc/profile" + for __ghostty_rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + done fi - } + else + if [[ $__ghostty_bash_flags != *"--norc"* ]]; then + # The location of the system bashrc is determined at bash build + # time via -DSYS_BASHRC and can therefore vary across distros: + # Arch, Debian, Ubuntu use /etc/bash.bashrc + # Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC + # Void Linux uses /etc/bash/bashrc + # Nixos uses /etc/bashrc + for __ghostty_rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do + [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + done + if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi + [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" + fi + fi - __ghostty_bash_startup - - builtin unset -f __ghostty_bash_startup - builtin unset -v __ghostty_bash_flags + builtin unset __ghostty_rcfile + builtin unset __ghostty_bash_flags builtin unset GHOSTTY_BASH_RCFILE fi From e5a3be3c46418bb150a991bd70a8a38565849b2b Mon Sep 17 00:00:00 2001 From: otomist Date: Wed, 15 Jan 2025 12:04:34 -0500 Subject: [PATCH 206/238] use whitespace instead of new flag for selecting full line --- src/Surface.zig | 16 +++++++--------- src/terminal/Screen.zig | 6 ------ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index a8fd4a817..138aa2ea2 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3563,17 +3563,15 @@ fn dragLeftClickTriple( const screen = &self.io.terminal.screen; const click_pin = self.mouse.left_click_pin.?.*; - // Get the line selection under our current drag point. If there isn't a line, do nothing. + // Get the line selection under our current drag point. If there isn't a + // line, do nothing. const line = screen.selectLine(.{ .pin = drag_pin }) orelse return; - // get the selection under our click point. - var sel_ = screen.selectLine(.{ .pin = click_pin }); - - // We may not have a selection if we started our triple-click in an area - // that had no data, in this case recall selectLine with allow_empty_lines. - if (sel_ == null) { - sel_ = screen.selectLine(.{ .pin = click_pin, .allow_empty_lines = true }); - } + // Get the selection under our click point. We first try to trim + // whitespace if we've selected a word. But if no word exists then + // we select the blank line. + const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse + screen.selectLine(.{ .pin = click_pin, .whitespace = null }); var sel = sel_ orelse return; if (drag_pin.before(click_pin)) { diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index db890ad3f..eb70d32d0 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -2215,7 +2215,6 @@ pub const SelectLine = struct { /// state changing a boundary. State changing is ANY state /// change. semantic_prompt_boundary: bool = true, - allow_empty_lines: bool = false, }; /// Select the line under the given point. This will select across soft-wrapped @@ -2293,11 +2292,6 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { return null; }; - // If we allow empty lines, we don't need to do any further checks. - if (opts.allow_empty_lines) { - return Selection.init(start_pin, end_pin, false); - } - // Go forward from the start to find the first non-whitespace character. const start: Pin = start: { const whitespace = opts.whitespace orelse break :start start_pin; From 4cc1fa2111848a78536e54ea34d9720d4f995dac Mon Sep 17 00:00:00 2001 From: julia Date: Tue, 14 Jan 2025 10:13:09 +1100 Subject: [PATCH 207/238] render consecutive shaders to the fbo not that big. see comments --- src/renderer/OpenGL.zig | 22 +++++++++++++++++----- src/renderer/opengl/custom.zig | 1 - 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index e5dec6b2b..8b6552bb9 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -2350,11 +2350,9 @@ pub fn drawFrame(self: *OpenGL, surface: *apprt.Surface) !void { } /// Draw the custom shaders. -fn drawCustomPrograms( - self: *OpenGL, - custom_state: *custom.State, -) !void { +fn drawCustomPrograms(self: *OpenGL, custom_state: *custom.State) !void { _ = self; + assert(custom_state.programs.len > 0); // Bind our state that is global to all custom shaders const custom_bind = try custom_state.bind(); @@ -2363,8 +2361,22 @@ fn drawCustomPrograms( // Setup the new frame try custom_state.newFrame(); + // To allow programs to retrieve each other via a texture + // then we must render the next shaders to the screen fbo. + // However, the first shader must be run while the default fbo + // is attached + { + const bind = try custom_state.programs[0].bind(); + defer bind.unbind(); + try bind.draw(); + if (custom_state.programs.len == 1) return; + } + + const fbobind = try custom_state.fbo.bind(.framebuffer); + defer fbobind.unbind(); + // Go through each custom shader and draw it. - for (custom_state.programs) |program| { + for (custom_state.programs[1..]) |program| { // Bind our cell program state, buffers const bind = try program.bind(); defer bind.unbind(); diff --git a/src/renderer/opengl/custom.zig b/src/renderer/opengl/custom.zig index 2cab0940c..3806921df 100644 --- a/src/renderer/opengl/custom.zig +++ b/src/renderer/opengl/custom.zig @@ -251,7 +251,6 @@ pub const Program = struct { const program = try gl.Program.createVF( @embedFile("../shaders/custom.v.glsl"), src, - //@embedFile("../shaders/temp.f.glsl"), ); errdefer program.destroy(); From 8c1db16c79caf322153bd41b07e377829d6ae87a Mon Sep 17 00:00:00 2001 From: Andrej Daskalov Date: Mon, 20 Jan 2025 20:19:12 +0100 Subject: [PATCH 208/238] added exec permission back to dolphin action --- dist/linux/ghostty_dolphin.desktop | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 dist/linux/ghostty_dolphin.desktop diff --git a/dist/linux/ghostty_dolphin.desktop b/dist/linux/ghostty_dolphin.desktop old mode 100644 new mode 100755 From 3b8ab10776664ed40f9cf571941785436546eb69 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 20 Jan 2025 18:36:48 -0500 Subject: [PATCH 209/238] fix(renderer): clip terminal contents to expected grid size (#4523) This significantly improves the robustness of the renderers since it prevents synchronization issues from causing memory corruption due to out of bounds read/writes while building the cells. TODO: when viewport is narrower than renderer grid size, fill blank margin with bg color- currently appears as black, this only affects DECCOLM right now, and possibly could create single-frame artefacts during poorly managed resizes, but it's not ideal regardless. --- src/renderer/Metal.zig | 37 +++++++++++++++++++------------------ src/renderer/OpenGL.zig | 38 ++++++++++++++++++++------------------ 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 45d8f84c2..bf28b58ac 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1046,19 +1046,6 @@ pub fn updateFrame( } } - // If our terminal screen size doesn't match our expected renderer - // size then we skip a frame. This can happen if the terminal state - // is resized between when the renderer mailbox is drained and when - // the state mutex is acquired inside this function. - // - // For some reason this doesn't seem to cause any significant issues - // with flickering while resizing. '\_('-')_/' - if (self.cells.size.rows != state.terminal.rows or - self.cells.size.columns != state.terminal.cols) - { - return; - } - // Get the viewport pin so that we can compare it to the current. const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?; @@ -2437,12 +2424,22 @@ fn rebuildCells( } } - // Go row-by-row to build the cells. We go row by row because we do - // font shaping by row. In the future, we will also do dirty tracking - // by row. + // We rebuild the cells row-by-row because we + // do font shaping and dirty tracking by row. var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); - var y: terminal.size.CellCountInt = screen.pages.rows; + // If our cell contents buffer is shorter than the screen viewport, + // we render the rows that fit, starting from the bottom. If instead + // the viewport is shorter than the cell contents buffer, we align + // the top of the viewport with the top of the contents buffer. + var y: terminal.size.CellCountInt = @min( + screen.pages.rows, + self.cells.size.rows, + ); while (row_it.next()) |row| { + // The viewport may have more rows than our cell contents, + // so we need to break from the loop early if we hit y = 0. + if (y == 0) break; + y -= 1; if (!rebuild) { @@ -2501,7 +2498,11 @@ fn rebuildCells( var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells_i: usize = 0; - const row_cells = row.cells(.all); + const row_cells_all = row.cells(.all); + + // If our viewport is wider than our cell contents buffer, + // we still only process cells up to the width of the buffer. + const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)]; for (row_cells, 0..) |*cell, x| { // If this cell falls within our preedit range then we diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 8b6552bb9..80fc3cab9 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -706,8 +706,6 @@ pub fn updateFrame( // Update all our data as tightly as possible within the mutex. var critical: Critical = critical: { - const grid_size = self.size.grid(); - state.mutex.lock(); defer state.mutex.unlock(); @@ -748,19 +746,6 @@ pub fn updateFrame( } } - // If our terminal screen size doesn't match our expected renderer - // size then we skip a frame. This can happen if the terminal state - // is resized between when the renderer mailbox is drained and when - // the state mutex is acquired inside this function. - // - // For some reason this doesn't seem to cause any significant issues - // with flickering while resizing. '\_('-')_/' - if (grid_size.rows != state.terminal.rows or - grid_size.columns != state.terminal.cols) - { - return; - } - // Get the viewport pin so that we can compare it to the current. const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?; @@ -1276,10 +1261,23 @@ pub fn rebuildCells( } } - // Build each cell + const grid_size = self.size.grid(); + + // We rebuild the cells row-by-row because we do font shaping by row. var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null); - var y: terminal.size.CellCountInt = screen.pages.rows; + // If our cell contents buffer is shorter than the screen viewport, + // we render the rows that fit, starting from the bottom. If instead + // the viewport is shorter than the cell contents buffer, we align + // the top of the viewport with the top of the contents buffer. + var y: terminal.size.CellCountInt = @min( + screen.pages.rows, + grid_size.rows, + ); while (row_it.next()) |row| { + // The viewport may have more rows than our cell contents, + // so we need to break from the loop early if we hit y = 0. + if (y == 0) break; + y -= 1; // True if we want to do font shaping around the cursor. We want to @@ -1356,7 +1354,11 @@ pub fn rebuildCells( var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells_i: usize = 0; - const row_cells = row.cells(.all); + const row_cells_all = row.cells(.all); + + // If our viewport is wider than our cell contents buffer, + // we still only process cells up to the width of the buffer. + const row_cells = row_cells_all[0..@min(row_cells_all.len, grid_size.columns)]; for (row_cells, 0..) |*cell, x| { // If this cell falls within our preedit range then we From 2d3db866e62e7cd2f09f83308c5f16287f10c7e0 Mon Sep 17 00:00:00 2001 From: Ryan Liptak Date: Mon, 20 Jan 2025 18:30:22 -0800 Subject: [PATCH 210/238] unigen: Remove libc dependency, use ArenaAllocator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not linking libc avoids potential problems when compiling from/for certain targets (see https://github.com/ghostty-org/ghostty/discussions/3218), and using an ArenaAllocator makes unigen run just as fast (in both release and debug modes) while also taking less memory. Benchmark 1 (3 runs): ./zig-out/bin/unigen-release-c measurement mean ± σ min … max outliers delta wall_time 1.75s ± 15.8ms 1.73s … 1.76s 0 ( 0%) 0% peak_rss 2.23MB ± 0 2.23MB … 2.23MB 0 ( 0%) 0% cpu_cycles 7.22G ± 62.8M 7.16G … 7.29G 0 ( 0%) 0% instructions 11.5G ± 16.0 11.5G … 11.5G 0 ( 0%) 0% cache_references 436M ± 6.54M 430M … 443M 0 ( 0%) 0% cache_misses 310K ± 203K 134K … 532K 0 ( 0%) 0% branch_misses 1.03M ± 29.9K 997K … 1.06M 0 ( 0%) 0% Benchmark 2 (3 runs): ./zig-out/bin/unigen-release-arena measurement mean ± σ min … max outliers delta wall_time 1.73s ± 6.40ms 1.72s … 1.73s 0 ( 0%) - 1.0% ± 1.6% peak_rss 1.27MB ± 75.7KB 1.18MB … 1.31MB 0 ( 0%) ⚡- 43.1% ± 5.4% cpu_cycles 7.16G ± 26.5M 7.13G … 7.18G 0 ( 0%) - 0.9% ± 1.5% instructions 11.4G ± 28.2 11.4G … 11.4G 0 ( 0%) - 0.8% ± 0.0% cache_references 441M ± 2.89M 439M … 444M 0 ( 0%) + 1.2% ± 2.6% cache_misses 152K ± 102K 35.2K … 220K 0 ( 0%) - 50.8% ± 117.8% branch_misses 1.05M ± 13.4K 1.04M … 1.06M 0 ( 0%) + 2.0% ± 5.1% Benchmark 1 (3 runs): ./zig-out/bin/unigen-debug-c measurement mean ± σ min … max outliers delta wall_time 1.75s ± 32.4ms 1.71s … 1.77s 0 ( 0%) 0% peak_rss 2.23MB ± 0 2.23MB … 2.23MB 0 ( 0%) 0% cpu_cycles 7.23G ± 136M 7.08G … 7.34G 0 ( 0%) 0% instructions 11.5G ± 37.9 11.5G … 11.5G 0 ( 0%) 0% cache_references 448M ± 1.03M 447M … 449M 0 ( 0%) 0% cache_misses 148K ± 42.6K 99.3K … 180K 0 ( 0%) 0% branch_misses 987K ± 5.27K 983K … 993K 0 ( 0%) 0% Benchmark 2 (3 runs): ./zig-out/bin/unigen-debug-arena measurement mean ± σ min … max outliers delta wall_time 1.76s ± 4.12ms 1.76s … 1.76s 0 ( 0%) + 0.4% ± 3.0% peak_rss 1.22MB ± 75.7KB 1.18MB … 1.31MB 0 ( 0%) ⚡- 45.1% ± 5.4% cpu_cycles 7.27G ± 17.1M 7.26G … 7.29G 0 ( 0%) + 0.6% ± 3.0% instructions 11.4G ± 3.79 11.4G … 11.4G 0 ( 0%) - 0.8% ± 0.0% cache_references 440M ± 4.52M 435M … 444M 0 ( 0%) - 1.7% ± 1.7% cache_misses 43.6K ± 19.2K 26.5K … 64.3K 0 ( 0%) ⚡- 70.5% ± 50.8% branch_misses 1.04M ± 2.25K 1.04M … 1.05M 0 ( 0%) 💩+ 5.8% ± 0.9% --- src/build/UnicodeTables.zig | 1 - src/unicode/props.zig | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/build/UnicodeTables.zig b/src/build/UnicodeTables.zig index 0159de442..7a4b0a5a2 100644 --- a/src/build/UnicodeTables.zig +++ b/src/build/UnicodeTables.zig @@ -15,7 +15,6 @@ pub fn init(b: *std.Build) !UnicodeTables { .root_source_file = b.path("src/unicode/props.zig"), .target = b.host, }); - exe.linkLibC(); const ziglyph_dep = b.dependency("ziglyph", .{ .target = b.host, diff --git a/src/unicode/props.zig b/src/unicode/props.zig index d77bf4c8a..8c7621b79 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -131,7 +131,9 @@ pub fn get(cp: u21) Properties { /// Runnable binary to generate the lookup tables and output to stdout. pub fn main() !void { - const alloc = std.heap.c_allocator; + var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_state.deinit(); + const alloc = arena_state.allocator(); const gen: lut.Generator( Properties, From 25ccdfe495dde9755b20b248bebc65d918cefc10 Mon Sep 17 00:00:00 2001 From: m154k1 <139042094+m154k1@users.noreply.github.com> Date: Tue, 21 Jan 2025 17:37:28 +0200 Subject: [PATCH 211/238] Fix sudo fish shell integration Set sudo_has_sudoedit_flags scope to --function. --- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index 420a49528..cd4f56105 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -71,11 +71,11 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x") # Wrap `sudo` command to ensure Ghostty terminfo is preserved function sudo -d "Wrap sudo to preserve terminfo" - set --local sudo_has_sudoedit_flags "no" + set --function sudo_has_sudoedit_flags "no" for arg in $argv # Check if argument is '-e' or '--edit' (sudoedit flags) if string match -q -- "-e" "$arg"; or string match -q -- "--edit" "$arg" - set --local sudo_has_sudoedit_flags "yes" + set --function sudo_has_sudoedit_flags "yes" break end # Check if argument is neither an option nor a key-value pair From bf6cce23da165c80622f7b12fefd3193d02c2d96 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Tue, 21 Jan 2025 09:50:15 +0800 Subject: [PATCH 212/238] Prevent hyperlink hover state when mouse is outside viewport --- src/Surface.zig | 3 +++ src/apprt/embedded.zig | 2 +- src/apprt/gtk/Surface.zig | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 138aa2ea2..d9a985aa7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1041,6 +1041,9 @@ fn mouseRefreshLinks( pos_vp: terminal.point.Coordinate, over_link: bool, ) !void { + // If the position is outside our viewport, do nothing + if (pos.x < 0 or pos.y < 0) return; + self.mouse.link_point = pos_vp; if (try self.linkAtPos(pos)) |link| { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 44c4c5f20..013117f15 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -638,7 +638,7 @@ pub const Surface = struct { .y = @floatCast(opts.scale_factor), }, .size = .{ .width = 800, .height = 600 }, - .cursor_pos = .{ .x = 0, .y = 0 }, + .cursor_pos = .{ .x = -1, .y = -1 }, .keymap_state = .{}, }; diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 61866dcec..21d811623 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -560,7 +560,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { .font_size = font_size, .init_config = init_config, .size = .{ .width = 800, .height = 600 }, - .cursor_pos = .{ .x = 0, .y = 0 }, + .cursor_pos = .{ .x = -1, .y = -1 }, .im_context = im_context, .cgroup_path = cgroup_path, }; From 52936b9b681e92f7bab37c3ec341be0013f591da Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 21 Jan 2025 14:29:43 -0800 Subject: [PATCH 213/238] apprt/gtk: fundamentally rework input method handling Fixes #4332 This commit fundamentally reworks the input method handling in the GTK apprt, making it work properly (as reported in the linked issue) on both Wayland and X11. This was tested with both a Gnome desktop on Wayland and i3 on X11 with fcitx and mozc. The main changes are: - Both key press and release events must be forwarded to the input method. - Input method callbacks such as preedit and commit must be expected outside of keypress events to handle on-screen keyboards and non-keyboard input devices. - Input methods should always commit when told to. Previously, we would only commit when a keypress event was given. This is incorrect. For example, it didn't work with input method changes outside the app which should result in committed text (as can be seen with "official" Gnome apps like Notes or Console). The key input handling also now generally does less so I think input latency should be positively affected by this change. I didn't measure. --- src/apprt/gtk/Surface.zig | 251 ++++++++++++++++++++------------------ 1 file changed, 131 insertions(+), 120 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 61866dcec..5a6ce1a38 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -368,10 +368,9 @@ cursor_pos: apprt.CursorPos, inspector: ?*inspector.Inspector = null, /// Key input states. See gtkKeyPressed for detailed descriptions. -in_keypress: bool = false, +in_keyevent: bool = false, im_context: *c.GtkIMContext, im_composing: bool = false, -im_commit_buffered: bool = false, im_buf: [128]u8 = undefined, im_len: u7 = 0, @@ -1604,30 +1603,36 @@ fn gtkKeyReleased( )) 1 else 0; } -/// Key press event. This is where we do ALL of our key handling, -/// translation to keyboard layouts, dead key handling, etc. Key handling -/// is complicated so this comment will explain what's going on. +/// 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 press events, we run the keypress through the input method context -/// in order to determine if we're in a dead key state, completed unicode -/// char, etc. This all happens through various callbacks: preedit, commit, -/// etc. These inspect "in_keypress" if they have to and set some instance -/// state. +/// 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. /// -/// We then take all of the information in order to determine if we have +/// 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. /// -/// Finally, we can emit the keyCallback. -/// -/// Note we ALSO have an IMContext attached directly to the widget -/// which can emit preedit and commit callbacks. But, if we're not -/// in a keypress, we let those automatically work. +/// Then we can emit the keyCallback. pub fn keyEvent( self: *Surface, action: input.Action, @@ -1636,26 +1641,15 @@ pub fn keyEvent( keycode: c.guint, gtk_mods: c.GdkModifierType, ) bool { + // log.warn("GTKIM: keyEvent action={}", .{action}); const event = c.gtk_event_controller_get_current_event( @ptrCast(ec_key), ) orelse return false; - const keyval_unicode = c.gdk_keyval_to_unicode(keyval); - - // Get the unshifted unicode value of the keyval. This is used - // by the Kitty keyboard protocol. - const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted( - @ptrCast(self.gl_area), - event, - keycode, - ); - - // We always reset our committed text when ending a keypress so that - // future keypresses don't think we have a commit event. - defer self.im_len = 0; - - // We only want to send the event through the IM context if we're a press - if (action == .press or action == .repeat) { + // 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. @@ -1667,41 +1661,77 @@ pub fn keyEvent( .height = 1, }); - // We mark that we're in a keypress event. We use this in our - // IM commit callback to determine if we need to send a char callback - // to the core surface or not. - self.in_keypress = true; - defer self.in_keypress = false; + // Pass the event through the IM controller. This will return true + // if the input method handled the event. + // + // Confusingly, not all events handled by the input method result + // in this returning true so we have to maintain some local state to + // find those and in one case we simply lose information. + // + // - 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 `was_composing` variable 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 was_composing = self.im_composing; + const im_handled = filter: { + // 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. + self.in_keyevent = true; + defer self.in_keyevent = false; + break :filter c.gtk_im_context_filter_keypress( + self.im_context, + event, + ) != 0; + }; + // log.warn("GTKIM: im_handled={} im_len={} im_composing={}", .{ + // im_handled, + // self.im_len, + // self.im_composing, + // }); - // Pass the event through the IM controller to handle dead key states. - // Filter is true if the event was handled by the IM controller. - const im_handled = c.gtk_im_context_filter_keypress(self.im_context, event) != 0; - // log.warn("im_handled={} im_len={} im_composing={}", .{ im_handled, self.im_len, self.im_composing }); - - // If this is a dead key, then we're composing a character and - // we need to set our proper preedit state. - if (self.im_composing) preedit: { - const text = self.im_buf[0..self.im_len]; - self.core_surface.preeditCallback(text) catch |err| { - log.err("error in preedit callback err={}", .{err}); - break :preedit; - }; - - // If we're composing then we don't want to send the key - // event to the core surface so we always return immediately. + if (self.im_composing) { + // If we're composing and the input method handled this event then + // we don't continue processing it. Any preedit changes or any of that + // would've been handled by the preedit events. if (im_handled) return true; - } else { - // If we aren't composing, then we set our preedit to - // empty no matter what. - self.core_surface.preeditCallback(null) catch {}; - - // If the IM handled this and we have no text, then we just - // return because this probably just changed the input method - // or something. - if (im_handled and self.im_len == 0) return true; + } else if (was_composing) { + // 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. + 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 = c.gdk_keyval_to_unicode(keyval); + const keyval_unicode_unshifted: u21 = gtk_key.keyvalUnicodeUnshifted( + @ptrCast(self.gl_area), + 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| { @@ -1834,12 +1864,11 @@ fn gtkInputPreeditStart( _: *c.GtkIMContext, ud: ?*anyopaque, ) callconv(.C) void { - //log.debug("preedit start", .{}); + // log.warn("GTKIM: preedit start", .{}); const self = userdataSelf(ud.?); - if (!self.in_keypress) return; - // Mark that we are now composing a string with a dead key state. - // We'll record the string in the preedit-changed callback. + // Start our composing state for the input method and reset our + // input buffer to empty. self.im_composing = true; self.im_len = 0; } @@ -1848,54 +1877,35 @@ fn gtkInputPreeditChanged( ctx: *c.GtkIMContext, ud: ?*anyopaque, ) callconv(.C) void { + // log.warn("GTKIM: preedit change", .{}); const self = userdataSelf(ud.?); - // If there's buffered character, send the characters directly to the surface. - if (self.im_composing and self.im_commit_buffered) { - defer self.im_commit_buffered = false; - defer self.im_len = 0; - _ = self.core_surface.keyCallback(.{ - .action = .press, - .key = .invalid, - .physical_key = .invalid, - .mods = .{}, - .consumed_mods = .{}, - .composing = false, - .utf8 = self.im_buf[0..self.im_len], - }) catch |err| { - log.err("error in key callback err={}", .{err}); - return; - }; - } - - if (!self.in_keypress) return; - // Get our pre-edit string that we'll use to show the user. var buf: [*c]u8 = undefined; _ = c.gtk_im_context_get_preedit_string(ctx, &buf, null, null); defer c.g_free(buf); const str = std.mem.sliceTo(buf, 0); - // If our string becomes empty we ignore this. This can happen after - // a commit event when the preedit is being cleared and we don't want - // to set im_len to zero. This is safe because preeditstart always sets - // im_len to zero. - if (str.len == 0) return; - - // Copy the preedit string into the im_buf. This is safe because - // commit will always overwrite this. - self.im_len = @intCast(@min(self.im_buf.len, str.len)); - @memcpy(self.im_buf[0..self.im_len], str); + // Update our preedit state in Ghostty core + self.core_surface.preeditCallback(str) catch |err| { + log.err("error in preedit callback err={}", .{err}); + }; } fn gtkInputPreeditEnd( _: *c.GtkIMContext, ud: ?*anyopaque, ) callconv(.C) void { - //log.debug("preedit end", .{}); + // log.warn("GTKIM: preedit end", .{}); const self = userdataSelf(ud.?); - if (!self.in_keypress) return; + + // 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( @@ -1903,38 +1913,39 @@ fn gtkInputCommit( bytes: [*:0]u8, ud: ?*anyopaque, ) callconv(.C) void { + // log.warn("GTKIM: input commit", .{}); const self = userdataSelf(ud.?); const str = std.mem.sliceTo(bytes, 0); - // If we're in a key event, then we want to buffer the commit so - // that we can send the proper keycallback followed by the char - // callback. - if (self.in_keypress) { - if (str.len <= self.im_buf.len) { - @memcpy(self.im_buf[0..str.len], str); - self.im_len = @intCast(str.len); - - // If composing is done and character should be committed, - // It should be committed in preedit callback. - if (self.im_composing) { - self.im_commit_buffered = true; - } - - // log.debug("input commit len={}", .{self.im_len}); - } else { + // If we're in a keyEvent (i.e. a keyboard event) and we're not composing, + // then this is just a normal key press resulting in UTF-8 text. We + // want the keyEvent to handle this so that the UTF-8 text can be associated + // with a keyboard event. + if (!self.im_composing and self.in_keyevent) { + 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; } - // This prevents staying in composing state after commit even though - // input method has changed. + // 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; - // We're not in a keypress, so this was sent from an on-screen emoji - // keyboard or something like that. Send the characters directly to - // the surface. + // 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 = .invalid, @@ -1944,7 +1955,7 @@ fn gtkInputCommit( .composing = false, .utf8 = str, }) catch |err| { - log.err("error in key callback err={}", .{err}); + log.warn("error in key callback err={}", .{err}); return; }; } From a8d218561121671bdce3aaab76688970f9893e9e Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 21 Jan 2025 20:13:14 -0600 Subject: [PATCH 214/238] Switch default key bindings to include on and offscreen contents Previous discussions: - https://github.com/ghostty-org/ghostty/discussions/3652 - https://github.com/ghostty-org/ghostty/issues/3496 - https://github.com/ghostty-org/ghostty/discussions/4911 - https://github.com/ghostty-org/ghostty/discussions/4390 - https://github.com/ghostty-org/ghostty/discussions/2363#discussioncomment-11735957 - https://github.com/ghostty-org/ghostty/issues/189#issuecomment-2564719973 - https://github.com/ghostty-org/ghostty/pull/2040 --- src/config/Config.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 386e6f923..fd0f58669 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2370,13 +2370,13 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, - .{ .write_scrollback_file = .paste }, + .{ .write_screen_file = .paste }, ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .j }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true, .alt = true }) }, - .{ .write_scrollback_file = .open }, + .{ .write_screen_file = .open }, ); // Expand Selection From 4408101b8d71d6819e0b82d3ccc1d714bec2c928 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 22 Jan 2025 20:07:26 -0800 Subject: [PATCH 215/238] apprt/gtk: ibus activation should not encode keys This cleans up our handling of when GTK tells us the input method handled the key press to address more scenarios we should not encode the key event. The comments in this diff should explain clearly. --- src/apprt/gtk/Surface.zig | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 5a6ce1a38..4539e61cb 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1700,15 +1700,32 @@ pub fn keyEvent( // self.im_composing, // }); - if (self.im_composing) { - // If we're composing and the input method handled this event then - // we don't continue processing it. Any preedit changes or any of that - // would've been handled by the preedit events. - if (im_handled) return true; - } else if (was_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. - return true; + // 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 (was_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: From a2018d7b20b557eeb2e0e59e39e9ad3c0c16e44a Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 23 Jan 2025 10:34:27 -0500 Subject: [PATCH 216/238] bash: handle additional command arguments A '-' or '--' argument signals the end of bash's own options. All remaining arguments are treated as filenames and arguments. We shouldn't perform any additional argument processing once we see this signal. We could also assume a non-interactive shell session in this case unless the '-i' (interactive) shell option has been explicitly specified, but let's wait on that until we know that doing so would solve a real user problem (and avoid any false negatives). --- src/termio/shell_integration.zig | 40 +++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 915d5be9e..423e2f518 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -203,8 +203,6 @@ test "setup features" { /// our script's responsibility (along with disabling POSIX /// mode). /// -/// This approach requires bash version 4 or later. -/// /// This returns a new (allocated) shell command string that /// enables the integration or null if integration failed. fn setupBash( @@ -246,12 +244,6 @@ fn setupBash( // Unsupported options: // -c -c is always non-interactive // --posix POSIX mode (a la /bin/sh) - // - // Some additional cases we don't yet cover: - // - // - If additional file arguments are provided (after a `-` or `--` flag), - // and the `i` shell option isn't being explicitly set, we can assume a - // non-interactive shell session and skip loading our shell integration. var rcfile: ?[]const u8 = null; while (iter.next()) |arg| { if (std.mem.eql(u8, arg, "--posix")) { @@ -268,6 +260,14 @@ fn setupBash( return null; } try args.append(arg); + } else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) { + // All remaining arguments should be passed directly to the shell + // command. We shouldn't perform any further option processing. + try args.append(arg); + while (iter.next()) |remaining_arg| { + try args.append(remaining_arg); + } + break; } else { try args.append(arg); } @@ -430,6 +430,30 @@ test "bash: HISTFILE" { } } +test "bash: additional arguments" { + const testing = std.testing; + const alloc = testing.allocator; + + var env = EnvMap.init(alloc); + defer env.deinit(); + + // "-" argument separator + { + const command = try setupBash(alloc, "bash - --arg file1 file2", ".", &env); + defer if (command) |c| alloc.free(c); + + try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?); + } + + // "--" argument separator + { + const command = try setupBash(alloc, "bash -- --arg file1 file2", ".", &env); + defer if (command) |c| alloc.free(c); + + try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?); + } +} + /// Setup automatic shell integration for shells that include /// their modules from paths in `XDG_DATA_DIRS` env variable. /// From d1e45ef768d0d342fff686c387b1e414d1b48c4c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Jan 2025 07:24:11 -0800 Subject: [PATCH 217/238] apprt/gtk: when text is committed, end the preedit state Fixes #3567 ibus 1.5.29 doesn't trigger a preedit end state when text is committed. This is fixed in ibus 1.5.30, but we need to handle this case for older versions which are shipped on LTS distributions such as Ubuntu. Every other input method engine I've tried thus far also triggers a preedit end state when text is committed, and none would expect preedit to continue after text is committed. So I think it's safe to assume that this is the expected behavior. --- src/apprt/gtk/Surface.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 4539e61cb..b429c7233 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1961,6 +1961,12 @@ fn gtkInputCommit( // 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(.{ From 8f49a227b7c352083b0815e7818db900402513e0 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 22 Jan 2025 22:29:25 -0600 Subject: [PATCH 218/238] build: options to enable/disable terminfo & termcap install Fixes #5253 Add `-Demit-terminfo` and `-Demit-termcap` build options to enable/disable installtion of source terminfo and termcap files. --- src/build/Config.zig | 25 +++++++++++++++++++- src/build/GhosttyResources.zig | 42 +++++++++++++++++----------------- src/os/resourcesdir.zig | 5 +++- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/build/Config.zig b/src/build/Config.zig index 71dffce4a..1d51525d0 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -55,6 +55,8 @@ emit_helpgen: bool = false, emit_docs: bool = false, emit_webdata: bool = false, emit_xcframework: bool = false, +emit_terminfo: bool = false, +emit_termcap: bool = false, /// Environmental properties env: std.process.EnvMap, @@ -306,11 +308,32 @@ pub fn init(b: *std.Build) !Config { break :emit_docs path != null; }; + config.emit_terminfo = b.option( + bool, + "emit-terminfo", + "Install Ghostty terminfo source file", + ) orelse switch (target.result.os.tag) { + .windows => true, + else => switch (optimize) { + .Debug => true, + .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, + }, + }; + + config.emit_termcap = b.option( + bool, + "emit-termcap", + "Install Ghostty termcap file", + ) orelse false; + config.emit_webdata = b.option( bool, "emit-webdata", "Build the website data for the website.", - ) orelse false; + ) orelse switch (optimize) { + .Debug => true, + .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, + }; config.emit_xcframework = b.option( bool, diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index cae907ec2..c0830e5f6 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -23,9 +23,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // Write it var wf = b.addWriteFiles(); - const src_source = wf.add("share/terminfo/ghostty.terminfo", str.items); - const src_install = b.addInstallFile(src_source, "share/terminfo/ghostty.terminfo"); - try steps.append(&src_install.step); + const source = wf.add("ghostty.terminfo", str.items); + + if (cfg.emit_terminfo) { + const source_install = b.addInstallFile(source, "share/terminfo/ghostty.terminfo"); + try steps.append(&source_install.step); + } // Windows doesn't have the binaries below. if (cfg.target.result.os.tag == .windows) break :terminfo; @@ -33,10 +36,10 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // Convert to termcap source format if thats helpful to people and // install it. The resulting value here is the termcap source in case // that is used for other commands. - { + if (cfg.emit_termcap) { const run_step = RunStep.create(b, "infotocap"); - run_step.addArg("infotocap"); - run_step.addFileArg(src_source); + run_step.addArgs(&.{ "infotocap", "-" }); + run_step.setStdIn(.{ .lazy_path = source }); const out_source = run_step.captureStdOut(); _ = run_step.captureStdErr(); // so we don't see stderr @@ -48,24 +51,21 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { { const run_step = RunStep.create(b, "tic"); run_step.addArgs(&.{ "tic", "-x", "-o" }); - const path = run_step.addOutputFileArg("terminfo"); - run_step.addFileArg(src_source); + const path = run_step.addOutputDirectoryArg("share/terminfo"); + run_step.addArg("-"); + run_step.setStdIn(.{ .lazy_path = source }); _ = run_step.captureStdErr(); // so we don't see stderr - // Depend on the terminfo source install step so that Zig build - // creates the "share" directory for us. - run_step.step.dependOn(&src_install.step); + try steps.append(&run_step.step); - { - // Use cp -R instead of Step.InstallDir because we need to preserve - // symlinks in the terminfo database. Zig's InstallDir step doesn't - // handle symlinks correctly yet. - const copy_step = RunStep.create(b, "copy terminfo db"); - copy_step.addArgs(&.{ "cp", "-R" }); - copy_step.addFileArg(path); - copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); - try steps.append(©_step.step); - } + // Use cp -R instead of Step.InstallDir because we need to preserve + // symlinks in the terminfo database. Zig's InstallDir step doesn't + // handle symlinks correctly yet. + const copy_step = RunStep.create(b, "copy terminfo db"); + copy_step.addArgs(&.{ "cp", "-R" }); + copy_step.addFileArg(path); + copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); + try steps.append(©_step.step); } } diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig index c0f82dec5..d2b274e87 100644 --- a/src/os/resourcesdir.zig +++ b/src/os/resourcesdir.zig @@ -21,7 +21,10 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // This is the sentinel value we look for in the path to know // we've found the resources directory. - const sentinel = "terminfo/ghostty.termcap"; + const sentinel = switch (comptime builtin.target.os.tag) { + .windows => "terminfo/ghostty.terminfo", + else => "terminfo/x/xterm-ghostty", + }; // Get the path to our running binary var exe_buf: [std.fs.max_path_bytes]u8 = undefined; From 9c8c53bffb9d5a8f4cc712fcea083fbf79f833c4 Mon Sep 17 00:00:00 2001 From: Julia <58243358+juliapaci@users.noreply.github.com> Date: Fri, 24 Jan 2025 07:57:14 +1100 Subject: [PATCH 219/238] use main buffer and copy data to fbo texture (opengl) (#5294) NEEDS REVIEW continuation of #5037 resolves #4729 renders all shaders to the default buffer and then copies it to the designated custom shader texture. this is a draft pr because: - it introduces a new shader "pipeline" which doesnt fit in with how the system was designed to work (which is only rendering to the fbo) - im not sure if this is the best way to achieve shaders being able to sample their output while also drawing to the screen. the cusom fbo (previous implementation) was useful in that it modularized the custom shader stage in rendering --------- Co-authored-by: Mitchell Hashimoto --- pkg/opengl/Texture.zig | 22 ++++++++++++++++++++++ src/renderer/OpenGL.zig | 18 ++---------------- src/renderer/opengl/custom.zig | 15 +++++++++++++++ 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/pkg/opengl/Texture.zig b/pkg/opengl/Texture.zig index 4cd1cf9f9..a9fa5d4fe 100644 --- a/pkg/opengl/Texture.zig +++ b/pkg/opengl/Texture.zig @@ -162,4 +162,26 @@ pub const Binding = struct { data, ); } + + pub fn copySubImage2D( + b: Binding, + level: c.GLint, + xoffset: c.GLint, + yoffset: c.GLint, + x: c.GLint, + y: c.GLint, + width: c.GLsizei, + height: c.GLsizei, + ) !void { + glad.context.CopyTexSubImage2D.?( + @intFromEnum(b.target), + level, + xoffset, + yoffset, + x, + y, + width, + height + ); + } }; diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 80fc3cab9..3e674c715 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -2363,26 +2363,12 @@ fn drawCustomPrograms(self: *OpenGL, custom_state: *custom.State) !void { // Setup the new frame try custom_state.newFrame(); - // To allow programs to retrieve each other via a texture - // then we must render the next shaders to the screen fbo. - // However, the first shader must be run while the default fbo - // is attached - { - const bind = try custom_state.programs[0].bind(); - defer bind.unbind(); - try bind.draw(); - if (custom_state.programs.len == 1) return; - } - - const fbobind = try custom_state.fbo.bind(.framebuffer); - defer fbobind.unbind(); - // Go through each custom shader and draw it. - for (custom_state.programs[1..]) |program| { - // Bind our cell program state, buffers + for (custom_state.programs) |program| { const bind = try program.bind(); defer bind.unbind(); try bind.draw(); + try custom_state.copyFramebuffer(); } } diff --git a/src/renderer/opengl/custom.zig b/src/renderer/opengl/custom.zig index 3806921df..859277ce5 100644 --- a/src/renderer/opengl/custom.zig +++ b/src/renderer/opengl/custom.zig @@ -230,6 +230,21 @@ pub const State = struct { }; } + /// Copy the fbo's attached texture to the backbuffer. + pub fn copyFramebuffer(self: *State) !void { + const texbind = try self.fb_texture.bind(.@"2D"); + errdefer texbind.unbind(); + try texbind.copySubImage2D( + 0, + 0, + 0, + 0, + 0, + @intFromFloat(self.uniforms.resolution[0]), + @intFromFloat(self.uniforms.resolution[1]), + ); + } + pub const Binding = struct { vao: gl.VertexArray.Binding, ebo: gl.Buffer.Binding, From 5477eb87c17a76d847ba34093d85fd4ef1490149 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 28 Oct 2024 18:41:53 -0400 Subject: [PATCH 220/238] macOS: prevent native window drag by top region when titlebar hidden The native window drag region is driven ultimately by the window's `contentLayoutRect`, so we can just override it in `TerminalWindow` to return a rect the size of the full window, disabling the gesture without causing any side effects by altering the responder chain. --- .../Terminal/TerminalController.swift | 22 +++++++++---------- .../Features/Terminal/TerminalWindow.swift | 15 +++++++++++++ src/config/Config.zig | 9 +++++--- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 89da6bfeb..f24261b9b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -22,7 +22,7 @@ class TerminalController: BaseTerminalController { private var restorable: Bool = true /// The configuration derived from the Ghostty config so we don't need to rely on references. - private var derivedConfig: DerivedConfig + private(set) var derivedConfig: DerivedConfig /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] @@ -315,28 +315,28 @@ class TerminalController: BaseTerminalController { window.styleMask = [ // We need `titled` in the mask to get the normal window frame .titled, - + // Full size content view so we can extend // content in to the hidden titlebar's area - .fullSizeContentView, - - .resizable, + .fullSizeContentView, + + .resizable, .closable, .miniaturizable, ] - + // Hide the title window.titleVisibility = .hidden window.titlebarAppearsTransparent = true - + // Hide the traffic lights (window control buttons) window.standardWindowButton(.closeButton)?.isHidden = true window.standardWindowButton(.miniaturizeButton)?.isHidden = true window.standardWindowButton(.zoomButton)?.isHidden = true - + // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. window.tabbingMode = .disallowed - + // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are // some operations that appear to bring back the titlebar visibility so this ensures // it is gone forever. @@ -345,7 +345,7 @@ class TerminalController: BaseTerminalController { titleBarContainer.isHidden = true } } - + override func windowDidLoad() { super.windowDidLoad() guard let window = window as? TerminalWindow else { return } @@ -776,7 +776,7 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } - private struct DerivedConfig { + struct DerivedConfig { let backgroundColor: Color let macosTitlebarStyle: String diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 0eb8daeeb..9d29c193f 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -115,6 +115,21 @@ class TerminalWindow: NSWindow { } } + // We override this so that with the hidden titlebar style the titlebar + // area is not draggable. + override var contentLayoutRect: CGRect { + var rect = super.contentLayoutRect + + // If we are using a hidden titlebar style, the content layout is the + // full frame making it so that it is not draggable. + if let controller = windowController as? TerminalController, + controller.derivedConfig.macosTitlebarStyle == "hidden" { + rect.origin.y = 0 + rect.size.height = self.frame.height + } + return rect + } + // The window theme configuration from Ghostty. This is used to control some // behaviors that don't look quite right in certain situations. var windowTheme: TerminalWindowTheme? diff --git a/src/config/Config.zig b/src/config/Config.zig index fd0f58669..e32a3485f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1816,9 +1816,12 @@ keybind: Keybinds = .{}, /// The "hidden" style hides the titlebar. Unlike `window-decoration = false`, /// however, it does not remove the frame from the window or cause it to have /// squared corners. Changing to or from this option at run-time may affect -/// existing windows in buggy ways. The top titlebar area of the window will -/// continue to drag the window around and you will not be able to use -/// the mouse for terminal events in this space. +/// existing windows in buggy ways. +/// +/// When "hidden", the top titlebar area can no longer be used for dragging +/// the window. To drag the window, you can use option+click on the resizable +/// areas of the frame to drag the window. This is a standard macOS behavior +/// and not something Ghostty enables. /// /// The default value is "transparent". This is an opinionated choice /// but its one I think is the most aesthetically pleasing and works in From e854b38872adc38050c39b6f2e8f580268d1e08c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Jan 2025 14:11:10 -0800 Subject: [PATCH 221/238] cli: allow renaming config fields to maintain backwards compatibility Fixes #4631 This introduces a mechanism by which parsed config fields can be renamed to maintain backwards compatibility. This already has a use case -- implemented in this commit -- for `background-blur-radius` to be renamed to `background-blur`. The remapping is comptime-known which lets us do some comptime validation. The remap check isn't done unless no fields match which means for well-formed config files, there's no overhead. For future improvements: - We should update our config help generator to note renamed fields. - We could offer automatic migration of config files be rewriting them. - We can enrich the value type with more metadata to help with config gen or other tooling. --- macos/Sources/Ghostty/Ghostty.Config.swift | 2 +- src/apprt/embedded.zig | 2 +- src/apprt/gtk/winproto/wayland.zig | 2 +- src/apprt/gtk/winproto/x11.zig | 2 +- src/cli/args.zig | 52 ++++++++++++++++++++++ src/config/Config.zig | 13 +++++- src/config/c_get.zig | 12 ++--- 7 files changed, 73 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 2a24b0257..9c8042c63 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -339,7 +339,7 @@ extension Ghostty { var backgroundBlurRadius: Int { guard let config = self.config else { return 1 } var v: Int = 0 - let key = "background-blur-radius" + let key = "background-blur" _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v; } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 44c4c5f20..890901c07 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1958,7 +1958,7 @@ pub const CAPI = struct { _ = CGSSetWindowBackgroundBlurRadius( CGSDefaultConnectionForThread(), nswindow.msgSend(usize, objc.sel("windowNumber"), .{}), - @intCast(config.@"background-blur-radius".cval()), + @intCast(config.@"background-blur".cval()), ); } diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index 7a28fc92c..8df3e57b3 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -176,7 +176,7 @@ pub const Window = struct { pub fn init(config: *const Config) DerivedConfig { return .{ - .blur = config.@"background-blur-radius".enabled(), + .blur = config.@"background-blur".enabled(), .window_decoration = config.@"window-decoration", }; } diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig index 4f607d1ef..7a6b8b4c7 100644 --- a/src/apprt/gtk/winproto/x11.zig +++ b/src/apprt/gtk/winproto/x11.zig @@ -165,7 +165,7 @@ pub const Window = struct { pub fn init(config: *const Config) DerivedConfig { return .{ - .blur = config.@"background-blur-radius".enabled(), + .blur = config.@"background-blur".enabled(), .has_decoration = switch (config.@"window-decoration") { .none => false, .auto, .client, .server => true, diff --git a/src/cli/args.zig b/src/cli/args.zig index 23dcf7733..166b2daf5 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -38,6 +38,12 @@ pub const Error = error{ /// "DiagnosticList" and any diagnostic messages will be added to that list. /// When diagnostics are present, only allocation errors will be returned. /// +/// If the destination type has a decl "renamed", it must be of type +/// std.StaticStringMap([]const u8) and contains a mapping from the old +/// field name to the new field name. This is used to allow renaming fields +/// while still supporting the old name. If a renamed field is set, parsing +/// will automatically set the new field name. +/// /// Note: If the arena is already non-null, then it will be used. In this /// case, in the case of an error some memory might be leaked into the arena. pub fn parse( @@ -49,6 +55,24 @@ pub fn parse( const info = @typeInfo(T); assert(info == .Struct); + comptime { + // Verify all renamed fields are valid (source does not exist, + // destination does exist). + if (@hasDecl(T, "renamed")) { + for (T.renamed.keys(), T.renamed.values()) |key, value| { + if (@hasField(T, key)) { + @compileLog(key); + @compileError("renamed field source exists"); + } + + if (!@hasField(T, value)) { + @compileLog(value); + @compileError("renamed field destination does not exist"); + } + } + } + } + // Make an arena for all our allocations if we support it. Otherwise, // use an allocator that always fails. If the arena is already set on // the config, then we reuse that. See memory note in parse docs. @@ -367,6 +391,16 @@ pub fn parseIntoField( } } + // Unknown field, is the field renamed? + if (@hasDecl(T, "renamed")) { + for (T.renamed.keys(), T.renamed.values()) |old, new| { + if (mem.eql(u8, old, key)) { + try parseIntoField(T, alloc, dst, new, value); + return; + } + } + } + return error.InvalidField; } @@ -1104,6 +1138,24 @@ test "parseIntoField: tagged union missing tag" { ); } +test "parseIntoField: renamed field" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var data: struct { + a: []const u8, + + const renamed = std.StaticStringMap([]const u8).initComptime(&.{ + .{ "old", "a" }, + }); + } = undefined; + + try parseIntoField(@TypeOf(data), alloc, &data, "old", "42"); + try testing.expectEqualStrings("42", data.a); +} + /// An iterator that considers its location to be CLI args. It /// iterates through an underlying iterator and increments a counter /// to track the current CLI arg index. diff --git a/src/config/Config.zig b/src/config/Config.zig index e32a3485f..16e08bf08 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -42,6 +42,15 @@ const c = @cImport({ @cInclude("unistd.h"); }); +/// Renamed fields, used by cli.parse +pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ + // Ghostty 1.1 introduced background-blur support for Linux which + // doesn't support a specific radius value. The renaming is to let + // one field be used for both platforms (macOS retained the ability + // to set a radius). + .{ "background-blur-radius", "background-blur" }, +}); + /// The font families to use. /// /// You can generate the list of valid values using the CLI: @@ -649,7 +658,7 @@ palette: Palette = .{}, /// need to set environment-specific settings and/or install third-party plugins /// in order to support background blur, as there isn't a unified interface for /// doing so. -@"background-blur-radius": BackgroundBlur = .false, +@"background-blur": BackgroundBlur = .false, /// The opacity level (opposite of transparency) of an unfocused split. /// Unfocused splits by default are slightly faded out to make it easier to see @@ -5854,7 +5863,7 @@ pub const AutoUpdate = enum { download, }; -/// See background-blur-radius +/// See background-blur pub const BackgroundBlur = union(enum) { false, true, diff --git a/src/config/c_get.zig b/src/config/c_get.zig index 6804b0ae0..251a95e77 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -192,21 +192,21 @@ test "c_get: background-blur" { defer c.deinit(); { - c.@"background-blur-radius" = .false; + c.@"background-blur" = .false; var cval: u8 = undefined; - try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval))); + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(0, cval); } { - c.@"background-blur-radius" = .true; + c.@"background-blur" = .true; var cval: u8 = undefined; - try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval))); + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(20, cval); } { - c.@"background-blur-radius" = .{ .radius = 42 }; + c.@"background-blur" = .{ .radius = 42 }; var cval: u8 = undefined; - try testing.expect(get(&c, .@"background-blur-radius", @ptrCast(&cval))); + try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval))); try testing.expectEqual(42, cval); } } From 80eb406b8200b99c05e126e28015718ff2a35047 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Wed, 15 Jan 2025 00:44:56 -0600 Subject: [PATCH 222/238] fix: gtk titlebar being restored if it shouldn't be --- src/apprt/gtk/Window.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 59d6437d7..3512e211d 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -646,7 +646,7 @@ fn gtkWindowNotifyMaximized( if (!self.winproto.clientSideDecorationEnabled()) return; if (!maximized) { - self.headerbar.setVisible(true); + self.headerbar.setVisible(self.app.config.@"gtk-titlebar"); return; } if (self.app.config.@"gtk-titlebar-hide-when-maximized") { From 1be89cb1461654962c67f9eede10c7c29a8304d7 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Wed, 15 Jan 2025 00:59:34 -0600 Subject: [PATCH 223/238] fix: also respect gtk-titlebar value in fullscreened callback --- src/apprt/gtk/Window.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 3512e211d..b850ece81 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -675,7 +675,13 @@ fn gtkWindowNotifyFullscreened( ud: ?*anyopaque, ) callconv(.C) void { const self = userdataSelf(ud orelse return); - self.headerbar.setVisible(c.gtk_window_is_fullscreen(@ptrCast(object)) == 0); + const fullscreened = c.gtk_window_is_fullscreen(@ptrCast(object)) != 0; + if (!fullscreened) { + self.headerbar.setVisible(self.app.config.@"gtk-titlebar"); + return; + } + + self.headerbar.setVisible(false); } // Note: we MUST NOT use the GtkButton parameter because gtkActionNewTab From 078ee42be32c3bde946d3ce18b05d79ae518649e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Jan 2025 15:04:39 -0800 Subject: [PATCH 224/238] apprt/gtk: we should only show the headerbar again if csd --- src/apprt/gtk/Window.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index b850ece81..58f5659f0 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -639,16 +639,20 @@ fn gtkWindowNotifyMaximized( ud: ?*anyopaque, ) callconv(.C) void { const self = userdataSelf(ud orelse return); - const maximized = c.gtk_window_is_maximized(self.window) != 0; // Only toggle visibility of the header bar when we're using CSDs, // and actually intend on displaying the header bar if (!self.winproto.clientSideDecorationEnabled()) return; + // If we aren't maximized, we should show the headerbar again + // if it was originally visible. + const maximized = c.gtk_window_is_maximized(self.window) != 0; if (!maximized) { self.headerbar.setVisible(self.app.config.@"gtk-titlebar"); return; } + + // If we are maximized, we should hide the headerbar if requested. if (self.app.config.@"gtk-titlebar-hide-when-maximized") { self.headerbar.setVisible(false); } @@ -677,7 +681,8 @@ fn gtkWindowNotifyFullscreened( const self = userdataSelf(ud orelse return); const fullscreened = c.gtk_window_is_fullscreen(@ptrCast(object)) != 0; if (!fullscreened) { - self.headerbar.setVisible(self.app.config.@"gtk-titlebar"); + const csd_enabled = self.winproto.clientSideDecorationEnabled(); + self.headerbar.setVisible(self.app.config.@"gtk-titlebar" and csd_enabled); return; } From 956bb8f02b860be27de966eb31b15028d5544409 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Tue, 14 Jan 2025 14:47:17 +0100 Subject: [PATCH 225/238] gtk: request initial color scheme asynchronously Requesting the initial color scheme on systems where the D-Bus interface is nonexistent would delay Ghostty startup by 1-2 minutes. That's not acceptable. Our color scheme events are already async-friendly anyway. Fixes #4632 --- src/apprt/gtk/App.zig | 148 +++++++++++++++++--------------------- src/apprt/gtk/Surface.zig | 3 - 2 files changed, 66 insertions(+), 85 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 193710293..df74cefb2 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -73,6 +73,11 @@ clipboard_confirmation_window: ?*ClipboardConfirmationWindow = null, /// This is set to false when the main loop should exit. running: bool = true, +/// If we should retry querying D-Bus for the color scheme with the deprecated +/// Read method, instead of the recommended ReadOne method. This is kind of +/// nasty to have as struct state but its just a byte... +dbus_color_scheme_retry: 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. @@ -1271,7 +1276,8 @@ pub fn run(self: *App) !void { self.transient_cgroup_base = path; } else log.debug("cgroup isolation disabled config={}", .{self.config.@"linux-cgroup"}); - // Setup our D-Bus connection for listening to settings changes. + // Setup our D-Bus connection for listening to settings changes, + // and asynchronously request the initial color scheme self.initDbus(); // Setup our menu items @@ -1279,9 +1285,6 @@ pub fn run(self: *App) !void { self.initMenu(); self.initContextMenu(); - // Setup our initial color scheme - self.colorSchemeEvent(self.getColorScheme()); - // 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. @@ -1329,6 +1332,22 @@ fn initDbus(self: *App) void { self, null, ); + + // Request the initial color scheme asynchronously. + c.g_dbus_connection_call( + dbus, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", + "ReadOne", + c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), + c.G_VARIANT_TYPE("(v)"), + c.G_DBUS_CALL_FLAGS_NONE, + -1, + null, + dbusColorSchemeCallback, + self, + ); } // This timeout function is started when no surfaces are open. It can be @@ -1566,93 +1585,58 @@ fn gtkWindowIsActive( core_app.focusEvent(false); } -/// Call a D-Bus method to determine the current color scheme. If there -/// is any error at any point we'll log the error and return "light" -pub fn getColorScheme(self: *App) apprt.ColorScheme { - const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app)); +fn dbusColorSchemeCallback( + source_object: [*c]c.GObject, + res: ?*c.GAsyncResult, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *App = @ptrCast(@alignCast(ud.?)); + const dbus: *c.GDBusConnection = @ptrCast(source_object); var err: ?*c.GError = null; defer if (err) |e| c.g_error_free(e); - const value = c.g_dbus_connection_call_sync( - dbus_connection, - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.Settings", - "ReadOne", - c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), - c.G_VARIANT_TYPE("(v)"), - c.G_DBUS_CALL_FLAGS_NONE, - -1, - null, - &err, - ) orelse { - if (err) |e| { - // If ReadOne is not yet implemented, fall back to deprecated "Read" method - // Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method “ReadOne” - if (e.code == 19) { - return self.getColorSchemeDeprecated(); + if (c.g_dbus_connection_call_finish(dbus, res, &err)) |value| { + if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) { + var inner: ?*c.GVariant = null; + c.g_variant_get(value, "(v)", &inner); + defer c.g_variant_unref(inner); + if (c.g_variant_is_of_type(inner, c.G_VARIANT_TYPE("u")) == 1) { + self.colorSchemeEvent(if (c.g_variant_get_uint32(inner) == 1) + .dark + else + .light); + return; } - // Otherwise, log the error and return .light - log.err("unable to get current color scheme: {s}", .{e.message}); } - return .light; - }; - defer c.g_variant_unref(value); + } else if (err) |e| { + // If ReadOne is not yet implemented, fall back to deprecated "Read" method + // Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method “ReadOne” + if (self.dbus_color_scheme_retry and e.code == 19) { + self.dbus_color_scheme_retry = false; + c.g_dbus_connection_call( + dbus, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", + "Read", + c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), + c.G_VARIANT_TYPE("(v)"), + c.G_DBUS_CALL_FLAGS_NONE, + -1, + null, + dbusColorSchemeCallback, + self, + ); + return; + } - if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) { - var inner: ?*c.GVariant = null; - c.g_variant_get(value, "(v)", &inner); - defer c.g_variant_unref(inner); - if (c.g_variant_is_of_type(inner, c.G_VARIANT_TYPE("u")) == 1) { - return if (c.g_variant_get_uint32(inner) == 1) .dark else .light; - } + // Otherwise, log the error and return .light + log.warn("unable to get current color scheme: {s}", .{e.message}); } - return .light; -} - -/// Call the deprecated D-Bus "Read" method to determine the current color scheme. If -/// there is any error at any point we'll log the error and return "light" -fn getColorSchemeDeprecated(self: *App) apprt.ColorScheme { - const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app)); - var err: ?*c.GError = null; - defer if (err) |e| c.g_error_free(e); - - const value = c.g_dbus_connection_call_sync( - dbus_connection, - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.Settings", - "Read", - c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), - c.G_VARIANT_TYPE("(v)"), - c.G_DBUS_CALL_FLAGS_NONE, - -1, - null, - &err, - ) orelse { - if (err) |e| log.err("Read method failed: {s}", .{e.message}); - return .light; - }; - defer c.g_variant_unref(value); - - if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) { - var inner: ?*c.GVariant = null; - c.g_variant_get(value, "(v)", &inner); - defer if (inner) |i| c.g_variant_unref(i); - - if (inner) |i| { - const child = c.g_variant_get_child_value(i, 0) orelse { - return .light; - }; - defer c.g_variant_unref(child); - - const val = c.g_variant_get_uint32(child); - return if (val == 1) .dark else .light; - } - } - return .light; + // Fall back + self.colorSchemeEvent(.light); } /// This will be called by D-Bus when the style changes between light & dark. diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index b429c7233..a72830786 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -633,9 +633,6 @@ fn realize(self: *Surface) !void { try self.core_surface.setFontSize(size); } - // Set the initial color scheme - try self.core_surface.colorSchemeCallback(self.app.getColorScheme()); - // Note we're realized self.realized = true; } From 5327646d583d46686a31dec902e349144d52c81c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Jan 2025 15:41:00 -0800 Subject: [PATCH 226/238] config: rename adw-toasts to app-notifications There is no `renamed` entry for this because this was never part of a released version of Ghostty. This is not considered a break change. Fixes #4460 --- src/apprt/gtk/Surface.zig | 2 +- src/config/Config.zig | 52 ++++++++++++++++++++------------------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index a72830786..76be18591 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1133,7 +1133,7 @@ pub fn setClipboardString( c.gdk_clipboard_set_text(clipboard, val.ptr); // We only toast if we are copying to the standard clipboard. if (clipboard_type == .standard and - self.app.config.@"adw-toast".@"clipboard-copy") + self.app.config.@"app-notifications".@"clipboard-copy") { if (self.container.window()) |window| window.sendToast("Copied to clipboard"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 16e08bf08..839656169 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1771,6 +1771,31 @@ keybind: Keybinds = .{}, /// open terminals. @"custom-shader-animation": CustomShaderAnimation = .true, +/// Control the in-app notifications that Ghostty shows. +/// +/// On Linux (GTK) with Adwaita, in-app notifications show up as toasts. Toasts +/// appear overlaid on top of the terminal window. They are used to show +/// information that is not critical but may be important. +/// +/// Possible notifications are: +/// +/// - `clipboard-copy` (default: true) - Show a notification when text is copied +/// to the clipboard. +/// +/// To specify a notification to enable, specify the name of the notification. +/// To specify a notification to disable, prefix the name with `no-`. For +/// example, to disable `clipboard-copy`, set this configuration to +/// `no-clipboard-copy`. To enable it, set this configuration to `clipboard-copy`. +/// +/// Multiple notifications can be enabled or disabled by separating them +/// with a comma. +/// +/// A value of "false" will disable all notifications. A value of "true" will +/// enable all notifications. +/// +/// This configuration only applies to GTK with Adwaita enabled. +@"app-notifications": AppNotifications = .{}, + /// If anything other than false, fullscreen mode on macOS will not use the /// native fullscreen, but make the window fullscreen without animations and /// using a new space. It's faster than the native fullscreen mode since it @@ -2121,29 +2146,6 @@ keybind: Keybinds = .{}, /// Changing this value at runtime will only affect new windows. @"adw-toolbar-style": AdwToolbarStyle = .raised, -/// Control the toasts that Ghostty shows. Toasts are small notifications -/// that appear overlaid on top of the terminal window. They are used to -/// show information that is not critical but may be important. -/// -/// Possible toasts are: -/// -/// - `clipboard-copy` (default: true) - Show a toast when text is copied -/// to the clipboard. -/// -/// To specify a toast to enable, specify the name of the toast. To specify -/// a toast to disable, prefix the name with `no-`. For example, to disable -/// the clipboard-copy toast, set this configuration to `no-clipboard-copy`. -/// To enable the clipboard-copy toast, set this configuration to -/// `clipboard-copy`. -/// -/// Multiple toasts can be enabled or disabled by separating them with a comma. -/// -/// A value of "false" will disable all toasts. A value of "true" will -/// enable all toasts. -/// -/// This configuration only applies to GTK with Adwaita enabled. -@"adw-toast": AdwToast = .{}, - /// If `true` (default), then the Ghostty GTK tabs will be "wide." Wide tabs /// are the new typical Gnome style where tabs fill their available space. /// If you set this to `false` then tabs will only take up space they need, @@ -5745,8 +5747,8 @@ pub const AdwToolbarStyle = enum { @"raised-border", }; -/// See adw-toast -pub const AdwToast = packed struct { +/// See app-notifications +pub const AppNotifications = packed struct { @"clipboard-copy": bool = true, }; From 168dd3136756836259b43a72bca9791827fb717b Mon Sep 17 00:00:00 2001 From: Anund Date: Fri, 3 Jan 2025 23:53:22 +1100 Subject: [PATCH 227/238] documentation: consistent format for actions help --- src/cli/action.zig | 6 +++--- src/cli/help.zig | 8 +++++--- src/cli/list_actions.zig | 4 +++- src/cli/list_fonts.zig | 21 ++++++++++++++------- src/cli/list_keybinds.zig | 12 ++++++++---- src/cli/list_themes.zig | 1 + src/cli/validate_config.zig | 9 ++++++--- src/cli/version.zig | 3 ++- src/shell-integration/README.md | 2 +- 9 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/cli/action.zig b/src/cli/action.zig index a84a40024..693d509fc 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -45,12 +45,12 @@ pub const Action = enum { // Validate passed config file @"validate-config", - // List, (eventually) view, and (eventually) send crash reports. - @"crash-report", - // Show which font face Ghostty loads a codepoint from. @"show-face", + // List, (eventually) view, and (eventually) send crash reports. + @"crash-report", + pub const Error = error{ /// Multiple actions were detected. You can specify at most one /// action on the CLI otherwise the behavior desired is ambiguous. diff --git a/src/cli/help.zig b/src/cli/help.zig index daadc37cc..22fe27d8d 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -15,9 +15,11 @@ pub const Options = struct { } }; -/// The `help` command shows general help about Ghostty. You can also specify -/// `--help` or `-h` along with any action such as `+list-themes` to see help -/// for a specific action. +/// The `help` command shows general help about Ghostty. Recognized as either +/// `-h, `--help`, or like other actions `+help`. +/// +/// You can also specify `--help` or `-h` along with any action such as +/// `+list-themes` to see help for a specific action. pub fn run(alloc: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); diff --git a/src/cli/list_actions.zig b/src/cli/list_actions.zig index 65b9dcdad..6f67a92d2 100644 --- a/src/cli/list_actions.zig +++ b/src/cli/list_actions.zig @@ -24,7 +24,9 @@ pub const Options = struct { /// actions for Ghostty. These are distinct from the CLI Actions which can /// be listed via `+help` /// -/// The `--docs` argument will print out the documentation for each action. +/// Flags: +/// +/// * `--docs`: will print out the documentation for each action. pub fn run(alloc: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); diff --git a/src/cli/list_fonts.zig b/src/cli/list_fonts.zig index 9d1f34cd1..e8a010ecd 100644 --- a/src/cli/list_fonts.zig +++ b/src/cli/list_fonts.zig @@ -44,14 +44,21 @@ pub const Options = struct { /// the sorting will be disabled and the results instead will be shown in the /// same priority order Ghostty would use to pick a font. /// -/// The `--family` argument can be used to filter results to a specific family. -/// The family handling is identical to the `font-family` set of Ghostty -/// configuration values, so this can be used to debug why your desired font may -/// not be loading. +/// Flags: /// -/// The `--bold` and `--italic` arguments can be used to filter results to -/// specific styles. It is not guaranteed that only those styles are returned, -/// it will just prioritize fonts that match those styles. +/// * `--bold`: Filter results to specific bold styles. It is not guaranteed +/// that only those styles are returned. They are only prioritized. +/// +/// * `--italic`: Filter results to specific italic styles. It is not guaranteed +/// that only those styles are returned. They are only prioritized. +/// +/// * `--style`: Filter results based on the style string advertised by a font. +/// It is not guaranteed that only those styles are returned. They are only +/// prioritized. +/// +/// * `--family`: Filter results to a specific font family. The family handling +/// is identical to the `font-family` set of Ghostty configuration values, so +/// this can be used to debug why your desired font may not be loading. pub fn run(alloc: Allocator) !u8 { var iter = try args.argsIterator(alloc); defer iter.deinit(); diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index ddaf75177..13c69d970 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -42,11 +42,15 @@ pub const Options = struct { /// changes to the keybinds it will print out the default ones configured for /// Ghostty /// -/// The `--default` argument will print out all the default keybinds configured -/// for Ghostty +/// Flags: /// -/// The `--plain` flag will disable formatting and make the output more -/// friendly for Unix tooling. This is default when not printing to a tty. +/// * `--default`: will print out all the default keybinds +/// +/// * `--docs`: currently does nothing, intended to print out documentation +/// about the action associated with the keybinds +/// +/// * `--plain`: will disable formatting and make the output more +/// friendly for Unix tooling. This is default when not printing to a tty. pub fn run(alloc: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 22e22a972..f7ee10ce6 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -91,6 +91,7 @@ const ThemeListElement = struct { /// Flags: /// /// * `--path`: Show the full path to the theme. +/// /// * `--plain`: Force a plain listing of themes. pub fn run(gpa_alloc: std.mem.Allocator) !u8 { var opts: Options = .{}; diff --git a/src/cli/validate_config.zig b/src/cli/validate_config.zig index 1615ef66b..5bc6ff406 100644 --- a/src/cli/validate_config.zig +++ b/src/cli/validate_config.zig @@ -23,10 +23,13 @@ pub const Options = struct { /// The `validate-config` command is used to validate a Ghostty config file. /// -/// When executed without any arguments, this will load the config from the default location. +/// When executed without any arguments, this will load the config from the default +/// location. /// -/// The `--config-file` argument can be passed to validate a specific target config -/// file in a non-default location. +/// Flags: +/// +/// * `--config-file`: can be passed to validate a specific target config file in +/// a non-default location pub fn run(alloc: std.mem.Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); diff --git a/src/cli/version.zig b/src/cli/version.zig index b00152589..4a6af242c 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -10,7 +10,8 @@ const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig"). pub const Options = struct {}; -/// The `version` command is used to display information about Ghostty. +/// The `version` command is used to display information about Ghostty. Recognized as +/// either `+version` or `--version`. pub fn run(alloc: Allocator) !u8 { _ = alloc; diff --git a/src/shell-integration/README.md b/src/shell-integration/README.md index 976cf4924..3d5159c71 100644 --- a/src/shell-integration/README.md +++ b/src/shell-integration/README.md @@ -6,7 +6,7 @@ supports. This README is meant as developer documentation and not as user documentation. For user documentation, see the main -README. +README or [ghostty.org](https://ghostty.org/docs) ## Implementation Details From 098a46f0773c544b07e8d812247719893f1b6b6d Mon Sep 17 00:00:00 2001 From: Anund Date: Wed, 8 Jan 2025 17:32:11 +1100 Subject: [PATCH 228/238] docs: generate mdx file for cli actions --- src/build/Config.zig | 1 + src/build/GhosttyWebdata.zig | 29 +++++++++++++++++ src/build/webgen/main_commands.zig | 51 ++++++++++++++++++++++++++++++ src/cli/README.md | 13 ++++++++ src/main.zig | 1 + 5 files changed, 95 insertions(+) create mode 100644 src/build/webgen/main_commands.zig create mode 100644 src/cli/README.md diff --git a/src/build/Config.zig b/src/build/Config.zig index 71dffce4a..b65a8d566 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -486,6 +486,7 @@ pub const ExeEntrypoint = enum { mdgen_ghostty_5, webgen_config, webgen_actions, + webgen_commands, bench_parser, bench_stream, bench_codepoint_width, diff --git a/src/build/GhosttyWebdata.zig b/src/build/GhosttyWebdata.zig index 6e0acaf17..860feb705 100644 --- a/src/build/GhosttyWebdata.zig +++ b/src/build/GhosttyWebdata.zig @@ -73,6 +73,35 @@ pub fn init( ).step); } + { + const webgen_commands = b.addExecutable(.{ + .name = "webgen_commands", + .root_source_file = b.path("src/main.zig"), + .target = b.host, + }); + deps.help_strings.addImport(webgen_commands); + + { + const buildconfig = config: { + var copy = deps.config.*; + copy.exe_entrypoint = .webgen_commands; + break :config copy; + }; + + const options = b.addOptions(); + try buildconfig.addOptions(options); + webgen_commands.root_module.addOptions("build_options", options); + } + + const webgen_commands_step = b.addRunArtifact(webgen_commands); + const webgen_commands_out = webgen_commands_step.captureStdOut(); + + try steps.append(&b.addInstallFile( + webgen_commands_out, + "share/ghostty/webdata/commands.mdx", + ).step); + } + return .{ .steps = steps.items }; } diff --git a/src/build/webgen/main_commands.zig b/src/build/webgen/main_commands.zig new file mode 100644 index 000000000..6e6b00c5e --- /dev/null +++ b/src/build/webgen/main_commands.zig @@ -0,0 +1,51 @@ +const std = @import("std"); +const Action = @import("../../cli/action.zig").Action; +const help_strings = @import("help_strings"); + +pub fn main() !void { + const output = std.io.getStdOut().writer(); + try genActions(output); +} + +// Note: as a shortcut for defining inline editOnGithubLinks per cli action the user +// is directed to the folder view on Github. This includes a README pointing them to +// the files to edit. +pub fn genActions(writer: anytype) !void { + // Write the header + try writer.writeAll( + \\--- + \\title: Reference + \\description: Reference of all Ghostty action subcommands. + \\editOnGithubLink: https://github.com/ghostty-org/ghostty/tree/main/src/cli + \\--- + \\Ghostty includes a number of utility actions that can be accessed as subcommands. + \\Actions provide utilities to work with config, list keybinds, list fonts, demo themes, + \\and debug. + \\ + ); + + inline for (@typeInfo(Action).Enum.fields) |field| { + const action = std.meta.stringToEnum(Action, field.name).?; + + switch (action) { + .help, .version => try writer.writeAll("## " ++ field.name ++ "\n"), + else => try writer.writeAll("## " ++ field.name ++ "\n"), + } + + if (@hasDecl(help_strings.Action, field.name)) { + var iter = std.mem.splitScalar(u8, @field(help_strings.Action, field.name), '\n'); + var first = true; + while (iter.next()) |s| { + try writer.writeAll(s); + try writer.writeAll("\n"); + first = false; + } + try writer.writeAll("\n```\n"); + switch (action) { + .help, .version => try writer.writeAll("ghostty --" ++ field.name ++ "\n"), + else => try writer.writeAll("ghostty +" ++ field.name ++ "\n"), + } + try writer.writeAll("```\n\n"); + } + } +} diff --git a/src/cli/README.md b/src/cli/README.md new file mode 100644 index 000000000..7a1d99409 --- /dev/null +++ b/src/cli/README.md @@ -0,0 +1,13 @@ +# Subcommand Actions + +This is the cli specific code. It contains cli actions and tui definitions and +argument parsing. + +This README is meant as developer documentation and not as user documentation. +For user documentation, see the main README or [ghostty.org](https://ghostty.org/docs). + +## Updating documentation + +Each cli action is defined in it's own file. Documentation for each action is defined +in the doc comment associated with the `run` function. For example the `run` function +in `list_keybinds.zig` contains the help text for `ghostty +list-keybinds`. diff --git a/src/main.zig b/src/main.zig index ecf38fbb3..121a3b7d2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -9,6 +9,7 @@ const entrypoint = switch (build_config.exe_entrypoint) { .mdgen_ghostty_5 => @import("build/mdgen/main_ghostty_5.zig"), .webgen_config => @import("build/webgen/main_config.zig"), .webgen_actions => @import("build/webgen/main_actions.zig"), + .webgen_commands => @import("build/webgen/main_commands.zig"), .bench_parser => @import("bench/parser.zig"), .bench_stream => @import("bench/stream.zig"), .bench_codepoint_width => @import("bench/codepoint-width.zig"), From 78790f6ef75c95929ad8dcb56b79efe91be55c64 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 23 Jan 2025 20:06:53 -0500 Subject: [PATCH 229/238] fix(Metal): always render explicit background colors fully opaque This fixes a regression introduced by the rework of this area before during the color space changes. It seems like the original intent of this code was the behavior it regressed to, but it turns out to be better like this. --- src/renderer/Metal.zig | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index bf28b58ac..52a5437c6 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -2672,9 +2672,8 @@ fn rebuildCells( // Cells that are reversed should be fully opaque. if (style.flags.inverse) break :bg_alpha default; - // Cells that have an explicit bg color, which does not - // match the current surface bg, should be fully opaque. - if (bg != null and !rgb.eql(self.background_color orelse self.default_background_color)) { + // Cells that have an explicit bg color should be fully opaque. + if (bg_style != null) { break :bg_alpha default; } From c0eb6985ee6adaeb031751fddb0d0448533d68c6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Jan 2025 19:38:13 -0800 Subject: [PATCH 230/238] Revert "build: options to enable/disable terminfo & termcap install" This reverts commit 8f49a227b7c352083b0815e7818db900402513e0. --- src/build/Config.zig | 25 +------------------- src/build/GhosttyResources.zig | 42 +++++++++++++++++----------------- src/os/resourcesdir.zig | 5 +--- 3 files changed, 23 insertions(+), 49 deletions(-) diff --git a/src/build/Config.zig b/src/build/Config.zig index 8b28a6a04..b65a8d566 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -55,8 +55,6 @@ emit_helpgen: bool = false, emit_docs: bool = false, emit_webdata: bool = false, emit_xcframework: bool = false, -emit_terminfo: bool = false, -emit_termcap: bool = false, /// Environmental properties env: std.process.EnvMap, @@ -308,32 +306,11 @@ pub fn init(b: *std.Build) !Config { break :emit_docs path != null; }; - config.emit_terminfo = b.option( - bool, - "emit-terminfo", - "Install Ghostty terminfo source file", - ) orelse switch (target.result.os.tag) { - .windows => true, - else => switch (optimize) { - .Debug => true, - .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, - }, - }; - - config.emit_termcap = b.option( - bool, - "emit-termcap", - "Install Ghostty termcap file", - ) orelse false; - config.emit_webdata = b.option( bool, "emit-webdata", "Build the website data for the website.", - ) orelse switch (optimize) { - .Debug => true, - .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, - }; + ) orelse false; config.emit_xcframework = b.option( bool, diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index c0830e5f6..cae907ec2 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -23,12 +23,9 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // Write it var wf = b.addWriteFiles(); - const source = wf.add("ghostty.terminfo", str.items); - - if (cfg.emit_terminfo) { - const source_install = b.addInstallFile(source, "share/terminfo/ghostty.terminfo"); - try steps.append(&source_install.step); - } + const src_source = wf.add("share/terminfo/ghostty.terminfo", str.items); + const src_install = b.addInstallFile(src_source, "share/terminfo/ghostty.terminfo"); + try steps.append(&src_install.step); // Windows doesn't have the binaries below. if (cfg.target.result.os.tag == .windows) break :terminfo; @@ -36,10 +33,10 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // Convert to termcap source format if thats helpful to people and // install it. The resulting value here is the termcap source in case // that is used for other commands. - if (cfg.emit_termcap) { + { const run_step = RunStep.create(b, "infotocap"); - run_step.addArgs(&.{ "infotocap", "-" }); - run_step.setStdIn(.{ .lazy_path = source }); + run_step.addArg("infotocap"); + run_step.addFileArg(src_source); const out_source = run_step.captureStdOut(); _ = run_step.captureStdErr(); // so we don't see stderr @@ -51,21 +48,24 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { { const run_step = RunStep.create(b, "tic"); run_step.addArgs(&.{ "tic", "-x", "-o" }); - const path = run_step.addOutputDirectoryArg("share/terminfo"); - run_step.addArg("-"); - run_step.setStdIn(.{ .lazy_path = source }); + const path = run_step.addOutputFileArg("terminfo"); + run_step.addFileArg(src_source); _ = run_step.captureStdErr(); // so we don't see stderr - try steps.append(&run_step.step); + // Depend on the terminfo source install step so that Zig build + // creates the "share" directory for us. + run_step.step.dependOn(&src_install.step); - // Use cp -R instead of Step.InstallDir because we need to preserve - // symlinks in the terminfo database. Zig's InstallDir step doesn't - // handle symlinks correctly yet. - const copy_step = RunStep.create(b, "copy terminfo db"); - copy_step.addArgs(&.{ "cp", "-R" }); - copy_step.addFileArg(path); - copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); - try steps.append(©_step.step); + { + // Use cp -R instead of Step.InstallDir because we need to preserve + // symlinks in the terminfo database. Zig's InstallDir step doesn't + // handle symlinks correctly yet. + const copy_step = RunStep.create(b, "copy terminfo db"); + copy_step.addArgs(&.{ "cp", "-R" }); + copy_step.addFileArg(path); + copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); + try steps.append(©_step.step); + } } } diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig index d2b274e87..c0f82dec5 100644 --- a/src/os/resourcesdir.zig +++ b/src/os/resourcesdir.zig @@ -21,10 +21,7 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // This is the sentinel value we look for in the path to know // we've found the resources directory. - const sentinel = switch (comptime builtin.target.os.tag) { - .windows => "terminfo/ghostty.terminfo", - else => "terminfo/x/xterm-ghostty", - }; + const sentinel = "terminfo/ghostty.termcap"; // Get the path to our running binary var exe_buf: [std.fs.max_path_bytes]u8 = undefined; From 0d6a1d3fdb93ee5444a2f998e085266ad443442a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 23 Jan 2025 21:22:06 -0800 Subject: [PATCH 231/238] Prevent fd leaks to the running shell or command Multiple fixes to prevent file descriptor leaks: - libxev eventfd now uses CLOEXEC - linux: cgroup clone now uses CLOEXEC for the cgroup fd - termio pipe uses pipe2 with CLOEXEC - pty master always sets CLOEXEC because the child doesn't need it - termio exec now closes pty slave fd after fork There still appear to be some fd leaks happening. They seem related to GTK, they aren't things we're accessig directly. I still want to investigate them but this at least cleans up the major sources of fd leakage. --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- src/os/cgroup.zig | 17 ++++++++++++++++- src/os/pipe.zig | 5 +++-- src/pty.zig | 26 ++++++++++++++++++++++---- src/termio/Exec.zig | 11 +++++++++++ 6 files changed, 55 insertions(+), 10 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 09dc9847e..9c00a4704 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .dependencies = .{ // Zig libs .libxev = .{ - .url = "https://github.com/mitchellh/libxev/archive/db6a52bafadf00360e675fefa7926e8e6c0e9931.tar.gz", - .hash = "12206029de146b685739f69b10a6f08baee86b3d0a5f9a659fa2b2b66c9602078bbf", + .url = "https://github.com/mitchellh/libxev/archive/aceef3d11efacd9d237c91632f930ed13a2834bf.tar.gz", + .hash = "12205b2b47fe61a4cde3a45ee4b9cddee75897739dbc196d6396e117cb1ce28e1ad0", }, .mach_glfw = .{ .url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz", diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index dfc2e5f7f..c687a5a79 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-H6o4Y09ATIylMUWuL9Y1fHwpuxSWyJ3Pl8fn4VeoDZo=" +"sha256-AvfYl8vLxxsRnf/ERpw5jQIro5rVd98q63hwFsgQOvo=" diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig index 0a66c5987..bef101acc 100644 --- a/src/os/cgroup.zig +++ b/src/os/cgroup.zig @@ -77,7 +77,22 @@ pub fn cloneInto(cgroup: []const u8) !posix.pid_t { // Get a file descriptor that refers to the cgroup directory in the cgroup // sysfs to pass to the kernel in clone3. const fd: linux.fd_t = fd: { - const rc = linux.open(path, linux.O{ .PATH = true, .DIRECTORY = true }, 0); + const rc = linux.open( + path, + .{ + // Self-explanatory: we expect to open a directory, and + // we only need the path-level permissions. + .PATH = true, + .DIRECTORY = true, + + // We don't want to leak this fd to the child process + // when we clone below since we're using this fd for + // a cgroup clone. + .CLOEXEC = true, + }, + 0, + ); + switch (posix.errno(rc)) { .SUCCESS => break :fd @as(linux.fd_t, @intCast(rc)), else => |errno| { diff --git a/src/os/pipe.zig b/src/os/pipe.zig index 392f72083..2cb7bd4a3 100644 --- a/src/os/pipe.zig +++ b/src/os/pipe.zig @@ -3,10 +3,11 @@ const builtin = @import("builtin"); const windows = @import("windows.zig"); const posix = std.posix; -/// pipe() that works on Windows and POSIX. +/// pipe() that works on Windows and POSIX. For POSIX systems, this sets +/// CLOEXEC on the file descriptors. pub fn pipe() ![2]posix.fd_t { switch (builtin.os.tag) { - else => return try posix.pipe(), + else => return try posix.pipe2(.{ .CLOEXEC = true }), .windows => { var read: windows.HANDLE = undefined; var write: windows.HANDLE = undefined; diff --git a/src/pty.zig b/src/pty.zig index c0d082411..1df09d79c 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -94,6 +94,9 @@ const PosixPty = struct { }; /// The file descriptors for the master and slave side of the pty. + /// The slave side is never closed automatically by this struct + /// so the caller is responsible for closing it if things + /// go wrong. master: Fd, slave: Fd, @@ -117,6 +120,24 @@ const PosixPty = struct { _ = posix.system.close(slave_fd); } + // Set CLOEXEC on the master fd, only the slave fd should be inherited + // by the child process (shell/command). + cloexec: { + const flags = std.posix.fcntl(master_fd, std.posix.F.GETFD, 0) catch |err| { + log.warn("error getting flags for master fd err={}", .{err}); + break :cloexec; + }; + + _ = std.posix.fcntl( + master_fd, + std.posix.F.SETFD, + flags | std.posix.FD_CLOEXEC, + ) catch |err| { + log.warn("error setting CLOEXEC on master fd err={}", .{err}); + break :cloexec; + }; + } + // Enable UTF-8 mode. I think this is on by default on Linux but it // is NOT on by default on macOS so we ensure that it is always set. var attrs: c.termios = undefined; @@ -126,7 +147,7 @@ const PosixPty = struct { if (c.tcsetattr(master_fd, c.TCSANOW, &attrs) != 0) return error.OpenptyFailed; - return Pty{ + return .{ .master = master_fd, .slave = slave_fd, }; @@ -134,7 +155,6 @@ const PosixPty = struct { pub fn deinit(self: *Pty) void { _ = posix.system.close(self.master); - _ = posix.system.close(self.slave); self.* = undefined; } @@ -201,8 +221,6 @@ const PosixPty = struct { // Can close master/slave pair now posix.close(self.slave); posix.close(self.master); - - // TODO: reset signals } }; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index e320152ec..c55e66729 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1098,6 +1098,10 @@ const Subprocess = struct { }); self.pty = pty; errdefer { + if (comptime builtin.os.tag != .windows) { + _ = posix.close(pty.slave); + } + pty.deinit(); self.pty = null; } @@ -1182,6 +1186,13 @@ const Subprocess = struct { log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"}); } + if (comptime builtin.os.tag != .windows) { + // Once our subcommand is started we can close the slave + // side. This prevents the slave fd from being leaked to + // future children. + _ = posix.close(pty.slave); + } + self.command = cmd; return switch (builtin.os.tag) { .windows => .{ From 2f8b0dc899dd197f1e018ccedcfadbe6d82994ad Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 23 Jan 2025 22:29:47 -0600 Subject: [PATCH 232/238] build: options to enable/disable terminfo & termcap install (take 2) Fixes #5253 Add -Demit-terminfo and -Demit-termcap build options to enable/disable installation of source terminfo and termcap files. --- src/build/Config.zig | 23 +++++++++++++++++ src/build/GhosttyResources.zig | 47 ++++++++++++++++++++-------------- src/os/resourcesdir.zig | 6 ++++- 3 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/build/Config.zig b/src/build/Config.zig index b65a8d566..c6f0e6d09 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -55,6 +55,8 @@ emit_helpgen: bool = false, emit_docs: bool = false, emit_webdata: bool = false, emit_xcframework: bool = false, +emit_terminfo: bool = false, +emit_termcap: bool = false, /// Environmental properties env: std.process.EnvMap, @@ -306,6 +308,27 @@ pub fn init(b: *std.Build) !Config { break :emit_docs path != null; }; + config.emit_terminfo = b.option( + bool, + "emit-terminfo", + "Install Ghostty terminfo source file", + ) orelse switch (target.result.os.tag) { + .windows => true, + else => switch (optimize) { + .Debug => true, + .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, + }, + }; + + config.emit_termcap = b.option( + bool, + "emit-termcap", + "Install Ghostty termcap file", + ) orelse switch (optimize) { + .Debug => true, + .ReleaseSafe, .ReleaseFast, .ReleaseSmall => false, + }; + config.emit_webdata = b.option( bool, "emit-webdata", diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index cae907ec2..2fdfbe81d 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -16,6 +16,15 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // Terminfo terminfo: { + const mkdir_step = RunStep.create(b, "make share/terminfo directory"); + switch (cfg.target.result.os.tag) { + // windows mkdir shouldn't need "-p" + .windows => mkdir_step.addArgs(&.{"mkdir"}), + else => mkdir_step.addArgs(&.{ "mkdir", "-p" }), + } + mkdir_step.addArg(b.fmt("{s}/share/terminfo", .{b.install_path})); + try steps.append(&mkdir_step.step); + // Encode our terminfo var str = std.ArrayList(u8).init(b.allocator); defer str.deinit(); @@ -23,9 +32,13 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // Write it var wf = b.addWriteFiles(); - const src_source = wf.add("share/terminfo/ghostty.terminfo", str.items); - const src_install = b.addInstallFile(src_source, "share/terminfo/ghostty.terminfo"); - try steps.append(&src_install.step); + const source = wf.add("ghostty.terminfo", str.items); + + if (cfg.emit_terminfo) { + const source_install = b.addInstallFile(source, "share/terminfo/ghostty.terminfo"); + source_install.step.dependOn(&mkdir_step.step); + try steps.append(&source_install.step); + } // Windows doesn't have the binaries below. if (cfg.target.result.os.tag == .windows) break :terminfo; @@ -36,11 +49,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { { const run_step = RunStep.create(b, "infotocap"); run_step.addArg("infotocap"); - run_step.addFileArg(src_source); + run_step.addFileArg(source); const out_source = run_step.captureStdOut(); _ = run_step.captureStdErr(); // so we don't see stderr const cap_install = b.addInstallFile(out_source, "share/terminfo/ghostty.termcap"); + cap_install.step.dependOn(&mkdir_step.step); try steps.append(&cap_install.step); } @@ -49,23 +63,18 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { const run_step = RunStep.create(b, "tic"); run_step.addArgs(&.{ "tic", "-x", "-o" }); const path = run_step.addOutputFileArg("terminfo"); - run_step.addFileArg(src_source); + run_step.addFileArg(source); _ = run_step.captureStdErr(); // so we don't see stderr - // Depend on the terminfo source install step so that Zig build - // creates the "share" directory for us. - run_step.step.dependOn(&src_install.step); - - { - // Use cp -R instead of Step.InstallDir because we need to preserve - // symlinks in the terminfo database. Zig's InstallDir step doesn't - // handle symlinks correctly yet. - const copy_step = RunStep.create(b, "copy terminfo db"); - copy_step.addArgs(&.{ "cp", "-R" }); - copy_step.addFileArg(path); - copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); - try steps.append(©_step.step); - } + // Use cp -R instead of Step.InstallDir because we need to preserve + // symlinks in the terminfo database. Zig's InstallDir step doesn't + // handle symlinks correctly yet. + const copy_step = RunStep.create(b, "copy terminfo db"); + copy_step.addArgs(&.{ "cp", "-R" }); + copy_step.addFileArg(path); + copy_step.addArg(b.fmt("{s}/share", .{b.install_path})); + copy_step.step.dependOn(&mkdir_step.step); + try steps.append(©_step.step); } } diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig index c0f82dec5..4ef256c1a 100644 --- a/src/os/resourcesdir.zig +++ b/src/os/resourcesdir.zig @@ -21,7 +21,11 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // This is the sentinel value we look for in the path to know // we've found the resources directory. - const sentinel = "terminfo/ghostty.termcap"; + const sentinel = switch (comptime builtin.target.os.tag) { + .windows => "terminfo/ghostty.terminfo", + .macos => "terminfo/78/xterm-ghostty", + else => "terminfo/x/xterm-ghostty", + }; // Get the path to our running binary var exe_buf: [std.fs.max_path_bytes]u8 = undefined; From d1969f74acee0b8d34ebc0b4fba4de7dd4494618 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 24 Jan 2025 10:05:56 -0600 Subject: [PATCH 233/238] only the cp step needs to depend on the mkdir step --- src/build/GhosttyResources.zig | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 2fdfbe81d..1ce3fd66c 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -16,15 +16,6 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // Terminfo terminfo: { - const mkdir_step = RunStep.create(b, "make share/terminfo directory"); - switch (cfg.target.result.os.tag) { - // windows mkdir shouldn't need "-p" - .windows => mkdir_step.addArgs(&.{"mkdir"}), - else => mkdir_step.addArgs(&.{ "mkdir", "-p" }), - } - mkdir_step.addArg(b.fmt("{s}/share/terminfo", .{b.install_path})); - try steps.append(&mkdir_step.step); - // Encode our terminfo var str = std.ArrayList(u8).init(b.allocator); defer str.deinit(); @@ -36,7 +27,6 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { if (cfg.emit_terminfo) { const source_install = b.addInstallFile(source, "share/terminfo/ghostty.terminfo"); - source_install.step.dependOn(&mkdir_step.step); try steps.append(&source_install.step); } @@ -54,7 +44,6 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { _ = run_step.captureStdErr(); // so we don't see stderr const cap_install = b.addInstallFile(out_source, "share/terminfo/ghostty.termcap"); - cap_install.step.dependOn(&mkdir_step.step); try steps.append(&cap_install.step); } @@ -66,6 +55,17 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { run_step.addFileArg(source); _ = run_step.captureStdErr(); // so we don't see stderr + // Ensure that `share/terminfo` is a directory, otherwise the `cp + // -R` will create a file named `share/terminfo` + const mkdir_step = RunStep.create(b, "make share/terminfo directory"); + switch (cfg.target.result.os.tag) { + // windows mkdir shouldn't need "-p" + .windows => mkdir_step.addArgs(&.{"mkdir"}), + else => mkdir_step.addArgs(&.{ "mkdir", "-p" }), + } + mkdir_step.addArg(b.fmt("{s}/share/terminfo", .{b.install_path})); + try steps.append(&mkdir_step.step); + // Use cp -R instead of Step.InstallDir because we need to preserve // symlinks in the terminfo database. Zig's InstallDir step doesn't // handle symlinks correctly yet. From 593d70a42f8a7d0c87136a7f222eb45ef2821c37 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 24 Jan 2025 10:06:32 -0600 Subject: [PATCH 234/238] fix missing check of emit_termcap build option --- src/build/GhosttyResources.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 1ce3fd66c..a7ff40cbd 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -36,7 +36,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { // Convert to termcap source format if thats helpful to people and // install it. The resulting value here is the termcap source in case // that is used for other commands. - { + if (cfg.emit_termcap) { const run_step = RunStep.create(b, "infotocap"); run_step.addArg("infotocap"); run_step.addFileArg(source); From 8475768ad1e6ada5b8c96ed02a661580ad8166de Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Jan 2025 09:38:01 -0800 Subject: [PATCH 235/238] termio/exec: if pty fd HUP, end read thread Fixes #4884 When our command exits, it will close the pty slave fd. This will trigger a HUP on our poll. Previously, we only checked for IN. When a fd is closed, IN triggers forever which would leave to an infinite loop and 100% CPU. Now, detect the HUP and exit the read thread. --- src/termio/Exec.zig | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index c55e66729..4428b16e1 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -179,8 +179,17 @@ pub fn threadExit(self: *Exec, td: *termio.Termio.ThreadData) void { // Quit our read thread after exiting the subprocess so that // we don't get stuck waiting for data to stop flowing if it is // a particularly noisy process. - _ = posix.write(exec.read_thread_pipe, "x") catch |err| - log.warn("error writing to read thread quit pipe err={}", .{err}); + _ = posix.write(exec.read_thread_pipe, "x") catch |err| switch (err) { + // BrokenPipe means that our read thread is closed already, + // which is completely fine since that is what we were trying + // to achieve. + error.BrokenPipe => {}, + + else => log.warn( + "error writing to read thread quit pipe err={}", + .{err}, + ), + }; if (comptime builtin.os.tag == .windows) { // Interrupt the blocking read so the thread can see the quit message @@ -1467,6 +1476,13 @@ pub const ReadThread = struct { log.info("read thread got quit signal", .{}); return; } + + // If our pty fd is closed, then we're also done with our + // read thread. + if (pollfds[0].revents & posix.POLL.HUP != 0) { + log.info("pty fd closed, read thread exiting", .{}); + return; + } } } From 9ab2e563bbb626f0c76008d026b92a77f95b6321 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Jan 2025 10:01:19 -0800 Subject: [PATCH 236/238] Update libxev to fix zombie processes on macOS Fixes #4554 xev.Process.wait is documented as being equivalent to calling `waitpid`, i.e. including reaping the process. On Linux, it does this automatically by using pidfd and the `waitid` syscall. On macOS, it wasn't doing this. This commit updates libxev to include a fix that explicitly calls `waitpid` for kqueue. --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 9c00a4704..a8f45e6ea 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .dependencies = .{ // Zig libs .libxev = .{ - .url = "https://github.com/mitchellh/libxev/archive/aceef3d11efacd9d237c91632f930ed13a2834bf.tar.gz", - .hash = "12205b2b47fe61a4cde3a45ee4b9cddee75897739dbc196d6396e117cb1ce28e1ad0", + .url = "https://github.com/mitchellh/libxev/archive/31eed4e337fed7b0149319e5cdbb62b848c24fbd.tar.gz", + .hash = "1220ebf88622c4d502dc59e71347e4d28c47e033f11b59aff774ae5787565c40999c", }, .mach_glfw = .{ .url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz", diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index c687a5a79..66b8eb8b6 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-AvfYl8vLxxsRnf/ERpw5jQIro5rVd98q63hwFsgQOvo=" +"sha256-Bjy31evaKgpRX1mGwAFkai44eiiorTV1gW3VdP9Ins8=" From f73cae07383ffd790a43d2f6b2e77e1236b61f68 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Jan 2025 13:46:52 -0800 Subject: [PATCH 237/238] Ignore SIGPIPE Fixes #5359 The comments explain what's going on. Longer term we should adjust our termio/exec to avoid the SIGPIPE but its still possible (i.e. that thread crashes) to happen so we should be robust to it. --- src/global.zig | 25 +++++++++++++++++++++++++ src/pty.zig | 1 + 2 files changed, 26 insertions(+) diff --git a/src/global.zig b/src/global.zig index c00ce27a4..d5a7af630 100644 --- a/src/global.zig +++ b/src/global.zig @@ -111,6 +111,9 @@ pub const GlobalState = struct { } } + // Setup our signal handlers before logging + initSignals(); + // Output some debug information right away std.log.info("ghostty version={s}", .{build_config.version_string}); std.log.info("ghostty build optimize={s}", .{build_config.mode_string}); @@ -175,6 +178,28 @@ pub const GlobalState = struct { _ = value.deinit(); } } + + fn initSignals() void { + // Only posix systems. + if (comptime builtin.os.tag == .windows) return; + + const p = std.posix; + + var sa: p.Sigaction = .{ + .handler = .{ .handler = p.SIG.IGN }, + .mask = p.empty_sigset, + .flags = 0, + }; + + // We ignore SIGPIPE because it is a common signal we may get + // due to how we implement termio. When a terminal is closed we + // often write to a broken pipe to exit the read thread. This should + // be fixed one day but for now this helps make this a bit more + // robust. + p.sigaction(p.SIG.PIPE, &sa, null) catch |err| { + std.log.warn("failed to ignore SIGPIPE err={}", .{err}); + }; + } }; /// Maintains the Unix resource limits that we set for our process. This diff --git a/src/pty.zig b/src/pty.zig index 1df09d79c..b6dc2e145 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -201,6 +201,7 @@ const PosixPty = struct { try posix.sigaction(posix.SIG.HUP, &sa, null); try posix.sigaction(posix.SIG.ILL, &sa, null); try posix.sigaction(posix.SIG.INT, &sa, null); + try posix.sigaction(posix.SIG.PIPE, &sa, null); try posix.sigaction(posix.SIG.SEGV, &sa, null); try posix.sigaction(posix.SIG.TRAP, &sa, null); try posix.sigaction(posix.SIG.TERM, &sa, null); From a5a73f83522836400a24624c565491f43feebd0d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 24 Jan 2025 14:36:40 -0800 Subject: [PATCH 238/238] macos: autohide dock if quick terminal would conflict with it Fixes #5328 The dock sits above the level of the quick terminal, and the quick terminal frame typical includes the dock. Hence, if the dock is visible and the quick terminal would conflict with it, then part of the terminal is obscured. This commit makes the dock autohide if the quick terminal would conflict with it. The autohide is disabled when the quick terminal is closed. We can't set our window level above the dock, as this would prevent things such as input methods from rendering properly in the quick terminal window. iTerm2 (the only other macOS terminal I know of that supports a dropdown mode) frames the terminal around the dock. I think this looks less aesthetically pleasing and I prefer autohiding the dock instead. We can introduce a setting to change this behavior if desired later. Additionally, this commit introduces a mechanism to safely set app-global presentation options from multiple sources without stepping on each other. --- macos/Ghostty.xcodeproj/project.pbxproj | 8 +++++ .../QuickTerminalController.swift | 32 ++++++++++++++++-- .../QuickTerminal/QuickTerminalPosition.swift | 18 ++++++++++ macos/Sources/Helpers/Dock.swift | 33 +++++++++++++++++++ macos/Sources/Helpers/Fullscreen.swift | 8 ++--- .../Helpers/NSApplication+Extension.swift | 31 +++++++++++++++++ 6 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 macos/Sources/Helpers/Dock.swift create mode 100644 macos/Sources/Helpers/NSApplication+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index efa4a07c9..02c8258cb 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -69,6 +69,8 @@ A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; }; A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; + A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; }; + A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; }; A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; @@ -163,6 +165,8 @@ A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = ""; }; A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; + A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = ""; }; + A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = ""; }; A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -271,6 +275,7 @@ A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, + A5A2A3C92D4445E20033CF96 /* Dock.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, @@ -278,6 +283,7 @@ A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, + A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */, A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, @@ -635,6 +641,7 @@ A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, + A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, @@ -657,6 +664,7 @@ A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, + A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */, A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index bc89022f5..05c8677a7 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -27,6 +27,10 @@ class QuickTerminalController: BaseTerminalController { // The active space when the quick terminal was last shown. private var previousActiveSpace: size_t = 0 + /// This is set to true of the dock was autohid when the terminal animated in. This lets us + /// know if we have to unhide when the terminal is animated out. + private var hidDock: Bool = false + /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig @@ -224,6 +228,18 @@ class QuickTerminalController: BaseTerminalController { animateWindowOut(window: window, to: position) } + private func hideDock() { + guard !hidDock else { return } + NSApp.acquirePresentationOption(.autoHideDock) + hidDock = true + } + + private func unhideDock() { + guard hidDock else { return } + NSApp.releasePresentationOption(.autoHideDock) + hidDock = false + } + private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } @@ -240,6 +256,12 @@ class QuickTerminalController: BaseTerminalController { window.makeKeyAndOrderFront(nil) } + // If our dock position would conflict with our target location then + // we autohide the dock. + if position.conflictsWithDock(on: screen) { + hideDock() + } + // Run the animation that moves our window into the proper place and makes // it visible. NSAnimationContext.runAnimationGroup({ context in @@ -250,8 +272,11 @@ class QuickTerminalController: BaseTerminalController { // There is a very minor delay here so waiting at least an event loop tick // keeps us safe from the view not being on the window. DispatchQueue.main.async { - // If we canceled our animation in we do nothing - guard self.visible else { return } + // If we canceled our animation clean up some state. + guard self.visible else { + self.unhideDock() + return + } // After animating in, we reset the window level to a value that // is above other windows but not as high as popUpMenu. This allows @@ -320,6 +345,9 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { + // If we hid the dock then we unhide it. + unhideDock() + // If the window isn't on our active space then we don't animate, we just // hide it. if !window.isOnActiveSpace { diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index 6ba224a28..7ba124a30 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -118,4 +118,22 @@ enum QuickTerminalPosition : String { return .init(x: screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2, y: screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2) } } + + func conflictsWithDock(on screen: NSScreen) -> Bool { + // Screen must have a dock for it to conflict + guard screen.hasDock else { return false } + + // Get the dock orientation for this screen + guard let orientation = Dock.orientation else { return false } + + // Depending on the orientation of the dock, we conflict if our quick terminal + // would potentially "hit" the dock. In the future we should probably consider + // the frame of the quick terminal. + return switch (orientation) { + case .top: self == .top || self == .left || self == .right + case .bottom: self == .bottom || self == .left || self == .right + case .left: self == .top || self == .bottom + case .right: self == .top || self == .bottom + } + } } diff --git a/macos/Sources/Helpers/Dock.swift b/macos/Sources/Helpers/Dock.swift new file mode 100644 index 000000000..70fb904d9 --- /dev/null +++ b/macos/Sources/Helpers/Dock.swift @@ -0,0 +1,33 @@ +import Cocoa + +// Private API to get Dock location +@_silgen_name("CoreDockGetOrientationAndPinning") +func CoreDockGetOrientationAndPinning( + _ outOrientation: UnsafeMutablePointer, + _ outPinning: UnsafeMutablePointer) + +// Private API to get the current Dock auto-hide state +@_silgen_name("CoreDockGetAutoHideEnabled") +func CoreDockGetAutoHideEnabled() -> Bool + +enum DockOrientation: Int { + case top = 1 + case bottom = 2 + case left = 3 + case right = 4 +} + +class Dock { + /// Returns the orientation of the dock or nil if it can't be determined. + static var orientation: DockOrientation? { + var orientation: Int32 = 0 + var pinning: Int32 = 0 + CoreDockGetOrientationAndPinning(&orientation, &pinning) + return .init(rawValue: Int(orientation)) ?? nil + } + + /// Returns true if the dock has auto-hide enabled. + static var autoHideEnabled: Bool { + return CoreDockGetAutoHideEnabled() + } +} diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index a16f329f8..320eca013 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -307,21 +307,21 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // MARK: Dock private func hideDock() { - NSApp.presentationOptions.insert(.autoHideDock) + NSApp.acquirePresentationOption(.autoHideDock) } private func unhideDock() { - NSApp.presentationOptions.remove(.autoHideDock) + NSApp.releasePresentationOption(.autoHideDock) } // MARK: Menu func hideMenu() { - NSApp.presentationOptions.insert(.autoHideMenuBar) + NSApp.acquirePresentationOption(.autoHideMenuBar) } func unhideMenu() { - NSApp.presentationOptions.remove(.autoHideMenuBar) + NSApp.releasePresentationOption(.autoHideMenuBar) } /// The state that must be saved for non-native fullscreen to exit fullscreen. diff --git a/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/NSApplication+Extension.swift new file mode 100644 index 000000000..0580cd5fc --- /dev/null +++ b/macos/Sources/Helpers/NSApplication+Extension.swift @@ -0,0 +1,31 @@ +import Cocoa + +extension NSApplication { + private static var presentationOptionCounts: [NSApplication.PresentationOptions.Element: UInt] = [:] + + /// Add a presentation option to the application and main a reference count so that and equal + /// number of pops is required to disable it. This is useful so that multiple classes can affect global + /// app state without overriding others. + func acquirePresentationOption(_ option: NSApplication.PresentationOptions.Element) { + Self.presentationOptionCounts[option, default: 0] += 1 + presentationOptions.insert(option) + } + + /// See acquirePresentationOption + func releasePresentationOption(_ option: NSApplication.PresentationOptions.Element) { + guard let value = Self.presentationOptionCounts[option] else { return } + guard value > 0 else { return } + if (value == 1) { + presentationOptions.remove(option) + Self.presentationOptionCounts.removeValue(forKey: option) + } else { + Self.presentationOptionCounts[option] = value - 1 + } + } +} + +extension NSApplication.PresentationOptions.Element: @retroactive Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } +}