From 1e5b02302baecf680ae6f84d3b3985ef88f325f2 Mon Sep 17 00:00:00 2001 From: DeftDawg Date: Mon, 2 Dec 2024 02:47:42 -0500 Subject: [PATCH 001/138] - Add alt keybindings for ctrl+ins = Copy and shift+ins = Paste for non-MacOS systems --- src/config/Config.zig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 7fda17289..93732ca8c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1879,6 +1879,20 @@ 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 a4daabb28afbfcc97afb42a939518861803934bc Mon Sep 17 00:00:00 2001 From: Daniel Patterson Date: Fri, 27 Dec 2024 14:44:33 +0000 Subject: [PATCH 002/138] Rename `goto_split` top/bottom directions to up/down. --- include/ghostty.h | 4 ++-- macos/Sources/App/macOS/AppDelegate.swift | 4 ++-- .../Terminal/BaseTerminalController.swift | 4 ++-- macos/Sources/Ghostty/Ghostty.SplitNode.swift | 4 ++-- macos/Sources/Ghostty/Package.swift | 18 +++++++++--------- src/apprt/action.zig | 4 ++-- src/apprt/gtk/Split.zig | 4 ++-- src/config/Config.zig | 8 ++++---- src/input/Binding.zig | 4 ++-- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 61c3aad32..4b8d409e9 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -375,9 +375,9 @@ typedef enum { typedef enum { GHOSTTY_GOTO_SPLIT_PREVIOUS, GHOSTTY_GOTO_SPLIT_NEXT, - GHOSTTY_GOTO_SPLIT_TOP, + GHOSTTY_GOTO_SPLIT_UP, GHOSTTY_GOTO_SPLIT_LEFT, - GHOSTTY_GOTO_SPLIT_BOTTOM, + GHOSTTY_GOTO_SPLIT_DOWN, GHOSTTY_GOTO_SPLIT_RIGHT, } ghostty_action_goto_split_e; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 7b0ff6fc2..b1af97b25 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -358,8 +358,8 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) - syncMenuShortcut(config, action: "goto_split:top", menuItem: self.menuSelectSplitAbove) - syncMenuShortcut(config, action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) + syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove) + syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow) syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 68c243004..8ce4af2c9 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -536,11 +536,11 @@ class BaseTerminalController: NSWindowController, } @IBAction func splitMoveFocusAbove(_ sender: Any) { - splitMoveFocus(direction: .top) + splitMoveFocus(direction: .up) } @IBAction func splitMoveFocusBelow(_ sender: Any) { - splitMoveFocus(direction: .bottom) + splitMoveFocus(direction: .down) } @IBAction func splitMoveFocusLeft(_ sender: Any) { diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index f863eeada..63128deb4 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -64,10 +64,10 @@ extension Ghostty { let node: SplitNode switch (direction) { - case .previous, .top, .left: + case .previous, .up, .left: node = container.bottomRight - case .next, .bottom, .right: + case .next, .down, .right: node = container.topLeft } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 65f928443..d09100212 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -66,7 +66,7 @@ extension Ghostty { /// An enum that is used for the directions that a split focus event can change. enum SplitFocusDirection { - case previous, next, top, bottom, left, right + case previous, next, up, down, left, right /// Initialize from a Ghostty API enum. static func from(direction: ghostty_action_goto_split_e) -> Self? { @@ -77,11 +77,11 @@ extension Ghostty { case GHOSTTY_GOTO_SPLIT_NEXT: return .next - case GHOSTTY_GOTO_SPLIT_TOP: - return .top + case GHOSTTY_GOTO_SPLIT_UP: + return .up - case GHOSTTY_GOTO_SPLIT_BOTTOM: - return .bottom + case GHOSTTY_GOTO_SPLIT_DOWN: + return .down case GHOSTTY_GOTO_SPLIT_LEFT: return .left @@ -102,11 +102,11 @@ extension Ghostty { case .next: return GHOSTTY_GOTO_SPLIT_NEXT - case .top: - return GHOSTTY_GOTO_SPLIT_TOP + case .up: + return GHOSTTY_GOTO_SPLIT_UP - case .bottom: - return GHOSTTY_GOTO_SPLIT_BOTTOM + case .down: + return GHOSTTY_GOTO_SPLIT_DOWN case .left: return GHOSTTY_GOTO_SPLIT_LEFT diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 527535ffa..de6758d6c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -332,9 +332,9 @@ pub const GotoSplit = enum(c_int) { previous, next, - top, + up, left, - bottom, + down, right, }; diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 83ba04da0..2d428acb2 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -316,7 +316,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap { // This behavior matches the behavior of macOS at the time of writing // this. There is an open issue (#524) to make this depend on the // actual physical location of the current split. - result.put(.top, prev.surface); + result.put(.up, prev.surface); result.put(.left, prev.surface); } } @@ -324,7 +324,7 @@ pub fn directionMap(self: *const Split, from: Side) DirectionMap { if (self.directionNext(from)) |next| { result.put(.next, next.surface); if (!next.wrapped) { - result.put(.bottom, next.surface); + result.put(.down, next.surface); result.put(.right, next.surface); } } diff --git a/src/config/Config.zig b/src/config/Config.zig index a5ba71b25..8e8ec7242 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2202,12 +2202,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ .key = .{ .translated = .up }, .mods = .{ .ctrl = true, .alt = true } }, - .{ .goto_split = .top }, + .{ .goto_split = .up }, ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .down }, .mods = .{ .ctrl = true, .alt = true } }, - .{ .goto_split = .bottom }, + .{ .goto_split = .down }, ); try result.keybind.set.put( alloc, @@ -2465,12 +2465,12 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { try result.keybind.set.put( alloc, .{ .key = .{ .translated = .up }, .mods = .{ .super = true, .alt = true } }, - .{ .goto_split = .top }, + .{ .goto_split = .up }, ); try result.keybind.set.put( alloc, .{ .key = .{ .translated = .down }, .mods = .{ .super = true, .alt = true } }, - .{ .goto_split = .bottom }, + .{ .goto_split = .down }, ); try result.keybind.set.put( alloc, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b451b5ec9..f8cc71d04 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -469,9 +469,9 @@ pub const Action = union(enum) { previous, next, - top, + up, left, - bottom, + down, right, }; From 8cbf8d500377173694801d099bbba97c35186085 Mon Sep 17 00:00:00 2001 From: Daniel Patterson Date: Fri, 27 Dec 2024 14:44:33 +0000 Subject: [PATCH 003/138] Fix broken macOS changes --- macos/Sources/Ghostty/Ghostty.SplitNode.swift | 12 ++++++------ macos/Sources/Ghostty/Ghostty.TerminalSplit.swift | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index 63128deb4..899825d37 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -51,7 +51,7 @@ extension Ghostty { /// Returns the view that would prefer receiving focus in this tree. This is always the /// top-left-most view. This is used when creating a split or closing a split to find the /// next view to send focus to. - func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView { + func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView { let container: Container switch (self) { case .leaf(let leaf): @@ -431,12 +431,12 @@ extension Ghostty { struct Neighbors { var left: SplitNode? var right: SplitNode? - var top: SplitNode? - var bottom: SplitNode? + var up: SplitNode? + var down: SplitNode? /// These are the previous/next nodes. It will certainly be one of the above as well /// but we keep track of these separately because depending on the split direction - /// of the containing node, previous may be left OR top (same for next). + /// of the containing node, previous may be left OR up (same for next). var previous: SplitNode? var next: SplitNode? @@ -448,8 +448,8 @@ extension Ghostty { let map: [SplitFocusDirection : KeyPath] = [ .previous: \.previous, .next: \.next, - .top: \.top, - .bottom: \.bottom, + .up: \.up, + .down: \.down, .left: \.left, .right: \.right, ] diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 272cdabdb..cc3bef149 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -308,7 +308,7 @@ extension Ghostty { resizeIncrements: .init(width: 1, height: 1), resizePublisher: container.resizeEvent, left: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.bottom + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.right : \.down TerminalSplitNested( node: closeableTopLeft(), @@ -318,7 +318,7 @@ extension Ghostty { ]) ) }, right: { - let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.top + let neighborKey: WritableKeyPath = container.direction == .horizontal ? \.left : \.up TerminalSplitNested( node: closeableBottomRight(), From 2bb3353672bde4dc34f4c1af82ff9c303dd18292 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Sun, 29 Dec 2024 22:06:30 +0100 Subject: [PATCH 004/138] add option to strip build regardless of optimization --- build.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/build.zig b/build.zig index c9ab5dd8f..eb34788bf 100644 --- a/build.zig +++ b/build.zig @@ -158,6 +158,12 @@ pub fn build(b: *std.Build) !void { "Build a Position Independent Executable. Default true for system packages.", ) orelse system_package; + const strip = b.option( + bool, + "strip", + "Build the website data for the website.", + ) orelse null; + const conformance = b.option( []const u8, "conformance", @@ -342,7 +348,7 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, - .strip = switch (optimize) { + .strip = strip orelse switch (optimize) { .Debug => false, .ReleaseSafe => false, .ReleaseFast, .ReleaseSmall => true, From 3e11476d3277b35a090f89a3163919e0e2c4ae98 Mon Sep 17 00:00:00 2001 From: Daniel Patterson Date: Sat, 28 Dec 2024 01:02:27 +0000 Subject: [PATCH 005/138] Add "top" and "bottom" aliases --- src/input/Binding.zig | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f8cc71d04..94e8e96de 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -473,6 +473,38 @@ pub const Action = union(enum) { left, down, right, + + pub fn parse(input: []const u8) !SplitFocusDirection { + return std.meta.stringToEnum(SplitFocusDirection, input) orelse { + // For backwards compatibility we map "top" and "bottom" onto the enum + // values "up" and "down" + if (std.mem.eql(u8, input, "top")) { + return .up; + } else if (std.mem.eql(u8, input, "bottom")) { + return .down; + } else { + return Error.InvalidFormat; + } + }; + } + + test "parse" { + const testing = std.testing; + + try testing.expectEqual(.previous, try SplitFocusDirection.parse("previous")); + try testing.expectEqual(.next, try SplitFocusDirection.parse("next")); + + try testing.expectEqual(.up, try SplitFocusDirection.parse("up")); + try testing.expectEqual(.left, try SplitFocusDirection.parse("left")); + try testing.expectEqual(.down, try SplitFocusDirection.parse("down")); + try testing.expectEqual(.right, try SplitFocusDirection.parse("right")); + + try testing.expectEqual(.up, try SplitFocusDirection.parse("top")); + try testing.expectEqual(.down, try SplitFocusDirection.parse("bottom")); + + try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("")); + try testing.expectError(error.InvalidFormat, SplitFocusDirection.parse("green")); + } }; pub const SplitResizeDirection = enum { @@ -515,7 +547,16 @@ pub const Action = union(enum) { comptime field: std.builtin.Type.UnionField, param: []const u8, ) !field.type { - return switch (@typeInfo(field.type)) { + const field_info = @typeInfo(field.type); + + // Fields can provide a custom "parse" function + if (field_info == .Struct or field_info == .Union or field_info == .Enum) { + if (@hasDecl(field.type, "parse") and @typeInfo(@TypeOf(field.type.parse)) == .Fn) { + return field.type.parse(param); + } + } + + return switch (field_info) { .Enum => try parseEnum(field.type, param), .Int => try parseInt(field.type, param), .Float => try parseFloat(field.type, param), From 33b1131a145ae7591877ecaf2550a65d35627c72 Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Mon, 30 Dec 2024 00:26:52 +0100 Subject: [PATCH 006/138] fix: selected text remains after clear_screen action Fixes #3414 --- src/termio/Termio.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index bbcee7906..ab61ae4ca 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -466,6 +466,9 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // for alt screen, we do nothing. if (self.terminal.active_screen == .alternate) return; + // Clear our selection + self.terminal.screen.clearSelection(); + // Clear our scrollback if (history) self.terminal.eraseDisplay(.scrollback, false); From 66681f94e04f61dc3d7d0d8456866f919d60ceea Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 26 Dec 2024 13:37:30 +0300 Subject: [PATCH 007/138] fix: handle intermediate bytes in CSI and ESC sequences This adds missing handling for CSI and ESC commands. Fixes: https://github.com/ghostty-org/ghostty/issues/3122 --- src/terminal/stream.zig | 601 ++++++++++++++++++++++++++-------------- 1 file changed, 399 insertions(+), 202 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 59a8e704d..a4a32e169 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -380,109 +380,172 @@ pub fn Stream(comptime Handler: type) type { fn csiDispatch(self: *Self, input: Parser.Action.CSI) !void { switch (input.final) { // CUU - Cursor Up - 'A', 'k' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor up command: {}", .{input}); - return; + 'A', 'k' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor up command: {}", .{input}); + return; + }, }, - }, - false, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + false, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI A with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUD - Cursor Down - 'B' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor down command: {}", .{input}); - return; + 'B' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor down command: {}", .{input}); + return; + }, }, - }, - false, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + false, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI B with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUF - Cursor Right - 'C' => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor right command: {}", .{input}); - return; + 'C' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorRight")) try self.handler.setCursorRight( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor right command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI C with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUB - Cursor Left - 'D', 'j' => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor left command: {}", .{input}); - return; + 'D', 'j' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorLeft")) try self.handler.setCursorLeft( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor left command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI D with intermediates: {s}", + .{input.intermediates}, + ), + }, // CNL - Cursor Next Line - 'E' => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor up command: {}", .{input}); - return; + 'E' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorDown")) try self.handler.setCursorDown( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor up command: {}", .{input}); + return; + }, }, - }, - true, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + true, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI E with intermediates: {s}", + .{input.intermediates}, + ), + }, // CPL - Cursor Previous Line - 'F' => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid cursor down command: {}", .{input}); - return; + 'F' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorUp")) try self.handler.setCursorUp( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid cursor down command: {}", .{input}); + return; + }, }, - }, - true, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + true, + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI F with intermediates: {s}", + .{input.intermediates}, + ), + }, // HPA - Cursor Horizontal Position Absolute // TODO: test - 'G', '`' => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) { - 0 => try self.handler.setCursorCol(1), - 1 => try self.handler.setCursorCol(input.params[0]), - else => log.warn("invalid HPA command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'G', '`' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorCol")) switch (input.params.len) { + 0 => try self.handler.setCursorCol(1), + 1 => try self.handler.setCursorCol(input.params[0]), + else => log.warn("invalid HPA command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI G with intermediates: {s}", + .{input.intermediates}, + ), + }, // CUP - Set Cursor Position. // TODO: test - 'H', 'f' => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) { - 0 => try self.handler.setCursorPos(1, 1), - 1 => try self.handler.setCursorPos(input.params[0], 1), - 2 => try self.handler.setCursorPos(input.params[0], input.params[1]), - else => log.warn("invalid CUP command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'H', 'f' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorPos")) switch (input.params.len) { + 0 => try self.handler.setCursorPos(1, 1), + 1 => try self.handler.setCursorPos(input.params[0], 1), + 2 => try self.handler.setCursorPos(input.params[0], input.params[1]), + else => log.warn("invalid CUP command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI H with intermediates: {s}", + .{input.intermediates}, + ), + }, // CHT - Cursor Horizontal Tabulation - 'I' => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid horizontal tab command: {}", .{input}); - return; + 'I' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "horizontalTab")) try self.handler.horizontalTab( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid horizontal tab command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI I with intermediates: {s}", + .{input.intermediates}, + ), + }, // Erase Display 'J' => if (@hasDecl(T, "eraseDisplay")) { @@ -540,31 +603,52 @@ pub fn Stream(comptime Handler: type) type { // IL - Insert Lines // TODO: test - 'L' => if (@hasDecl(T, "insertLines")) switch (input.params.len) { - 0 => try self.handler.insertLines(1), - 1 => try self.handler.insertLines(input.params[0]), - else => log.warn("invalid IL command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'L' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "insertLines")) switch (input.params.len) { + 0 => try self.handler.insertLines(1), + 1 => try self.handler.insertLines(input.params[0]), + else => log.warn("invalid IL command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI L with intermediates: {s}", + .{input.intermediates}, + ), + }, // DL - Delete Lines // TODO: test - 'M' => if (@hasDecl(T, "deleteLines")) switch (input.params.len) { - 0 => try self.handler.deleteLines(1), - 1 => try self.handler.deleteLines(input.params[0]), - else => log.warn("invalid DL command: {}", .{input}), - } else log.warn("unimplemented CSI callback: {}", .{input}), + 'M' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "deleteLines")) switch (input.params.len) { + 0 => try self.handler.deleteLines(1), + 1 => try self.handler.deleteLines(input.params[0]), + else => log.warn("invalid DL command: {}", .{input}), + } else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI M with intermediates: {s}", + .{input.intermediates}, + ), + }, // Delete Character (DCH) - 'P' => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid delete characters command: {}", .{input}); - return; + 'P' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "deleteChars")) try self.handler.deleteChars( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid delete characters command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI P with intermediates: {s}", + .{input.intermediates}, + ), + }, // Scroll Up (SD) @@ -587,38 +671,43 @@ pub fn Stream(comptime Handler: type) type { }, // Scroll Down (SD) - 'T' => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid scroll down command: {}", .{input}); - return; + 'T' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "scrollDown")) try self.handler.scrollDown( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid scroll down command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI T with intermediates: {s}", + .{input.intermediates}, + ), + }, // Cursor Tabulation Control - 'W' => { - switch (input.params.len) { - 0 => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{input}), + 'W' => switch (input.intermediates.len) { + 0 => { + if (input.params.len == 0 or + (input.params.len == 1 and input.params[0] == 0)) + { + if (@hasDecl(T, "tabSet")) + try self.handler.tabSet() + else + log.warn("unimplemented tab set callback: {}", .{input}); - 1 => if (input.intermediates.len == 1 and input.intermediates[0] == '?') { - if (input.params[0] == 5) { - if (@hasDecl(T, "tabReset")) - try self.handler.tabReset() - else - log.warn("unimplemented tab reset callback: {}", .{input}); - } else log.warn("invalid cursor tabulation control: {}", .{input}); - } else { - switch (input.params[0]) { - 0 => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{input}), + return; + } + + switch (input.params.len) { + 0 => unreachable, + + 1 => switch (input.params[0]) { + 0 => unreachable, 2 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear(.current) @@ -631,63 +720,103 @@ pub fn Stream(comptime Handler: type) type { log.warn("unimplemented tab clear callback: {}", .{input}), else => {}, - } - }, + }, - else => {}, - } + else => {}, + } - log.warn("invalid cursor tabulation control: {}", .{input}); - return; + log.warn("invalid cursor tabulation control: {}", .{input}); + return; + }, + + 1 => if (input.intermediates[0] == '?' and input.params[0] == 5) { + if (@hasDecl(T, "tabReset")) + try self.handler.tabReset() + else + log.warn("unimplemented tab reset callback: {}", .{input}); + } else log.warn("invalid cursor tabulation control: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI W with intermediates: {s}", + .{input.intermediates}, + ), }, // Erase Characters (ECH) - 'X' => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid erase characters command: {}", .{input}); - return; + 'X' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "eraseChars")) try self.handler.eraseChars( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid erase characters command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI X with intermediates: {s}", + .{input.intermediates}, + ), + }, // CHT - Cursor Horizontal Tabulation Back - 'Z' => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid horizontal tab back command: {}", .{input}); - return; + 'Z' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "horizontalTabBack")) try self.handler.horizontalTabBack( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid horizontal tab back command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI Z with intermediates: {s}", + .{input.intermediates}, + ), + }, // HPR - Cursor Horizontal Position Relative - 'a' => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid HPR command: {}", .{input}); - return; + 'a' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorColRelative")) try self.handler.setCursorColRelative( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid HPR command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI a with intermediates: {s}", + .{input.intermediates}, + ), + }, // Repeat Previous Char (REP) - 'b' => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid print repeat command: {}", .{input}); - return; + 'b' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "printRepeat")) try self.handler.printRepeat( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid print repeat command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI b with intermediates: {s}", + .{input.intermediates}, + ), + }, // c - Device Attributes (DA1) 'c' => if (@hasDecl(T, "deviceAttributes")) { @@ -708,40 +837,61 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented CSI callback: {}", .{input}), // VPA - Cursor Vertical Position Absolute - 'd' => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid VPA command: {}", .{input}); - return; + 'd' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorRow")) try self.handler.setCursorRow( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid VPA command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI d with intermediates: {s}", + .{input.intermediates}, + ), + }, // VPR - Cursor Vertical Position Relative - 'e' => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( - switch (input.params.len) { - 0 => 1, - 1 => input.params[0], - else => { - log.warn("invalid VPR command: {}", .{input}); - return; + 'e' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "setCursorRowRelative")) try self.handler.setCursorRowRelative( + switch (input.params.len) { + 0 => 1, + 1 => input.params[0], + else => { + log.warn("invalid VPR command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI e with intermediates: {s}", + .{input.intermediates}, + ), + }, // TBC - Tab Clear // TODO: test - 'g' => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( - switch (input.params.len) { - 1 => @enumFromInt(input.params[0]), - else => { - log.warn("invalid tab clear command: {}", .{input}); - return; + 'g' => switch (input.intermediates.len) { + 0 => if (@hasDecl(T, "tabClear")) try self.handler.tabClear( + switch (input.params.len) { + 1 => @enumFromInt(input.params[0]), + else => { + log.warn("invalid tab clear command: {}", .{input}); + return; + }, }, - }, - ) else log.warn("unimplemented CSI callback: {}", .{input}), + ) else log.warn("unimplemented CSI callback: {}", .{input}), + + else => log.warn( + "ignoring unimplemented CSI g with intermediates: {s}", + .{input.intermediates}, + ), + }, // SM - Set Mode 'h' => if (@hasDecl(T, "setMode")) mode: { @@ -1564,10 +1714,13 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented ESC callback: {}", .{action}), // HTS - Horizontal Tab Set - 'H' => if (@hasDecl(T, "tabSet")) - try self.handler.tabSet() - else - log.warn("unimplemented tab set callback: {}", .{action}), + 'H' => if (@hasDecl(T, "tabSet")) switch (action.intermediates.len) { + 0 => try self.handler.tabSet(), + else => { + log.warn("invalid tab set command: {}", .{action}); + return; + }, + } else log.warn("unimplemented tab set callback: {}", .{action}), // RI - Reverse Index 'M' => if (@hasDecl(T, "reverseIndex")) switch (action.intermediates.len) { @@ -1597,17 +1750,17 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented invokeCharset: {}", .{action}), // SPA - Start of Guarded Area - 'V' => if (@hasDecl(T, "setProtectedMode")) { + 'V' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) { try self.handler.setProtectedMode(ansi.ProtectedMode.iso); } else log.warn("unimplemented ESC callback: {}", .{action}), // EPA - End of Guarded Area - 'W' => if (@hasDecl(T, "setProtectedMode")) { + 'W' => if (@hasDecl(T, "setProtectedMode") and action.intermediates.len == 0) { try self.handler.setProtectedMode(ansi.ProtectedMode.off); } else log.warn("unimplemented ESC callback: {}", .{action}), // DECID - 'Z' => if (@hasDecl(T, "deviceAttributes")) { + 'Z' => if (@hasDecl(T, "deviceAttributes") and action.intermediates.len == 0) { try self.handler.deviceAttributes(.primary, &.{}); } else log.warn("unimplemented ESC callback: {}", .{action}), @@ -1666,12 +1819,12 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented invokeCharset: {}", .{action}), // Set application keypad mode - '=' => if (@hasDecl(T, "setMode")) { + '=' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) { try self.handler.setMode(.keypad_keys, true); } else log.warn("unimplemented setMode: {}", .{action}), // Reset application keypad mode - '>' => if (@hasDecl(T, "setMode")) { + '>' => if (@hasDecl(T, "setMode") and action.intermediates.len == 0) { try self.handler.setMode(.keypad_keys, false); } else log.warn("unimplemented setMode: {}", .{action}), @@ -1753,6 +1906,10 @@ test "stream: cursor right (CUF)" { s.handler.amount = 0; try s.nextSlice("\x1B[5;4C"); try testing.expectEqual(@as(u16, 0), s.handler.amount); + + s.handler.amount = 0; + try s.nextSlice("\x1b[?3C"); + try testing.expectEqual(@as(u16, 0), s.handler.amount); } test "stream: dec set mode (SM) and reset mode (RM)" { @@ -1770,6 +1927,10 @@ test "stream: dec set mode (SM) and reset mode (RM)" { try s.nextSlice("\x1B[?6l"); try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); + + s.handler.mode = @as(modes.Mode, @enumFromInt(1)); + try s.nextSlice("\x1B[6 h"); + try testing.expectEqual(@as(modes.Mode, @enumFromInt(1)), s.handler.mode); } test "stream: ansi set mode (SM) and reset mode (RM)" { @@ -1788,6 +1949,10 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { try s.nextSlice("\x1B[4l"); try testing.expect(s.handler.mode == null); + + s.handler.mode = null; + try s.nextSlice("\x1B[>5h"); + try testing.expect(s.handler.mode == null); } test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { @@ -1937,6 +2102,12 @@ test "stream: DECED, DECSED" { try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } + { + // Invalid and ignored by the handler + for ("\x1B[>0J") |c| try s.next(c); + try testing.expectEqual(csi.EraseDisplay.scrollback, s.handler.mode.?); + try testing.expect(!s.handler.protected.?); + } } test "stream: DECEL, DECSEL" { @@ -1997,6 +2168,12 @@ test "stream: DECEL, DECSEL" { try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); try testing.expect(!s.handler.protected.?); } + { + // Invalid and ignored by the handler + for ("\x1B[<1K") |c| try s.next(c); + try testing.expectEqual(csi.EraseLine.complete, s.handler.mode.?); + try testing.expect(!s.handler.protected.?); + } } test "stream: DECSCUSR" { @@ -2014,6 +2191,10 @@ test "stream: DECSCUSR" { try s.nextSlice("\x1B[1 q"); try testing.expect(s.handler.style.? == .blinking_block); + + // Invalid and ignored by the handler + try s.nextSlice("\x1B[?0 q"); + try testing.expect(s.handler.style.? == .blinking_block); } test "stream: DECSCUSR without space" { @@ -2054,6 +2235,10 @@ test "stream: XTSHIFTESCAPE" { try s.nextSlice("\x1B[>1s"); try testing.expect(s.handler.escape.? == true); + + // Invalid and ignored by the handler + try s.nextSlice("\x1B[1 s"); + try testing.expect(s.handler.escape.? == true); } test "stream: change window title with invalid utf-8" { @@ -2374,6 +2559,14 @@ test "stream CSI W tab set" { s.handler.called = false; try s.nextSlice("\x1b[0W"); try testing.expect(s.handler.called); + + s.handler.called = false; + try s.nextSlice("\x1b[>W"); + try testing.expect(!s.handler.called); + + s.handler.called = false; + try s.nextSlice("\x1b[99W"); + try testing.expect(!s.handler.called); } test "stream CSI ? W reset tab stops" { @@ -2392,4 +2585,8 @@ test "stream CSI ? W reset tab stops" { try s.nextSlice("\x1b[?5W"); try testing.expect(s.handler.reset); + + // Invalid and ignored by the handler + try s.nextSlice("\x1b[?1;2;3W"); + try testing.expect(s.handler.reset); } From c87e3e98a3414b105cc89762927b15e070311ad6 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Mon, 30 Dec 2024 18:37:59 +0100 Subject: [PATCH 008/138] give strip option a proper description --- build.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.zig b/build.zig index eb34788bf..2f34a6667 100644 --- a/build.zig +++ b/build.zig @@ -161,7 +161,7 @@ pub fn build(b: *std.Build) !void { const strip = b.option( bool, "strip", - "Build the website data for the website.", + "Strip the final executable. Default true for fast and small releases", ) orelse null; const conformance = b.option( From 3971c460d132aa955d72f21f3868c16fdfe758f3 Mon Sep 17 00:00:00 2001 From: Hugo Gouveia Date: Mon, 30 Dec 2024 23:05:21 -0300 Subject: [PATCH 009/138] doc: add background-opacity needs restart --- src/config/Config.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index eb3d28d95..d84f326a6 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -560,6 +560,7 @@ palette: Palette = .{}, /// On macOS, background opacity is disabled when the terminal enters native /// fullscreen. This is because the background becomes gray and it can cause /// widgets to show through which isn't generally desirable. +/// On macOs, this setting cannot be reloaded and needs a restart @"background-opacity": f64 = 1.0, /// A positive value enables blurring of the background when background-opacity From 2d174f9bff96cc65c55f6c0a6b27f14e655b7a08 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 30 Dec 2024 20:49:45 -0500 Subject: [PATCH 010/138] font: allow non-boolean font feature settings + much more flexible syntax and lenient parser + allows comma-separated list as a single config value This allows, e.g. `cv01 = 2` to select the second variant of `cv01`. --- src/config/Config.zig | 21 +- src/font/shape.zig | 9 +- src/font/shaper/coretext.zig | 142 +++++-------- src/font/shaper/feature.zig | 390 +++++++++++++++++++++++++++++++++++ src/font/shaper/harfbuzz.zig | 50 ++--- 5 files changed, 489 insertions(+), 123 deletions(-) create mode 100644 src/font/shaper/feature.zig diff --git a/src/config/Config.zig b/src/config/Config.zig index a2f71c0c0..da7c1fee0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -147,23 +147,28 @@ const c = @cImport({ /// By default, synthetic styles are enabled. @"font-synthetic-style": FontSyntheticStyle = .{}, -/// Apply a font feature. This can be repeated multiple times to enable multiple -/// font features. You can NOT set multiple font features with a single value -/// (yet). +/// Apply a font feature. To enable multiple font features you can repeat +/// this multiple times or use a comma-separated list of feature settings. +/// +/// The syntax for feature settings is as follows, where `feat` is a feature: +/// +/// * Enable features with e.g. `feat`, `+feat`, `feat on`, `feat=1`. +/// * Disabled features with e.g. `-feat`, `feat off`, `feat=0`. +/// * Set a feature value with e.g. `feat=2`, `feat = 3`, `feat 4`. +/// * Feature names may be wrapped in quotes, meaning this config should be +/// syntactically compatible with the `font-feature-settings` CSS property. +/// +/// The syntax is fairly loose, but invalid settings will be silently ignored. /// /// The font feature will apply to all fonts rendered by Ghostty. A future /// enhancement will allow targeting specific faces. /// -/// A valid value is the name of a feature. Prefix the feature with a `-` to -/// explicitly disable it. Example: `ss20` or `-ss20`. -/// /// To disable programming ligatures, use `-calt` since this is the typical /// feature name for programming ligatures. To look into what font features /// your font has and what they do, use a font inspection tool such as /// [fontdrop.info](https://fontdrop.info). /// -/// To generally disable most ligatures, use `-calt`, `-liga`, and `-dlig` (as -/// separate repetitive entries in your config). +/// To generally disable most ligatures, use `-calt, -liga, -dlig`. @"font-feature": RepeatableString = .{}, /// Font size in points. This value can be a non-integer and the nearest integer diff --git a/src/font/shape.zig b/src/font/shape.zig index 3721c63a6..cc67fc7a0 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -1,6 +1,7 @@ const builtin = @import("builtin"); const options = @import("main.zig").options; const run = @import("shaper/run.zig"); +const feature = @import("shaper/feature.zig"); pub const noop = @import("shaper/noop.zig"); pub const harfbuzz = @import("shaper/harfbuzz.zig"); pub const coretext = @import("shaper/coretext.zig"); @@ -8,6 +9,9 @@ pub const web_canvas = @import("shaper/web_canvas.zig"); pub const Cache = @import("shaper/Cache.zig"); pub const TextRun = run.TextRun; pub const RunIterator = run.RunIterator; +pub const Feature = feature.Feature; +pub const FeatureList = feature.FeatureList; +pub const default_features = feature.default_features; /// Shaper implementation for our compile options. pub const Shaper = switch (options.backend) { @@ -49,10 +53,7 @@ pub const Cell = struct { /// Options for shapers. pub const Options = struct { - /// Font features to use when shaping. These can be in the following - /// formats: "-feat" "+feat" "feat". A "-"-prefix is used to disable - /// a feature and the others are used to enable a feature. If a feature - /// isn't supported or is invalid, it will be ignored. + /// Font features to use when shaping. /// /// Note: eventually, this will move to font.Face probably as we may /// want to support per-face feature configuration. For now, we only diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index dbc9809e3..e084a68c9 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -7,6 +7,9 @@ const trace = @import("tracy").trace; const font = @import("../main.zig"); const os = @import("../../os/main.zig"); const terminal = @import("../../terminal/main.zig"); +const Feature = font.shape.Feature; +const FeatureList = font.shape.FeatureList; +const default_features = font.shape.default_features; const Face = font.Face; const Collection = font.Collection; const DeferredFace = font.DeferredFace; @@ -40,9 +43,10 @@ pub const Shaper = struct { /// The string used for shaping the current run. run_state: RunState, - /// The font features we want to use. The hardcoded features are always - /// set first. - features: FeatureList, + /// CoreFoundation Dictionary which represents our font feature settings. + features: *macos.foundation.Dictionary, + /// A version of the features dictionary with the default features excluded. + features_no_default: *macos.foundation.Dictionary, /// The shared memory used for shaping results. cell_buf: CellBuf, @@ -100,51 +104,17 @@ pub const Shaper = struct { } }; - /// List of font features, parsed into the data structures used by - /// the CoreText API. The CoreText API requires a pretty annoying wrapping - /// to setup font features: - /// - /// - The key parsed into a CFString - /// - The value parsed into a CFNumber - /// - The key and value are then put into a CFDictionary - /// - The CFDictionary is then put into a CFArray - /// - The CFArray is then put into another CFDictionary - /// - The CFDictionary is then passed to the CoreText API to create - /// a new font with the features set. - /// - /// This structure handles up to the point that we have a CFArray of - /// CFDictionary objects representing the font features and provides - /// functions for creating the dictionary to init the font. - const FeatureList = struct { - list: *macos.foundation.MutableArray, + /// Create a CoreFoundation Dictionary suitable for + /// settings the font features of a CoreText font. + fn makeFeaturesDict(feats: []const Feature) !*macos.foundation.Dictionary { + const list = try macos.foundation.MutableArray.create(); + errdefer list.release(); - pub fn init() !FeatureList { - var list = try macos.foundation.MutableArray.create(); - errdefer list.release(); - return .{ .list = list }; - } - - pub fn deinit(self: FeatureList) void { - self.list.release(); - } - - /// Append the given feature to the list. The feature syntax is - /// the same as Harfbuzz: "feat" enables it and "-feat" disables it. - pub fn append(self: *FeatureList, name_raw: []const u8) !void { - // If the name is `-name` then we are disabling the feature, - // otherwise we are enabling it, so we need to parse this out. - const name = if (name_raw[0] == '-') name_raw[1..] else name_raw; - const dict = try featureDict(name, name_raw[0] != '-'); - defer dict.release(); - self.list.appendValue(macos.foundation.Dictionary, dict); - } - - /// Create the dictionary for the given feature and value. - fn featureDict(name: []const u8, v: bool) !*macos.foundation.Dictionary { - const value_num: c_int = @intFromBool(v); + for (feats) |feat| { + const value_num: c_int = @intCast(feat.value); // Keys can only be ASCII. - var key = try macos.foundation.String.createWithBytes(name, .ascii, false); + var key = try macos.foundation.String.createWithBytes(&feat.tag, .ascii, false); defer key.release(); var value = try macos.foundation.Number.create(.int, &value_num); defer value.release(); @@ -154,50 +124,44 @@ pub const Shaper = struct { macos.text.c.kCTFontOpenTypeFeatureTag, macos.text.c.kCTFontOpenTypeFeatureValue, }, - &[_]?*const anyopaque{ - key, - value, - }, + &[_]?*const anyopaque{ key, value }, ); - errdefer dict.release(); - return dict; + defer dict.release(); + + list.appendValue(macos.foundation.Dictionary, dict); } - /// Returns the dictionary to use with the font API to set the - /// features. This should be released by the caller. - pub fn attrsDict( - self: FeatureList, - omit_defaults: bool, - ) !*macos.foundation.Dictionary { - // Get our feature list. If we're omitting defaults then we - // slice off the hardcoded features. - const list = if (!omit_defaults) self.list else list: { - const list = try macos.foundation.MutableArray.createCopy(@ptrCast(self.list)); - for (hardcoded_features) |_| list.removeValue(0); - break :list list; - }; - defer if (omit_defaults) list.release(); + var dict = try macos.foundation.Dictionary.create( + &[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute}, + &[_]?*const anyopaque{list}, + ); + errdefer dict.release(); - var dict = try macos.foundation.Dictionary.create( - &[_]?*const anyopaque{macos.text.c.kCTFontFeatureSettingsAttribute}, - &[_]?*const anyopaque{list}, - ); - errdefer dict.release(); - return dict; - } - }; - - // These features are hardcoded to always be on by default. Users - // can turn them off by setting the features to "-liga" for example. - const hardcoded_features = [_][]const u8{ "dlig", "liga" }; + return dict; + } /// The cell_buf argument is the buffer to use for storing shaped results. /// This should be at least the number of columns in the terminal. pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { - var feats = try FeatureList.init(); - errdefer feats.deinit(); - for (hardcoded_features) |name| try feats.append(name); - for (opts.features) |name| try feats.append(name); + var feature_list: FeatureList = .{}; + defer feature_list.deinit(alloc); + for (opts.features) |feature_str| { + try feature_list.appendFromString(alloc, feature_str); + } + + // We need to construct two attrs dictionaries for font features; + // one without the default features included, and one with them. + const feats = feature_list.features.items; + const feats_df = try alloc.alloc(Feature, feats.len + default_features.len); + defer alloc.free(feats_df); + + @memcpy(feats_df[0..default_features.len], &default_features); + @memcpy(feats_df[default_features.len..], feats); + + const features = try makeFeaturesDict(feats_df); + errdefer features.release(); + const features_no_default = try makeFeaturesDict(feats); + errdefer features_no_default.release(); var run_state = RunState.init(); errdefer run_state.deinit(alloc); @@ -242,7 +206,8 @@ pub const Shaper = struct { .alloc = alloc, .cell_buf = .{}, .run_state = run_state, - .features = feats, + .features = features, + .features_no_default = features_no_default, .writing_direction = writing_direction, .cached_fonts = .{}, .cached_font_grid = 0, @@ -255,7 +220,8 @@ pub const Shaper = struct { pub fn deinit(self: *Shaper) void { self.cell_buf.deinit(self.alloc); self.run_state.deinit(self.alloc); - self.features.deinit(); + self.features.release(); + self.features_no_default.release(); self.writing_direction.release(); { @@ -509,8 +475,8 @@ pub const Shaper = struct { // If we have it, return the cached attr dict. if (self.cached_fonts.items[index_int]) |cached| return cached; - // Features dictionary, font descriptor, font - try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3); + // Font descriptor, font + try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 2); const run_font = font: { // The CoreText shaper relies on CoreText and CoreText claims @@ -533,8 +499,10 @@ pub const Shaper = struct { const face = try grid.resolver.collection.getFace(index); const original = face.font; - const attrs = try self.features.attrsDict(face.quirks_disable_default_font_features); - self.cf_release_pool.appendAssumeCapacity(attrs); + const attrs = if (face.quirks_disable_default_font_features) + self.features_no_default + else + self.features; const desc = try macos.text.FontDescriptor.createWithAttributes(attrs); self.cf_release_pool.appendAssumeCapacity(desc); diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig new file mode 100644 index 000000000..8e70d51da --- /dev/null +++ b/src/font/shaper/feature.zig @@ -0,0 +1,390 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const log = std.log.scoped(.font_shaper); + +/// Represents an OpenType font feature setting, which consists of a tag and +/// a numeric parameter >= 0. Most features are boolean, so only parameters +/// of 0 and 1 make sense for them, but some (e.g. 'cv01'..'cv99') can take +/// parameters to choose between multiple variants of a given character or +/// characters. +/// +/// Ref: +/// - https://learn.microsoft.com/en-us/typography/opentype/spec/chapter2#features-and-lookups +/// - https://harfbuzz.github.io/shaping-opentype-features.html +pub const Feature = struct { + tag: [4]u8, + value: u32, + + pub fn fromString(str: []const u8) ?Feature { + var fbs = std.io.fixedBufferStream(str); + const reader = fbs.reader(); + return Feature.fromReader(reader); + } + + /// Parse a single font feature setting from a std.io.Reader, with a version + /// of the syntax of HarfBuzz's font feature strings. Stops at end of stream + /// or when a ',' is encountered. + /// + /// This parsing aims to be as error-tolerant as possible while avoiding any + /// assumptions in ambiguous scenarios. When invalid syntax is encountered, + /// the reader is advanced to the next boundary (end-of-stream or ',') so + /// that further features may be read. + /// + /// Ref: https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string + pub fn fromReader(reader: anytype) ?Feature { + var tag: [4]u8 = undefined; + var value: ?u32 = null; + + // TODO: when we move to Zig 0.14 this can be replaced with a + // labeled switch continue pattern rather than this loop. + var state: union(enum) { + /// Initial state. + start: void, + /// Parsing the tag, data is index. + tag: u2, + /// In the space between the tag and the value. + space: void, + /// Parsing an integer parameter directly in to `value`. + int: void, + /// Parsing a boolean keyword parameter ("on"/"off"). + bool: void, + /// Encountered an unrecoverable syntax error, advancing to boundary. + err: void, + /// Done parsing feature. + done: void, + } = .start; + while (true) { + // If we hit the end of the stream we just pretend it's a comma. + const byte = reader.readByte() catch ','; + switch (state) { + // If we're done then we skip whitespace until we see a ','. + .done => switch (byte) { + ' ', '\t' => continue, + ',' => break, + // If we see something other than whitespace or a ',' + // then this is an error since the intent is unclear. + else => { + state = .err; + continue; + }, + }, + + // If we're fast-forwarding from an error we just wanna + // stop at the first boundary and ignore all other bytes. + .err => if (byte == ',') return null, + + .start => switch (byte) { + // Ignore leading whitespace. + ' ', '\t' => continue, + // Empty feature string. + ',' => return null, + // '+' prefix to explicitly enable feature. + '+' => { + value = 1; + state = .{ .tag = 0 }; + continue; + }, + // '-' prefix to explicitly disable feature. + '-' => { + value = 0; + state = .{ .tag = 0 }; + continue; + }, + // Quote mark introducing a tag. + '"', '\'' => { + state = .{ .tag = 0 }; + continue; + }, + // First letter of tag. + else => { + tag[0] = byte; + state = .{ .tag = 1 }; + continue; + }, + }, + + .tag => |*i| switch (byte) { + // If the tag is interrupted by a comma it's invalid. + ',' => return null, + // Ignore quote marks. + '"', '\'' => continue, + // A prefix of '+' or '-' + // In all other cases we add the byte to our tag. + else => { + tag[i.*] = byte; + if (i.* == 3) { + state = .space; + continue; + } + i.* += 1; + }, + }, + + .space => switch (byte) { + ' ', '\t' => continue, + // Ignore quote marks since we might have a + // closing quote from the tag still ahead. + '"', '\'' => continue, + // Allow an '=' (which we can safely ignore) + // only if we don't already have a value due + // to a '+' or '-' prefix. + '=' => if (value != null) { + state = .err; + continue; + }, + ',' => { + // Specifying only a tag turns a feature on. + if (value == null) value = 1; + break; + }, + '0'...'9' => { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) { + state = .err; + continue; + } + value = byte - '0'; + state = .int; + continue; + }, + 'o', 'O' => { + // If we already have value because of a + // '+' or '-' prefix then this is an error. + if (value != null) { + state = .err; + continue; + } + state = .bool; + continue; + }, + else => { + state = .err; + continue; + }, + }, + + .int => switch (byte) { + ',' => break, + '0'...'9' => { + // If our value gets too big while + // parsing we consider it an error. + value = std.math.mul(u32, value.?, 10) catch { + state = .err; + continue; + }; + value.? += byte - '0'; + }, + else => { + state = .err; + continue; + }, + }, + + .bool => switch (byte) { + ',' => return null, + 'n', 'N' => { + // "ofn" + if (value != null) { + assert(value == 0); + state = .err; + continue; + } + value = 1; + state = .done; + continue; + }, + 'f', 'F' => { + // To make sure we consume two 'f's. + if (value == null) { + value = 0; + } else { + assert(value == 0); + state = .done; + continue; + } + }, + else => { + state = .err; + continue; + }, + }, + } + } + + assert(value != null); + + return .{ + .tag = tag, + .value = value.?, + }; + } + + /// Serialize this feature to the provided buffer. + /// The string that this produces should be valid to parse. + pub fn toString(self: *const Feature, buf: []u8) !void { + var fbs = std.io.fixedBufferStream(buf); + try self.format("", .{}, fbs.writer()); + } + + /// Formatter for logging + pub fn format( + self: Feature, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = layout; + _ = opts; + if (self.value <= 1) { + // Format boolean options as "+tag" for on and "-tag" for off. + try std.fmt.format(writer, "{c}{s}", .{ + "-+"[self.value], + self.tag, + }); + } else { + // Format non-boolean tags as "tag=value". + try std.fmt.format(writer, "{s}={d}", .{ + self.tag, + self.value, + }); + } + } +}; + +/// A list of font feature settings (see `Feature` for more documentation). +pub const FeatureList = struct { + features: std.ArrayListUnmanaged(Feature) = .{}, + + pub fn deinit(self: *FeatureList, alloc: Allocator) void { + self.features.deinit(alloc); + } + + /// Parse a comma separated list of features. + /// See `Feature.fromReader` for more docs. + pub fn fromString(alloc: Allocator, str: []const u8) !FeatureList { + var self: FeatureList = .{}; + try self.appendFromString(alloc, str); + return self; + } + + /// Append features to this list from a string with a comma separated list. + /// See `Feature.fromReader` for more docs. + pub fn appendFromString( + self: *FeatureList, + alloc: Allocator, + str: []const u8, + ) !void { + var fbs = std.io.fixedBufferStream(str); + const reader = fbs.reader(); + while (fbs.pos < fbs.buffer.len) { + const i = fbs.pos; + if (Feature.fromReader(reader)) |feature| { + try self.features.append(alloc, feature); + } else log.warn( + "failed to parse font feature setting: \"{s}\"", + .{fbs.buffer[i..fbs.pos]}, + ); + } + } + + /// Formatter for logging + pub fn format( + self: FeatureList, + comptime layout: []const u8, + opts: std.fmt.FormatOptions, + writer: anytype, + ) !void { + for (self.features.items, 0..) |feature, i| { + try feature.format(layout, opts, writer); + if (i != std.features.items.len - 1) try writer.writeAll(", "); + } + if (self.value <= 1) { + // Format boolean options as "+tag" for on and "-tag" for off. + try std.fmt.format(writer, "{c}{s}", .{ + "-+"[self.value], + self.tag, + }); + } else { + // Format non-boolean tags as "tag=value". + try std.fmt.format(writer, "{s}={d}", .{ + self.tag, + self.value, + }); + } + } +}; + +/// These features are hardcoded to always be on by default. Users +/// can turn them off by setting the features to "-liga" for example. +pub const default_features = [_]Feature{ + .{ .tag = "dlig".*, .value = 1 }, + .{ .tag = "liga".*, .value = 1 }, +}; + +test "Feature.fromString" { + const testing = std.testing; + + // This is not *complete* coverage of every possible + // combination of syntax, but it covers quite a few. + + // Boolean settings (on) + const kern_on = Feature{ .tag = "kern".*, .value = 1 }; + try testing.expectEqual(kern_on, Feature.fromString("kern")); + try testing.expectEqual(kern_on, Feature.fromString("kern, ")); + try testing.expectEqual(kern_on, Feature.fromString("kern on")); + try testing.expectEqual(kern_on, Feature.fromString("kern on, ")); + try testing.expectEqual(kern_on, Feature.fromString("+kern")); + try testing.expectEqual(kern_on, Feature.fromString("+kern, ")); + try testing.expectEqual(kern_on, Feature.fromString("\"kern\" = 1")); + try testing.expectEqual(kern_on, Feature.fromString("\"kern\" = 1, ")); + + // Boolean settings (off) + const kern_off = Feature{ .tag = "kern".*, .value = 0 }; + try testing.expectEqual(kern_off, Feature.fromString("kern off")); + try testing.expectEqual(kern_off, Feature.fromString("kern off, ")); + try testing.expectEqual(kern_off, Feature.fromString("-'kern'")); + try testing.expectEqual(kern_off, Feature.fromString("-'kern', ")); + try testing.expectEqual(kern_off, Feature.fromString("\"kern\" = 0")); + try testing.expectEqual(kern_off, Feature.fromString("\"kern\" = 0, ")); + + // Non-boolean settings + const aalt_2 = Feature{ .tag = "aalt".*, .value = 2 }; + try testing.expectEqual(aalt_2, Feature.fromString("aalt=2")); + try testing.expectEqual(aalt_2, Feature.fromString("aalt=2, ")); + try testing.expectEqual(aalt_2, Feature.fromString("'aalt' 2")); + try testing.expectEqual(aalt_2, Feature.fromString("'aalt' 2, ")); + + // Various ambiguous/error cases which should be null + try testing.expectEqual(null, Feature.fromString("aalt=2x")); // bad number + try testing.expectEqual(null, Feature.fromString("toolong")); // tag too long + try testing.expectEqual(null, Feature.fromString("sht")); // tag too short + try testing.expectEqual(null, Feature.fromString("-kern 1")); // redundant/conflicting + try testing.expectEqual(null, Feature.fromString("-kern on")); // redundant/conflicting + try testing.expectEqual(null, Feature.fromString("aalt=o,")); // bad keyword + try testing.expectEqual(null, Feature.fromString("aalt=ofn,")); // bad keyword +} + +test "FeatureList.fromString" { + const testing = std.testing; + + const str = + " kern, kern on , +kern, \"kern\" = 1," ++ // Boolean settings (on) + "kern off, -'kern' , \"kern\"=0," ++ // Boolean settings (off) + "aalt=2, 'aalt'\t2," ++ // Non-boolean settings + "aalt=2x, toolong, sht, -kern 1, -kern on, aalt=o, aalt=ofn," ++ // Invalid cases + "last"; // To ensure final element is included correctly. + var feats = try FeatureList.fromString(testing.allocator, str); + defer feats.deinit(testing.allocator); + try testing.expectEqualSlices( + Feature, + &(.{Feature{ .tag = "kern".*, .value = 1 }} ** 4 ++ + .{Feature{ .tag = "kern".*, .value = 0 }} ** 3 ++ + .{Feature{ .tag = "aalt".*, .value = 2 }} ** 2 ++ + .{Feature{ .tag = "last".*, .value = 1 }}), + feats.features.items, + ); +} diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index ccb422f20..97292b9b0 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -3,6 +3,10 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const harfbuzz = @import("harfbuzz"); const font = @import("../main.zig"); +const terminal = @import("../../terminal/main.zig"); +const Feature = font.shape.Feature; +const FeatureList = font.shape.FeatureList; +const default_features = font.shape.default_features; const Face = font.Face; const Collection = font.Collection; const DeferredFace = font.DeferredFace; @@ -10,7 +14,6 @@ const Library = font.Library; const SharedGrid = font.SharedGrid; const Style = font.Style; const Presentation = font.Presentation; -const terminal = @import("../../terminal/main.zig"); const log = std.log.scoped(.font_shaper); @@ -27,38 +30,37 @@ pub const Shaper = struct { cell_buf: CellBuf, /// The features to use for shaping. - hb_feats: FeatureList, + hb_feats: []harfbuzz.Feature, const CellBuf = std.ArrayListUnmanaged(font.shape.Cell); - const FeatureList = std.ArrayListUnmanaged(harfbuzz.Feature); - - // These features are hardcoded to always be on by default. Users - // can turn them off by setting the features to "-liga" for example. - const hardcoded_features = [_][]const u8{ "dlig", "liga" }; /// The cell_buf argument is the buffer to use for storing shaped results. /// This should be at least the number of columns in the terminal. pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper { - // Parse all the features we want to use. We use - var hb_feats = hb_feats: { - var list = try FeatureList.initCapacity(alloc, opts.features.len + hardcoded_features.len); - errdefer list.deinit(alloc); - - for (hardcoded_features) |name| { - if (harfbuzz.Feature.fromString(name)) |feat| { - try list.append(alloc, feat); - } else log.warn("failed to parse font feature: {s}", .{name}); + // Parse all the features we want to use. + const hb_feats = hb_feats: { + var feature_list: FeatureList = .{}; + defer feature_list.deinit(alloc); + try feature_list.features.appendSlice(alloc, &default_features); + for (opts.features) |feature_str| { + try feature_list.appendFromString(alloc, feature_str); } - for (opts.features) |name| { - if (harfbuzz.Feature.fromString(name)) |feat| { - try list.append(alloc, feat); - } else log.warn("failed to parse font feature: {s}", .{name}); + var list = try alloc.alloc(harfbuzz.Feature, feature_list.features.items.len); + errdefer alloc.free(list); + + for (feature_list.features.items, 0..) |feature, i| { + list[i] = .{ + .tag = std.mem.nativeToBig(u32, @bitCast(feature.tag)), + .value = feature.value, + .start = harfbuzz.c.HB_FEATURE_GLOBAL_START, + .end = harfbuzz.c.HB_FEATURE_GLOBAL_END, + }; } break :hb_feats list; }; - errdefer hb_feats.deinit(alloc); + errdefer alloc.free(hb_feats); return Shaper{ .alloc = alloc, @@ -71,7 +73,7 @@ pub const Shaper = struct { pub fn deinit(self: *Shaper) void { self.hb_buf.destroy(); self.cell_buf.deinit(self.alloc); - self.hb_feats.deinit(self.alloc); + self.alloc.free(self.hb_feats); } pub fn endFrame(self: *const Shaper) void { @@ -125,10 +127,10 @@ pub const Shaper = struct { // If we are disabling default font features we just offset // our features by the hardcoded items because always // add those at the beginning. - break :i hardcoded_features.len; + break :i default_features.len; }; - harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats.items[i..]); + harfbuzz.shape(face.hb_font, self.hb_buf, self.hb_feats[i..]); } // If our buffer is empty, we short-circuit the rest of the work From 061a730dd37188c6b6aca7eac66f8d0a9bc108b3 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Tue, 31 Dec 2024 12:06:45 +0100 Subject: [PATCH 011/138] move strip switch behind option --- build.zig | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/build.zig b/build.zig index 2f34a6667..dffaa8882 100644 --- a/build.zig +++ b/build.zig @@ -162,7 +162,11 @@ pub fn build(b: *std.Build) !void { bool, "strip", "Strip the final executable. Default true for fast and small releases", - ) orelse null; + ) orelse switch (optimize) { + .Debug => false, + .ReleaseSafe => false, + .ReleaseFast, .ReleaseSmall => true, + }; const conformance = b.option( []const u8, @@ -348,11 +352,7 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, - .strip = strip orelse switch (optimize) { - .Debug => false, - .ReleaseSafe => false, - .ReleaseFast, .ReleaseSmall => true, - }, + .strip = strip, }) else null; // Exe From 6a8b31571bd3a56a0406250bd4e746b0fa5602e5 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Tue, 31 Dec 2024 12:09:11 +0100 Subject: [PATCH 012/138] reuse strip option for libraries --- build.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.zig b/build.zig index dffaa8882..05750589b 100644 --- a/build.zig +++ b/build.zig @@ -690,6 +690,7 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main_c.zig"), .optimize = optimize, .target = target, + .strip = strip, }); _ = try addDeps(b, lib, config); @@ -707,6 +708,7 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main_c.zig"), .optimize = optimize, .target = target, + .strip = strip, }); _ = try addDeps(b, lib, config); From e20ec96fee4ba0b2ee863147310fc180e0ae66b9 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 31 Dec 2024 14:50:37 +0200 Subject: [PATCH 013/138] config: improve documentation for color configuration --- src/config/Config.zig | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index a2f71c0c0..8a449f71d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -398,14 +398,17 @@ const c = @cImport({ theme: ?Theme = null, /// Background color for the window. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, /// Foreground color for the window. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// The foreground and background color for selection. If this is not set, then /// the selection color is just the inverted window background and foreground /// (note: not to be confused with the cell bg/fg). +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"selection-foreground": ?Color = null, @"selection-background": ?Color = null, @@ -431,15 +434,16 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, @"minimum-contrast": f64 = 1, /// Color palette for the 256 color form that many terminal applications use. -/// The syntax of this configuration is `N=HEXCODE` where `N` is 0 to 255 (for -/// the 256 colors in the terminal color table) and `HEXCODE` is a typical RGB -/// color code such as `#AABBCC`. +/// The syntax of this configuration is `N=COLOR` where `N` is 0 to 255 (for +/// the 256 colors in the terminal color table) and `COLOR` is a typical RGB +/// color code such as `#AABBCC` or `AABBCC`, or a named X11 color. /// -/// For definitions on all the codes [see this cheat -/// sheet](https://www.ditig.com/256-colors-cheat-sheet). +/// For definitions on the color indices and what they canonically map to, +/// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet). palette: Palette = .{}, /// The color of the cursor. If this is not set, a default will be chosen. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"cursor-color": ?Color = null, /// Swap the foreground and background colors of the cell under the cursor. This @@ -493,6 +497,7 @@ palette: Palette = .{}, /// The color of the text under the cursor. If this is not set, a default will /// be chosen. +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"cursor-text": ?Color = null, /// Enables the ability to move the cursor at prompts by using `alt+click` on @@ -586,6 +591,8 @@ palette: Palette = .{}, /// that rectangle and can be used to carefully control the dimming effect. /// /// This will default to the background color. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"unfocused-split-fill": ?Color = null, /// The command to run, usually a shell. If this is not an absolute path, it'll @@ -1152,11 +1159,15 @@ keybind: Keybinds = .{}, /// Background color for the window titlebar. This only takes effect if /// window-theme is set to ghostty. Currently only supported in the GTK app /// runtime. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"window-titlebar-background": ?Color = null, /// Foreground color for the window titlebar. This only takes effect if /// window-theme is set to ghostty. Currently only supported in the GTK app /// runtime. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"window-titlebar-foreground": ?Color = null, /// This controls when resize overlays are shown. Resize overlays are a @@ -1772,21 +1783,19 @@ keybind: Keybinds = .{}, /// The color of the ghost in the macOS app icon. /// -/// The format of the color is the same as the `background` configuration; -/// see that for more information. -/// /// Note: This configuration is required when `macos-icon` is set to /// `custom-style`. /// /// This only has an effect when `macos-icon` is set to `custom-style`. +/// +/// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. @"macos-icon-ghost-color": ?Color = null, /// The color of the screen in the macOS app icon. /// /// The screen is a gradient so you can specify multiple colors that -/// make up the gradient. Colors should be separated by commas. The -/// format of the color is the same as the `background` configuration; -/// see that for more information. +/// make up the gradient. Comma-separated colors may be specified as +/// as either hex (`#RRGGBB` or `RRGGBB`) or as named X11 colors. /// /// Note: This configuration is required when `macos-icon` is set to /// `custom-style`. From 27c3382a6a6b1d87262fe2f21d68242093c6950d Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Tue, 31 Dec 2024 15:53:10 +0100 Subject: [PATCH 014/138] Implement loading custom css in the GTK app --- src/apprt/gtk/App.zig | 55 ++++++++++++++++++++++++++++++++++++++++++- src/config/Config.zig | 8 +++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c10ba7993..ee663485f 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -81,6 +81,9 @@ transient_cgroup_base: ?[]const u8 = null, /// CSS Provider for any styles based on ghostty configuration values css_provider: *c.GtkCssProvider, +/// Providers for loading custom stylesheets defined by user +custom_css_providers: std.ArrayList(*c.GtkCssProvider), + /// The timer used to quit the application after the last window is closed. quit_timer: union(enum) { off: void, @@ -425,6 +428,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // our "activate" call above will open a window. .running = c.g_application_get_is_remote(gapp) == 0, .css_provider = css_provider, + .custom_css_providers = std.ArrayList(*c.GtkCssProvider).init(core_app.alloc), }; } @@ -441,6 +445,11 @@ pub fn terminate(self: *App) void { if (self.context_menu) |context_menu| c.g_object_unref(context_menu); if (self.transient_cgroup_base) |path| self.core_app.alloc.free(path); + for (self.custom_css_providers.items) |provider| { + c.g_object_unref(provider); + } + self.custom_css_providers.deinit(); + self.config.deinit(); } @@ -892,7 +901,7 @@ fn syncConfigChanges(self: *App) !void { try self.updateConfigErrors(); try self.syncActionAccelerators(); - // Load our runtime CSS. If this fails then our window is just stuck + // Load our runtime and custom CSS. If this fails then our window is just stuck // with the old CSS but we don't want to fail the entire sync operation. self.loadRuntimeCss() catch |err| switch (err) { error.OutOfMemory => log.warn( @@ -900,6 +909,12 @@ fn syncConfigChanges(self: *App) !void { .{}, ), }; + self.loadCustomCss() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "out of memory loading custom CSS, no custom CSS applied", + .{}, + ), + }; } /// This should be called whenever the configuration changes to update @@ -1040,6 +1055,44 @@ fn loadRuntimeCss( ); } +fn loadCustomCss(self: *App) Allocator.Error!void { + const display = c.gdk_display_get_default(); + + // unload the previously loaded style providers + for (self.custom_css_providers.items) |provider| { + c.gtk_style_context_remove_provider_for_display( + display, + @ptrCast(provider), + ); + c.g_object_unref(provider); + } + self.custom_css_providers.clearRetainingCapacity(); + + for (self.config.@"gtk-custom-css".value.items) |p| { + const path, const optional = switch (p) { + .optional => |path| .{ path, true }, + .required => |path| .{ path, false }, + }; + std.fs.accessAbsolute(path, .{}) catch |err| { + if (err != error.FileNotFound or !optional) { + log.err("error opening gtk-custom-css file {s}: {}", .{ path, err }); + } + continue; + }; + + const provider = c.gtk_css_provider_new(); + + c.gtk_style_context_add_provider_for_display( + display, + @ptrCast(provider), + c.GTK_STYLE_PROVIDER_PRIORITY_USER, + ); + c.gtk_css_provider_load_from_path(provider, path); + + try self.custom_css_providers.append(provider); + } +} + /// Called by CoreApp to wake up the event loop. pub fn wakeup(self: App) void { _ = self; diff --git a/src/config/Config.zig b/src/config/Config.zig index 91c07cc78..a136017d1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1925,6 +1925,14 @@ keybind: Keybinds = .{}, /// Adwaita support. @"gtk-adwaita": bool = true, +/// Custom CSS files to be loaded. +/// +/// This configuration can be repeated multiple times to load multiple files. +/// Prepend a ? character to the file path to suppress errors if the file does +/// not exist. If you want to include a file that begins with a literal ? +/// character, surround the file path in double quotes ("). +@"gtk-custom-css": RepeatablePath = .{}, + /// If `true` (default), applications running in the terminal can show desktop /// notifications using certain escape sequences such as OSC 9 or OSC 777. @"desktop-notifications": bool = true, From 973467b1caaa3c77ad6fea2362892f1e61b34b1a Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Tue, 31 Dec 2024 20:08:12 +0100 Subject: [PATCH 015/138] code review: - use ArrayListUnmanaged - read the stylesheet file using zig api - use proper css_provider_load_ function depending on the GTK version --- src/apprt/gtk/App.zig | 56 +++++++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index ee663485f..60c029330 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -82,7 +82,7 @@ transient_cgroup_base: ?[]const u8 = null, css_provider: *c.GtkCssProvider, /// Providers for loading custom stylesheets defined by user -custom_css_providers: std.ArrayList(*c.GtkCssProvider), +custom_css_providers: std.ArrayListUnmanaged(*c.GtkCssProvider), /// The timer used to quit the application after the last window is closed. quit_timer: union(enum) { @@ -428,7 +428,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // our "activate" call above will open a window. .running = c.g_application_get_is_remote(gapp) == 0, .css_provider = css_provider, - .custom_css_providers = std.ArrayList(*c.GtkCssProvider).init(core_app.alloc), + .custom_css_providers = .{}, }; } @@ -448,7 +448,7 @@ pub fn terminate(self: *App) void { for (self.custom_css_providers.items) |provider| { c.g_object_unref(provider); } - self.custom_css_providers.deinit(); + self.custom_css_providers.deinit(self.core_app.alloc); self.config.deinit(); } @@ -909,11 +909,8 @@ fn syncConfigChanges(self: *App) !void { .{}, ), }; - self.loadCustomCss() catch |err| switch (err) { - error.OutOfMemory => log.warn( - "out of memory loading custom CSS, no custom CSS applied", - .{}, - ), + self.loadCustomCss() catch |err| { + log.warn("Failed to load custom CSS, no custom CSS applied, err={}", .{err}); }; } @@ -1048,14 +1045,10 @@ fn loadRuntimeCss( } // Clears any previously loaded CSS from this provider - c.gtk_css_provider_load_from_data( - self.css_provider, - buf.items.ptr, - @intCast(buf.items.len), - ); + loadCssProviderFromData(self.css_provider, buf.items); } -fn loadCustomCss(self: *App) Allocator.Error!void { +fn loadCustomCss(self: *App) !void { const display = c.gdk_display_get_default(); // unload the previously loaded style providers @@ -1073,23 +1066,50 @@ fn loadCustomCss(self: *App) Allocator.Error!void { .optional => |path| .{ path, true }, .required => |path| .{ path, false }, }; - std.fs.accessAbsolute(path, .{}) catch |err| { + const file = std.fs.openFileAbsolute(path, .{}) catch |err| { if (err != error.FileNotFound or !optional) { log.err("error opening gtk-custom-css file {s}: {}", .{ path, err }); } continue; }; + defer file.close(); + + log.info("loading gtk-custom-css path={s}", .{path}); + var buf_reader = std.io.bufferedReader(file.reader()); + const contents = try buf_reader.reader().readAllAlloc( + self.core_app.alloc, + 5 * 1024 * 1024 // 5MB + ); + defer self.core_app.alloc.free(contents); const provider = c.gtk_css_provider_new(); - c.gtk_style_context_add_provider_for_display( display, @ptrCast(provider), c.GTK_STYLE_PROVIDER_PRIORITY_USER, ); - c.gtk_css_provider_load_from_path(provider, path); - try self.custom_css_providers.append(provider); + loadCssProviderFromData(provider, contents); + + try self.custom_css_providers.append(self.core_app.alloc, provider); + } +} + +fn loadCssProviderFromData(provider: *c.GtkCssProvider, data: []const u8) void { + if (version.atLeast(4, 12, 0)) { + const g_bytes = c.g_bytes_new(data.ptr, data.len); + defer c.g_bytes_unref(g_bytes); + + c.gtk_css_provider_load_from_bytes( + provider, + g_bytes + ); + } else { + c.gtk_css_provider_load_from_data( + provider, + data.ptr, + @intCast(data.len), + ); } } From 5957e1101c9e51131ca0042beee07e5c67644c06 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Tue, 31 Dec 2024 17:54:09 +0100 Subject: [PATCH 016/138] don't build harfbuzz when system integration is enabled --- pkg/harfbuzz/build.zig | 82 +++++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index b5c5c3c1e..983ec9ffc 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -14,7 +14,6 @@ pub fn build(b: *std.Build) !void { .@"enable-libpng" = true, }); const macos = b.dependency("macos", .{ .target = target, .optimize = optimize }); - const upstream = b.dependency("harfbuzz", .{}); const module = b.addModule("harfbuzz", .{ .root_source_file = b.path("main.zig"), @@ -26,6 +25,62 @@ pub fn build(b: *std.Build) !void { }, }); + // 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, + }; + + const test_exe = b.addTest(.{ + .name = "test", + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + + { + 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")); + const tests_run = b.addRunArtifact(test_exe); + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&tests_run.step); + } + + if (b.systemIntegrationOption("harfbuzz", .{})) { + module.linkSystemLibrary("harfbuzz", dynamic_link_opts); + test_exe.linkSystemLibrary2("harfbuzz", dynamic_link_opts); + } else { + const lib = try buildLib(b, module, .{ + .target = target, + .optimize = optimize, + + .coretext_enabled = coretext_enabled, + .freetype_enabled = freetype_enabled, + + .dynamic_link_opts = dynamic_link_opts, + }); + + test_exe.linkLibrary(lib); + } +} + +pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const target = options.target; + const optimize = options.optimize; + + const coretext_enabled = options.coretext_enabled; + const freetype_enabled = options.freetype_enabled; + + const freetype = b.dependency("freetype", .{ + .target = target, + .optimize = optimize, + .@"enable-libpng" = true, + }); + + const upstream = b.dependency("harfbuzz", .{}); const lib = b.addStaticLibrary(.{ .name = "harfbuzz", .target = target, @@ -41,13 +96,7 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, module); } - // 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, - }; + const dynamic_link_opts = options.dynamic_link_opts; var flags = std.ArrayList([]const u8).init(b.allocator); defer flags.deinit(); @@ -102,20 +151,5 @@ pub fn build(b: *std.Build) !void { b.installArtifact(lib); - { - const test_exe = b.addTest(.{ - .name = "test", - .root_source_file = b.path("main.zig"), - .target = target, - .optimize = optimize, - }); - test_exe.linkLibrary(lib); - - 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")); - const tests_run = b.addRunArtifact(test_exe); - const test_step = b.step("test", "Run tests"); - test_step.dependOn(&tests_run.step); - } + return lib; } From 4ccd56484949a3c9b20e49f3f5b9a920b4fecb9e Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Tue, 31 Dec 2024 21:21:44 +0100 Subject: [PATCH 017/138] code review: - initialize custom_css_providers using a default value - remove usage of buffered reader - document maximum file size - handle exceptions explicitly --- src/apprt/gtk/App.zig | 40 ++++++++++++++++++++++++++++++---------- src/config/Config.zig | 1 + 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 60c029330..ee1b8d41b 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -82,7 +82,7 @@ transient_cgroup_base: ?[]const u8 = null, css_provider: *c.GtkCssProvider, /// Providers for loading custom stylesheets defined by user -custom_css_providers: std.ArrayListUnmanaged(*c.GtkCssProvider), +custom_css_providers: std.ArrayListUnmanaged(*c.GtkCssProvider) = .{}, /// The timer used to quit the application after the last window is closed. quit_timer: union(enum) { @@ -428,7 +428,6 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // our "activate" call above will open a window. .running = c.g_application_get_is_remote(gapp) == 0, .css_provider = css_provider, - .custom_css_providers = .{}, }; } @@ -909,8 +908,33 @@ fn syncConfigChanges(self: *App) !void { .{}, ), }; - self.loadCustomCss() catch |err| { - log.warn("Failed to load custom CSS, no custom CSS applied, err={}", .{err}); + self.loadCustomCss() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "out of memory loading custom CSS, no custom CSS applied", + .{}, + ), + error.StreamTooLong => log.warn( + "failed to load custom CSS, no custom CSS applied - encountered stream too long error: {}", + .{err}, + ), + error.Unexpected => log.warn( + "failed to load custom CSS, no custom CSS applied - encountered unexpected error: {}", + .{err}, + ), + std.fs.File.Reader.Error.InputOutput, + std.fs.File.Reader.Error.SystemResources, + std.fs.File.Reader.Error.IsDir, + std.fs.File.Reader.Error.OperationAborted, + std.fs.File.Reader.Error.BrokenPipe, + std.fs.File.Reader.Error.ConnectionResetByPeer, + std.fs.File.Reader.Error.ConnectionTimedOut, + std.fs.File.Reader.Error.NotOpenForReading, + std.fs.File.Reader.Error.SocketNotConnected, + std.fs.File.Reader.Error.WouldBlock, + std.fs.File.Reader.Error.AccessDenied => log.warn( + "failed to load custom CSS, no custom CSS applied - encountered error while reading file: {}", + .{err}, + ), }; } @@ -1075,8 +1099,7 @@ fn loadCustomCss(self: *App) !void { defer file.close(); log.info("loading gtk-custom-css path={s}", .{path}); - var buf_reader = std.io.bufferedReader(file.reader()); - const contents = try buf_reader.reader().readAllAlloc( + const contents = try file.reader().readAllAlloc( self.core_app.alloc, 5 * 1024 * 1024 // 5MB ); @@ -1100,10 +1123,7 @@ fn loadCssProviderFromData(provider: *c.GtkCssProvider, data: []const u8) void { const g_bytes = c.g_bytes_new(data.ptr, data.len); defer c.g_bytes_unref(g_bytes); - c.gtk_css_provider_load_from_bytes( - provider, - g_bytes - ); + c.gtk_css_provider_load_from_bytes(provider, g_bytes); } else { c.gtk_css_provider_load_from_data( provider, diff --git a/src/config/Config.zig b/src/config/Config.zig index a136017d1..70b85790c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1931,6 +1931,7 @@ keybind: Keybinds = .{}, /// Prepend a ? character to the file path to suppress errors if the file does /// not exist. If you want to include a file that begins with a literal ? /// character, surround the file path in double quotes ("). +/// The file size limit for a single stylesheet is 5MiB. @"gtk-custom-css": RepeatablePath = .{}, /// If `true` (default), applications running in the terminal can show desktop From 9ce4e36aa26549159ed089d4d77ddabad5d41bc4 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Wed, 1 Jan 2025 08:56:58 +0100 Subject: [PATCH 018/138] code review - revert explicit error handling --- src/apprt/gtk/App.zig | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index ee1b8d41b..4677f0da7 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -908,33 +908,8 @@ fn syncConfigChanges(self: *App) !void { .{}, ), }; - self.loadCustomCss() catch |err| switch (err) { - error.OutOfMemory => log.warn( - "out of memory loading custom CSS, no custom CSS applied", - .{}, - ), - error.StreamTooLong => log.warn( - "failed to load custom CSS, no custom CSS applied - encountered stream too long error: {}", - .{err}, - ), - error.Unexpected => log.warn( - "failed to load custom CSS, no custom CSS applied - encountered unexpected error: {}", - .{err}, - ), - std.fs.File.Reader.Error.InputOutput, - std.fs.File.Reader.Error.SystemResources, - std.fs.File.Reader.Error.IsDir, - std.fs.File.Reader.Error.OperationAborted, - std.fs.File.Reader.Error.BrokenPipe, - std.fs.File.Reader.Error.ConnectionResetByPeer, - std.fs.File.Reader.Error.ConnectionTimedOut, - std.fs.File.Reader.Error.NotOpenForReading, - std.fs.File.Reader.Error.SocketNotConnected, - std.fs.File.Reader.Error.WouldBlock, - std.fs.File.Reader.Error.AccessDenied => log.warn( - "failed to load custom CSS, no custom CSS applied - encountered error while reading file: {}", - .{err}, - ), + self.loadCustomCss() catch |err| { + log.warn("Failed to load custom CSS, no custom CSS applied, err={}", .{err}); }; } From d203075a2e45953dca3a434e698da32b52f80d39 Mon Sep 17 00:00:00 2001 From: Morgan Date: Wed, 1 Jan 2025 17:14:03 +0900 Subject: [PATCH 019/138] Fix for #1938, add GDK_DEBUG=gl-no-fractional --- src/apprt/gtk/App.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c10ba7993..9cb479646 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -108,7 +108,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // 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"); + _ = internal_os.setenv("GDK_DEBUG", "opengl,gl-no-fractional"); } else if (version.atLeast(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 @@ -123,7 +123,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // - "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"); + _ = 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 From b1a197ef57450838fc8320475dacee1e6ac019eb Mon Sep 17 00:00:00 2001 From: moritz-john <70295002+moritz-john@users.noreply.github.com> Date: Wed, 1 Jan 2025 09:46:06 +0100 Subject: [PATCH 020/138] Fix: typo in comment Fix: 'becauseonce' -> 'because once' --- src/font/discovery.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/discovery.zig b/src/font/discovery.zig index e73ea626f..071407d92 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -551,7 +551,7 @@ pub const CoreText = struct { for (0..result.len) |i| { result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i); - // We need to retain becauseonce the list is freed it will + // We need to retain because once the list is freed it will // release all its members. result[i].retain(); } From 180db3c77b1ad44b0fb3037246a6da835a3fd363 Mon Sep 17 00:00:00 2001 From: arthsmn Date: Wed, 1 Jan 2025 12:30:32 -0300 Subject: [PATCH 021/138] Adding default.nix for flake-compat --- default.nix | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 default.nix diff --git a/default.nix b/default.nix new file mode 100644 index 000000000..d6bf5743f --- /dev/null +++ b/default.nix @@ -0,0 +1,13 @@ +(import ( + let + lock = builtins.fromJSON (builtins.readFile ./flake.lock); + nodeName = lock.nodes.root.inputs.flake-compat; + in + fetchTarball { + url = + lock.nodes.${nodeName}.locked.url + or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz"; + sha256 = lock.nodes.${nodeName}.locked.narHash; + } +) {src = ./.;}) +.defaultNix From 120fffa42c0ce5e055ec7388f79a3a3968f6050f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20L=C3=B6vdahl?= Date: Wed, 1 Jan 2025 19:05:51 +0200 Subject: [PATCH 022/138] Fix typo in `freetype-load-flags` documentation --- 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..4ee8e7228 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -320,7 +320,7 @@ const c = @cImport({ /// FreeType load flags to enable. The format of this is a list of flags to /// enable separated by commas. If you prefix a flag with `no-` then it is -/// disabled. If you omit a flag, it's default value is used, so you must +/// disabled. If you omit a flag, its default value is used, so you must /// explicitly disable flags you don't want. You can also use `true` or `false` /// to turn all flags on or off. /// From cdf51b1304388c869440ce3be4b1977ed04f057e Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Wed, 1 Jan 2025 18:28:26 +0100 Subject: [PATCH 023/138] Try to create parent directory when writing default config --- src/config/Config.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 91c07cc78..35593f3f3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2695,6 +2695,9 @@ pub fn loadOptionalFile( fn writeConfigTemplate(path: []const u8) !void { log.info("creating template config file: path={s}", .{path}); + if (std.fs.path.dirname(path)) |dir_path| { + try std.fs.makeDirAbsolute(dir_path); + } const file = try std.fs.createFileAbsolute(path, .{}); defer file.close(); try std.fmt.format( From 57ace2d0b8c01931eb98ef7be1a75fc2c762c9f0 Mon Sep 17 00:00:00 2001 From: Tim Visher Date: Mon, 30 Dec 2024 14:52:50 -0500 Subject: [PATCH 024/138] Clarify CLI vs. Keybind Actions documentation https://github.com/ghostty-org/ghostty/discussions/4107#discussioncomment-11699228 --- src/cli/help.zig | 2 +- src/cli/list_actions.zig | 5 +++-- src/main_ghostty.zig | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/cli/help.zig b/src/cli/help.zig index e9e449550..daadc37cc 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -63,7 +63,7 @@ pub fn run(alloc: Allocator) !u8 { try stdout.writeAll( \\ \\Specify `+ --help` to see the help for a specific action, - \\where `` is one of actions listed below. + \\where `` is one of actions listed above. \\ ); diff --git a/src/cli/list_actions.zig b/src/cli/list_actions.zig index 8dbadc65a..65b9dcdad 100644 --- a/src/cli/list_actions.zig +++ b/src/cli/list_actions.zig @@ -20,8 +20,9 @@ pub const Options = struct { } }; -/// The `list-actions` command is used to list all the available keybind actions -/// for Ghostty. +/// The `list-actions` command is used to list all the available keybind +/// 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. pub fn run(alloc: Allocator) !u8 { diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index b3df80538..9efe8d9b0 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -49,7 +49,8 @@ pub fn main() !MainReturn { error.InvalidAction => try stderr.print( "Error: unknown CLI action specified. CLI actions are specified with\n" ++ - "the '+' character.\n", + "the '+' character.\n\n" ++ + "All valid CLI actions can be listed with `ghostty +help`\n", .{}, ), From 96fd18f9945202516062dee2b9a0ee100cc3202d Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Wed, 1 Jan 2025 20:43:52 +0100 Subject: [PATCH 025/138] Don't install 1024x1024 icons for Flatpak Per the Flatpak spec (https://docs.flatpak.org/en/latest/conventions.html#application-icons) the maximum icon size is 512x512. Trying to build a Flatpak with an icon larger than this will fail. To solve this, installing the icon is skipped when building with -Dflatpak=true. --- build.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build.zig b/build.zig index 3ba8b6b64..414b66804 100644 --- a/build.zig +++ b/build.zig @@ -669,7 +669,12 @@ pub fn build(b: *std.Build) !void { 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"); - b.installFile("images/icons/icon_1024.png", "share/icons/hicolor/1024x1024/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"); From 9ea0aa49348ca653f7236636e6370abdd1b4767c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 1 Jan 2025 14:31:15 -0600 Subject: [PATCH 026/138] core: if we change RLIMIT_NOFILE, reset it when executing commands --- src/Command.zig | 5 +++++ src/global.zig | 9 ++++++++- src/os/file.zig | 24 +++++++++++++++++------- src/os/main.zig | 2 ++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/Command.zig b/src/Command.zig index 82b48fa18..2801def36 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -18,6 +18,7 @@ const Command = @This(); const std = @import("std"); const builtin = @import("builtin"); +const global_state = &@import("global.zig").state; const internal_os = @import("os/main.zig"); const windows = internal_os.windows; const TempDir = internal_os.TempDir; @@ -178,6 +179,10 @@ fn startPosix(self: *Command, arena: Allocator) !void { // If the user requested a pre exec callback, call it now. if (self.pre_exec) |f| f(self); + if (global_state.rlimits.nofile) |lim| { + internal_os.restoreMaxFiles(lim); + } + // Finally, replace our process. _ = posix.execveZ(pathZ, argsZ, envp) catch null; diff --git a/src/global.zig b/src/global.zig index 7e43a9184..b3f35fde5 100644 --- a/src/global.zig +++ b/src/global.zig @@ -27,6 +27,12 @@ pub const GlobalState = struct { alloc: std.mem.Allocator, action: ?cli.Action, logging: Logging, + /// If we change any resource limits for our own purposes, we save the + /// old limits so that they can be restored before we execute any child + /// processes. + rlimits: struct { + nofile: ?internal_os.rlimit = null, + } = .{}, /// The app resources directory, equivalent to zig-out/share when we build /// from source. This is null if we can't detect it. @@ -56,6 +62,7 @@ pub const GlobalState = struct { .alloc = undefined, .action = null, .logging = .{ .stderr = {} }, + .rlimits = .{}, .resources_dir = null, }; errdefer self.deinit(); @@ -124,7 +131,7 @@ pub const GlobalState = struct { std.log.info("libxev backend={}", .{xev.backend}); // First things first, we fix our file descriptors - internal_os.fixMaxFiles(); + self.rlimits.nofile = internal_os.fixMaxFiles(); // Initialize our crash reporting. crash.init(self.alloc) catch |err| { diff --git a/src/os/file.zig b/src/os/file.zig index e0ec2f52c..d89c05e3e 100644 --- a/src/os/file.zig +++ b/src/os/file.zig @@ -4,23 +4,27 @@ const posix = std.posix; const log = std.log.scoped(.os); +pub const rlimit = if (@hasDecl(posix.system, "rlimit")) posix.rlimit else struct {}; + /// This maximizes the number of file descriptors we can have open. We /// need to do this because each window consumes at least a handful of fds. /// This is extracted from the Zig compiler source code. -pub fn fixMaxFiles() void { - if (!@hasDecl(posix.system, "rlimit")) return; +pub fn fixMaxFiles() ?rlimit { + if (!@hasDecl(posix.system, "rlimit")) return null; - var lim = posix.getrlimit(.NOFILE) catch { + const old = posix.getrlimit(.NOFILE) catch { log.warn("failed to query file handle limit, may limit max windows", .{}); - return; // Oh well; we tried. + return null; // Oh well; we tried. }; // If we're already at the max, we're done. - if (lim.cur >= lim.max) { - log.debug("file handle limit already maximized value={}", .{lim.cur}); - return; + if (old.cur >= old.max) { + log.debug("file handle limit already maximized value={}", .{old.cur}); + return old; } + var lim = old; + // Do a binary search for the limit. var min: posix.rlim_t = lim.cur; var max: posix.rlim_t = 1 << 20; @@ -41,6 +45,12 @@ pub fn fixMaxFiles() void { } log.debug("file handle limit raised value={}", .{lim.cur}); + return old; +} + +pub fn restoreMaxFiles(lim: rlimit) void { + if (!@hasDecl(posix.system, "rlimit")) return; + posix.setrlimit(.NOFILE, lim) catch {}; } /// Return the recommended path for temporary files. diff --git a/src/os/main.zig b/src/os/main.zig index 98e57b4fc..af885aa5c 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -32,7 +32,9 @@ pub const getenv = env.getenv; pub const setenv = env.setenv; pub const unsetenv = env.unsetenv; pub const launchedFromDesktop = desktop.launchedFromDesktop; +pub const rlimit = file.rlimit; pub const fixMaxFiles = file.fixMaxFiles; +pub const restoreMaxFiles = file.restoreMaxFiles; pub const allocTmpDir = file.allocTmpDir; pub const freeTmpDir = file.freeTmpDir; pub const isFlatpak = flatpak.isFlatpak; From 46097617b4e7b03256a96ee39d80856998eae8ac Mon Sep 17 00:00:00 2001 From: Ethan Conneely Date: Thu, 2 Jan 2025 00:18:05 +0000 Subject: [PATCH 027/138] copy_to_clipboard return false if not performed --- src/Surface.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index c359efd8a..3677c04e5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3889,7 +3889,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool log.err("error setting clipboard string err={}", .{err}); return true; }; + + return true; } + + return false; }, .paste_from_clipboard => try self.startClipboardRequest( From 98aa046a4dbd1a27953a9587eca078783d0db15f Mon Sep 17 00:00:00 2001 From: Ethan Conneely Date: Thu, 2 Jan 2025 00:18:39 +0000 Subject: [PATCH 028/138] Add performable flag --- src/input/Binding.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b2c03b674..3380896b4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -36,6 +36,10 @@ pub const Flags = packed struct { /// and not just while Ghostty is focused. This may not work on all platforms. /// See the keybind config documentation for more information. global: bool = false, + + /// True if this binding can be performed then the action is + /// triggered otherwise it acts as if it doesn't exist. + performable: bool = false, }; /// Full binding parser. The binding parser is implemented as an iterator @@ -90,6 +94,9 @@ pub const Parser = struct { } else if (std.mem.eql(u8, prefix, "unconsumed")) { if (!flags.consumed) return Error.InvalidFormat; flags.consumed = false; + } else if (std.mem.eql(u8, prefix, "performable")) { + if (flags.performable) return Error.InvalidFormat; + flags.performable = true; } else { // If we don't recognize the prefix then we're done. // There are trigger-specific prefixes like "physical:" so From f38d1585e8c739aae1e0e1513118295671aafb2f Mon Sep 17 00:00:00 2001 From: Ethan Conneely Date: Thu, 2 Jan 2025 01:14:47 +0000 Subject: [PATCH 029/138] Do nothing if action not performed with flag --- src/Surface.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 3677c04e5..ce70d56ff 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1941,10 +1941,16 @@ fn maybeHandleBinding( return .closed; } + // If we have the performable flag and the + // action was not performed do nothing at all + if (leaf.flags.performable and !performed) { + return null; + } + // If we consume this event, then we are done. If we don't consume // it, we processed the action but we still want to process our // encodings, too. - if (performed and consumed) { + if (consumed) { // If we had queued events, we deinit them since we consumed self.endKeySequence(.drop, .retain); From 652079b26ccb7d76fbc6726f04b40bb3552db06c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 1 Jan 2025 00:03:29 -0600 Subject: [PATCH 030/138] wuffs: update, add jpeg decoding, add simple tests --- .github/workflows/test.yml | 60 +++++++++++++++ nix/zigCacheHash.nix | 2 +- pkg/wuffs/build.zig | 32 ++++++++ pkg/wuffs/build.zig.zon | 9 ++- pkg/wuffs/src/error.zig | 10 +++ pkg/wuffs/src/jpeg.zig | 146 +++++++++++++++++++++++++++++++++++++ pkg/wuffs/src/main.zig | 7 ++ pkg/wuffs/src/png.zig | 49 ++++++------- 8 files changed, 287 insertions(+), 28 deletions(-) create mode 100644 pkg/wuffs/src/jpeg.zig diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f8d2671c..4f07faf2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -478,3 +478,63 @@ jobs: useDaemon: false # sometimes fails on short jobs - name: typos check run: nix develop -c typos + + test-pkg-linux: + strategy: + fail-fast: false + matrix: + pkg: ["wuffs"] + name: Run pkg/${{ matrix.pkg }} tests on Linux + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@v1.2.0 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test ${{ matrix.pkg }} Build + run: | + nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test" + + test-pkg-macos: + strategy: + fail-fast: false + matrix: + pkg: ["wuffs"] + name: Run pkg/${{ matrix.pkg }} tests on macOS + runs-on: namespace-profile-ghostty-macos + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test ${{ matrix.pkg }} Build + run: | + nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test" diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index d4d451e03..60e9e58a4 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-ot5onG1yq7EWQkNUgTNBuqvsnLuaoFs2UDS96IqgJmU=" +"sha256-njCce+r1DPTKLNrmrD2ObEoBS9nR7q03hqegQWe1UuY=" diff --git a/pkg/wuffs/build.zig b/pkg/wuffs/build.zig index 36bb5a07c..438f714d3 100644 --- a/pkg/wuffs/build.zig +++ b/pkg/wuffs/build.zig @@ -30,4 +30,36 @@ pub fn build(b: *std.Build) !void { .file = wuffs.path("release/c/wuffs-v0.4.c"), .flags = flags.items, }); + + const unit_tests = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + unit_tests.linkLibC(); + unit_tests.addIncludePath(wuffs.path("release/c")); + unit_tests.addCSourceFile(.{ + .file = wuffs.path("release/c/wuffs-v0.4.c"), + .flags = flags.items, + }); + + const pixels = b.dependency("pixels", .{}); + + inline for (.{ "000000", "FFFFFF" }) |color| { + inline for (.{ "gif", "jpg", "png", "ppm" }) |extension| { + const filename = std.fmt.comptimePrint("1x1#{s}.{s}", .{ color, extension }); + unit_tests.root_module.addAnonymousImport( + filename, + .{ + .root_source_file = pixels.path(filename), + }, + ); + } + } + + const run_unit_tests = b.addRunArtifact(unit_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); } diff --git a/pkg/wuffs/build.zig.zon b/pkg/wuffs/build.zig.zon index 126e43aba..d84d6957e 100644 --- a/pkg/wuffs/build.zig.zon +++ b/pkg/wuffs/build.zig.zon @@ -3,8 +3,13 @@ .version = "0.0.0", .dependencies = .{ .wuffs = .{ - .url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.8.tar.gz", - .hash = "12200984439edc817fbcbbaff564020e5104a0d04a2d0f53080700827052de700462", + .url = "https://github.com/google/wuffs/archive/refs/tags/v0.4.0-alpha.9.tar.gz", + .hash = "122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd", + }, + + .pixels = .{ + .url = "git+https://github.com/make-github-pseudonymous-again/pixels?ref=main#d843c2714d32e15b48b8d7eeb480295af537f877", + .hash = "12207ff340169c7d40c570b4b6a97db614fe47e0d83b5801a932dcd44917424c8806", }, .apple_sdk = .{ .path = "../apple-sdk" }, diff --git a/pkg/wuffs/src/error.zig b/pkg/wuffs/src/error.zig index 609deec9c..c75188718 100644 --- a/pkg/wuffs/src/error.zig +++ b/pkg/wuffs/src/error.zig @@ -1,3 +1,13 @@ const std = @import("std"); +const c = @import("c.zig").c; + pub const Error = std.mem.Allocator.Error || error{WuffsError}; + +pub fn check(log: anytype, status: *const c.struct_wuffs_base__status__struct) error{WuffsError}!void { + if (!c.wuffs_base__status__is_ok(status)) { + const e = c.wuffs_base__status__message(status); + log.warn("decode err={s}", .{e}); + return error.WuffsError; + } +} diff --git a/pkg/wuffs/src/jpeg.zig b/pkg/wuffs/src/jpeg.zig new file mode 100644 index 000000000..63ca428d1 --- /dev/null +++ b/pkg/wuffs/src/jpeg.zig @@ -0,0 +1,146 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const c = @import("c.zig").c; +const Error = @import("error.zig").Error; +const check = @import("error.zig").check; + +const log = std.log.scoped(.wuffs_jpeg); + +/// Decode a JPEG image. +pub fn decode(alloc: Allocator, data: []const u8) Error!struct { + width: u32, + height: u32, + data: []const u8, +} { + // Work around some weirdness in WUFFS/Zig, there are some structs that + // are defined as "extern" by the Zig compiler which means that Zig won't + // allocate them on the stack at compile time. WUFFS has functions for + // dynamically allocating these structs but they use the C malloc/free. This + // gets around that by using the Zig allocator to allocate enough memory for + // the struct and then casts it to the appropriate pointer. + + const decoder_buf = try alloc.alloc(u8, c.sizeof__wuffs_jpeg__decoder()); + defer alloc.free(decoder_buf); + + const decoder: ?*c.wuffs_jpeg__decoder = @ptrCast(decoder_buf); + { + const status = c.wuffs_jpeg__decoder__initialize( + decoder, + c.sizeof__wuffs_jpeg__decoder(), + c.WUFFS_VERSION, + 0, + ); + try check(log, &status); + } + + var source_buffer: c.wuffs_base__io_buffer = .{ + .data = .{ .ptr = @constCast(@ptrCast(data.ptr)), .len = data.len }, + .meta = .{ + .wi = data.len, + .ri = 0, + .pos = 0, + .closed = true, + }, + }; + + var image_config: c.wuffs_base__image_config = undefined; + { + const status = c.wuffs_jpeg__decoder__decode_image_config( + decoder, + &image_config, + &source_buffer, + ); + try check(log, &status); + } + + const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg); + const height = c.wuffs_base__pixel_config__height(&image_config.pixcfg); + + c.wuffs_base__pixel_config__set( + &image_config.pixcfg, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + c.WUFFS_BASE__PIXEL_SUBSAMPLING__NONE, + width, + height, + ); + + const destination = try alloc.alloc( + u8, + width * height * @sizeOf(c.wuffs_base__color_u32_argb_premul), + ); + errdefer alloc.free(destination); + + // temporary buffer for intermediate processing of image + const work_buffer = try alloc.alloc( + u8, + + // The type of this is a u64 on all systems but our allocator + // uses a usize which is a u32 on 32-bit systems. + std.math.cast( + usize, + c.wuffs_jpeg__decoder__workbuf_len(decoder).max_incl, + ) orelse return error.OutOfMemory, + ); + defer alloc.free(work_buffer); + + const work_slice = c.wuffs_base__make_slice_u8( + work_buffer.ptr, + work_buffer.len, + ); + + var pixel_buffer: c.wuffs_base__pixel_buffer = undefined; + { + const status = c.wuffs_base__pixel_buffer__set_from_slice( + &pixel_buffer, + &image_config.pixcfg, + c.wuffs_base__make_slice_u8(destination.ptr, destination.len), + ); + 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, + &pixel_buffer, + &source_buffer, + c.WUFFS_BASE__PIXEL_BLEND__SRC, + work_slice, + null, + ); + try check(log, &status); + } + + return .{ + .width = width, + .height = height, + .data = destination, + }; +} + +test "jpeg_decode_000000" { + const data = try decode(std.testing.allocator, @embedFile("1x1#000000.jpg")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data); +} + +test "jpeg_decode_FFFFFF" { + const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.jpg")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data); +} diff --git a/pkg/wuffs/src/main.zig b/pkg/wuffs/src/main.zig index 3f03a4158..f5fc01501 100644 --- a/pkg/wuffs/src/main.zig +++ b/pkg/wuffs/src/main.zig @@ -1,2 +1,9 @@ +const std = @import("std"); + pub const png = @import("png.zig"); +pub const jpeg = @import("jpeg.zig"); pub const swizzle = @import("swizzle.zig"); + +test { + std.testing.refAllDeclsRecursive(@This()); +} diff --git a/pkg/wuffs/src/png.zig b/pkg/wuffs/src/png.zig index 3a3ac9a35..4597c6ccb 100644 --- a/pkg/wuffs/src/png.zig +++ b/pkg/wuffs/src/png.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const c = @import("c.zig").c; const Error = @import("error.zig").Error; +const check = @import("error.zig").check; const log = std.log.scoped(.wuffs_png); @@ -29,11 +30,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { c.WUFFS_VERSION, 0, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } var source_buffer: c.wuffs_base__io_buffer = .{ @@ -53,11 +50,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { &image_config, &source_buffer, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } const width = c.wuffs_base__pixel_config__width(&image_config.pixcfg); @@ -102,11 +95,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { &image_config.pixcfg, c.wuffs_base__make_slice_u8(destination.ptr, destination.len), ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } var frame_config: c.wuffs_base__frame_config = undefined; @@ -116,11 +105,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { &frame_config, &source_buffer, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } { @@ -132,11 +117,7 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { work_slice, null, ); - if (!c.wuffs_base__status__is_ok(&status)) { - const e = c.wuffs_base__status__message(&status); - log.warn("decode err={s}", .{e}); - return error.WuffsError; - } + try check(log, &status); } return .{ @@ -145,3 +126,21 @@ pub fn decode(alloc: Allocator, data: []const u8) Error!struct { .data = destination, }; } + +test "png_decode_000000" { + const data = try decode(std.testing.allocator, @embedFile("1x1#000000.png")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 255 }, data.data); +} + +test "png_decode_FFFFFF" { + const data = try decode(std.testing.allocator, @embedFile("1x1#FFFFFF.png")); + defer std.testing.allocator.free(data.data); + + try std.testing.expectEqual(1, data.width); + try std.testing.expectEqual(1, data.height); + try std.testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, data.data); +} From 22c2fe9610a2f5755108829d9181ad0a52b7d041 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 1 Jan 2025 22:48:16 -0600 Subject: [PATCH 031/138] wuffs: use common struct to return decoded image data --- pkg/wuffs/src/jpeg.zig | 7 ++----- pkg/wuffs/src/main.zig | 6 ++++++ pkg/wuffs/src/png.zig | 7 ++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/wuffs/src/jpeg.zig b/pkg/wuffs/src/jpeg.zig index 63ca428d1..69628f582 100644 --- a/pkg/wuffs/src/jpeg.zig +++ b/pkg/wuffs/src/jpeg.zig @@ -3,15 +3,12 @@ const Allocator = std.mem.Allocator; const c = @import("c.zig").c; const Error = @import("error.zig").Error; const check = @import("error.zig").check; +const ImageData = @import("main.zig").ImageData; const log = std.log.scoped(.wuffs_jpeg); /// Decode a JPEG image. -pub fn decode(alloc: Allocator, data: []const u8) Error!struct { - width: u32, - height: u32, - data: []const u8, -} { +pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { // Work around some weirdness in WUFFS/Zig, there are some structs that // are defined as "extern" by the Zig compiler which means that Zig won't // allocate them on the stack at compile time. WUFFS has functions for diff --git a/pkg/wuffs/src/main.zig b/pkg/wuffs/src/main.zig index f5fc01501..f282261c2 100644 --- a/pkg/wuffs/src/main.zig +++ b/pkg/wuffs/src/main.zig @@ -4,6 +4,12 @@ pub const png = @import("png.zig"); pub const jpeg = @import("jpeg.zig"); pub const swizzle = @import("swizzle.zig"); +pub const ImageData = struct { + width: u32, + height: u32, + data: []const u8, +}; + test { std.testing.refAllDeclsRecursive(@This()); } diff --git a/pkg/wuffs/src/png.zig b/pkg/wuffs/src/png.zig index 4597c6ccb..b85e4d747 100644 --- a/pkg/wuffs/src/png.zig +++ b/pkg/wuffs/src/png.zig @@ -3,15 +3,12 @@ const Allocator = std.mem.Allocator; const c = @import("c.zig").c; const Error = @import("error.zig").Error; const check = @import("error.zig").check; +const ImageData = @import("main.zig").ImageData; const log = std.log.scoped(.wuffs_png); /// Decode a PNG image. -pub fn decode(alloc: Allocator, data: []const u8) Error!struct { - width: u32, - height: u32, - data: []const u8, -} { +pub fn decode(alloc: Allocator, data: []const u8) Error!ImageData { // Work around some weirdness in WUFFS/Zig, there are some structs that // are defined as "extern" by the Zig compiler which means that Zig won't // allocate them on the stack at compile time. WUFFS has functions for From 78b914b3d87eede3f922a7973e01909f1faa207d Mon Sep 17 00:00:00 2001 From: Kat <65649991+00-kat@users.noreply.github.com> Date: Thu, 2 Jan 2025 07:44:40 +0000 Subject: [PATCH 032/138] Fix format string of font size in points in the inspector Credits to @gabydd who found this over at https://discord.com/channels/1005603569187160125/1324249888514506752/1324275061380874250. Co-authored-by: Gabriel Dinner-David --- src/inspector/Inspector.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 7dd61c8a1..f9c6e98d1 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -724,7 +724,7 @@ fn renderSizeWindow(self: *Inspector) void { { _ = cimgui.c.igTableSetColumnIndex(1); cimgui.c.igText( - "%d pt", + "%.2f pt", self.surface.font_size.points, ); } From 15ceb18fcb1ebd5b340f331e05a15838d829059b Mon Sep 17 00:00:00 2001 From: Jade Date: Thu, 2 Jan 2025 20:54:42 +0800 Subject: [PATCH 033/138] Improve goto_split documentation --- 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 b2c03b674..c9f7a443f 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -314,7 +314,7 @@ pub const Action = union(enum) { /// the direction given. new_split: SplitDirection, - /// Focus on a split in a given direction. + /// Focus on a split in a given direction. For example `goto_split:top`. Valid values are top, bottom, left and right. goto_split: SplitFocusDirection, /// zoom/unzoom the current split. From 80fe32be329fd47df7998796bb34b2ad7a95fed5 Mon Sep 17 00:00:00 2001 From: Jade Date: Thu, 2 Jan 2025 20:58:50 +0800 Subject: [PATCH 034/138] Update Binding.zig --- 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 c9f7a443f..1bcf6332d 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -314,7 +314,7 @@ pub const Action = union(enum) { /// the direction given. new_split: SplitDirection, - /// Focus on a split in a given direction. For example `goto_split:top`. Valid values are top, bottom, left and right. + /// Focus on a split in a given direction. For example `goto_split:top`. Valid values are top, bottom, left, right, previous and next. goto_split: SplitFocusDirection, /// zoom/unzoom the current split. From cd809106c44e55260838d4637f74d120b8793c15 Mon Sep 17 00:00:00 2001 From: Jade Date: Thu, 2 Jan 2025 21:05:56 +0800 Subject: [PATCH 035/138] Improve new_split documentation --- 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 b2c03b674..2c1d0f614 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -311,7 +311,7 @@ pub const Action = union(enum) { toggle_tab_overview: void, /// Create a new split in the given direction. The new split will appear in - /// the direction given. + /// 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. From 355ac91c0a7c91cd0444a877bef85dbfe21a3a03 Mon Sep 17 00:00:00 2001 From: Jade Date: Thu, 2 Jan 2025 21:33:47 +0800 Subject: [PATCH 036/138] Improve resize_split documentation --- 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 b2c03b674..a9d231e45 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -321,7 +321,7 @@ pub const Action = union(enum) { toggle_split_zoom: void, /// Resize the current split by moving the split divider in the given - /// direction + /// direction. For example `resize_split:left,10`. The valid directions are up, down, left and right. resize_split: SplitResizeParameter, /// Equalize all splits in the current window From 913a1404be22435c28fde0cf6068d0e0f0f853f0 Mon Sep 17 00:00:00 2001 From: Anund Date: Fri, 3 Jan 2025 01:53:07 +1100 Subject: [PATCH 037/138] keybindings: improve sort to include key value 1,2,3,4... --- src/input/Binding.zig | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b2c03b674..c9a2630d2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -185,10 +185,29 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { if (rhs.trigger.mods.alt) count += 1; break :blk count; }; - if (lhs_count == rhs_count) + + if (lhs_count != rhs_count) + return lhs_count > rhs_count; + + if (lhs.trigger.mods.int() != rhs.trigger.mods.int()) return lhs.trigger.mods.int() > rhs.trigger.mods.int(); - return lhs_count > rhs_count; + const lhs_key: c_int = blk: { + switch (lhs.trigger.key) { + .translated => break :blk @intFromEnum(lhs.trigger.key.translated), + .physical => break :blk @intFromEnum(lhs.trigger.key.physical), + .unicode => break :blk @intCast(lhs.trigger.key.unicode), + } + }; + const rhs_key: c_int = blk: { + switch (rhs.trigger.key) { + .translated => break :blk @intFromEnum(rhs.trigger.key.translated), + .physical => break :blk @intFromEnum(rhs.trigger.key.physical), + .unicode => break :blk @intCast(rhs.trigger.key.unicode), + } + }; + + return lhs_key < rhs_key; } /// The set of actions that a keybinding can take. From 1941a440d87a96b1e7f4e0b498c5f15f133d1f99 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Sat, 28 Dec 2024 00:02:28 +0800 Subject: [PATCH 038/138] Use `$HOME/Library/Caches` on macOS instead of `$HOME/.cache` --- src/os/xdg.zig | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/os/xdg.zig b/src/os/xdg.zig index 6c7655c22..1d60374ef 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -30,6 +30,22 @@ pub fn config(alloc: Allocator, opts: Options) ![]u8 { /// Get the XDG cache directory. The returned value is allocated. pub fn cache(alloc: Allocator, opts: Options) ![]u8 { + // On macOS we should use ~/Library/Caches instead of ~/.cache + if (builtin.os.tag == .macos) { + // Get our home dir if not provided + const home = if (opts.home) |h| h else blk: { + var buf: [1024]u8 = undefined; + break :blk try homedir.home(&buf) orelse return error.NoHomeDir; + }; + + return try std.fs.path.join(alloc, &[_][]const u8{ + home, + "Library", + "Caches", + opts.subdir orelse "", + }); + } + return try dir(alloc, opts, .{ .env = "XDG_CACHE_HOME", .windows_env = "LOCALAPPDATA", @@ -143,6 +159,52 @@ test { } } +test "cache directory paths" { + const testing = std.testing; + const alloc = testing.allocator; + const mock_home = "/Users/test"; + + // Test macOS path + if (builtin.os.tag == .macos) { + // Test base path + { + const cache_path = try cache(alloc, .{ .home = mock_home }); + defer alloc.free(cache_path); + try testing.expectEqualStrings("/Users/test/Library/Caches", cache_path); + } + + // Test with subdir + { + const cache_path = try cache(alloc, .{ + .home = mock_home, + .subdir = "ghostty", + }); + defer alloc.free(cache_path); + try testing.expectEqualStrings("/Users/test/Library/Caches/ghostty", cache_path); + } + } + + // Test Linux path (when XDG_CACHE_HOME is not set) + if (builtin.os.tag == .linux) { + // Test base path + { + const cache_path = try cache(alloc, .{ .home = mock_home }); + defer alloc.free(cache_path); + try testing.expectEqualStrings("/Users/test/.cache", cache_path); + } + + // Test with subdir + { + const cache_path = try cache(alloc, .{ + .home = mock_home, + .subdir = "ghostty", + }); + defer alloc.free(cache_path); + try testing.expectEqualStrings("/Users/test/.cache/ghostty", cache_path); + } + } +} + test parseTerminalExec { const testing = std.testing; From 67794d3f6f39b4f765a1780e939dda024c9bf561 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Mon, 30 Dec 2024 09:33:53 +0800 Subject: [PATCH 039/138] Respect `XDG_CACHE_HOME` and use`NSFileManager` for cache paths --- src/crash/sentry.zig | 5 +- src/os/macos.zig | 109 ++++++++++++++++++++++++++++++------------- src/os/xdg.zig | 37 ++------------- 3 files changed, 85 insertions(+), 66 deletions(-) diff --git a/src/crash/sentry.zig b/src/crash/sentry.zig index 14f2e484c..fba20067d 100644 --- a/src/crash/sentry.zig +++ b/src/crash/sentry.zig @@ -101,7 +101,10 @@ fn initThread(gpa: Allocator) !void { sentry.c.sentry_options_set_before_send(opts, beforeSend, null); // Determine the Sentry cache directory. - const cache_dir = try internal_os.xdg.cache(alloc, .{ .subdir = "ghostty/sentry" }); + const cache_dir = if (builtin.os.tag == .macos) + try internal_os.macos.cacheDir(alloc, "ghostty/sentry") + else + try internal_os.xdg.cache(alloc, .{ .subdir = "ghostty/sentry" }); sentry.c.sentry_options_set_database_path_n( opts, cache_dir.ptr, diff --git a/src/os/macos.zig b/src/os/macos.zig index b3d0a917c..5cf6ab23a 100644 --- a/src/os/macos.zig +++ b/src/os/macos.zig @@ -25,43 +25,23 @@ pub fn appSupportDir( alloc: Allocator, sub_path: []const u8, ) AppSupportDirError![]const u8 { - comptime assert(builtin.target.isDarwin()); - - const NSFileManager = objc.getClass("NSFileManager").?; - const manager = NSFileManager.msgSend( - objc.Object, - objc.sel("defaultManager"), - .{}, - ); - - const url = manager.msgSend( - objc.Object, - objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"), - .{ - NSSearchPathDirectory.NSApplicationSupportDirectory, - NSSearchPathDomainMask.NSUserDomainMask, - @as(?*anyopaque, null), - true, - @as(?*anyopaque, null), - }, - ); - - // I don't think this is possible but just in case. - if (url.value == null) return error.AppleAPIFailed; - - // Get the UTF-8 string from the URL. - const path = url.getProperty(objc.Object, "path"); - const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse - return error.AppleAPIFailed; - const app_support_dir = std.mem.sliceTo(c_str, 0); - - return try std.fs.path.join(alloc, &.{ - app_support_dir, + return try makeCommonPath(alloc, .NSApplicationSupportDirectory, &.{ build_config.bundle_id, sub_path, }); } +pub const CacheDirError = Allocator.Error || error{AppleAPIFailed}; + +/// Return the path to the system cache directory with the given sub path joined. +/// This allocates the result using the given allocator. +pub fn cacheDir( + alloc: Allocator, + sub_path: []const u8, +) CacheDirError![]const u8 { + return try makeCommonPath(alloc, .NSCachesDirectory, &.{sub_path}); +} + pub const SetQosClassError = error{ // The thread can't have its QoS class changed usually because // a different pthread API was called that makes it an invalid @@ -110,9 +90,74 @@ pub const NSOperatingSystemVersion = extern struct { }; pub const NSSearchPathDirectory = enum(c_ulong) { + NSCachesDirectory = 13, NSApplicationSupportDirectory = 14, }; pub const NSSearchPathDomainMask = enum(c_ulong) { NSUserDomainMask = 1, }; + +fn makeCommonPath( + alloc: Allocator, + directory: NSSearchPathDirectory, + sub_paths: []const []const u8, +) (error{AppleAPIFailed} || Allocator.Error)![]const u8 { + comptime assert(builtin.target.isDarwin()); + + const NSFileManager = objc.getClass("NSFileManager").?; + const manager = NSFileManager.msgSend( + objc.Object, + objc.sel("defaultManager"), + .{}, + ); + + const url = manager.msgSend( + objc.Object, + objc.sel("URLForDirectory:inDomain:appropriateForURL:create:error:"), + .{ + directory, + NSSearchPathDomainMask.NSUserDomainMask, + @as(?*anyopaque, null), + true, + @as(?*anyopaque, null), + }, + ); + + if (url.value == null) return error.AppleAPIFailed; + + const path = url.getProperty(objc.Object, "path"); + const c_str = path.getProperty(?[*:0]const u8, "UTF8String") orelse + return error.AppleAPIFailed; + const base_dir = std.mem.sliceTo(c_str, 0); + + // Create a new array with base_dir as the first element + var paths = try alloc.alloc([]const u8, sub_paths.len + 1); + paths[0] = base_dir; + @memcpy(paths[1..], sub_paths); + defer alloc.free(paths); + + return try std.fs.path.join(alloc, paths); +} + +test "cacheDir paths" { + if (!builtin.target.isDarwin()) return; + + const testing = std.testing; + const alloc = testing.allocator; + + // Test base path + { + const cache_path = try cacheDir(alloc, ""); + defer alloc.free(cache_path); + // We don't test the exact path since it comes from NSFileManager + try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null); + } + + // Test with subdir + { + const cache_path = try cacheDir(alloc, "ghostty"); + defer alloc.free(cache_path); + try testing.expect(std.mem.indexOf(u8, cache_path, "Caches/ghostty") != null); + } +} diff --git a/src/os/xdg.zig b/src/os/xdg.zig index 1d60374ef..80645dcb5 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -30,18 +30,9 @@ pub fn config(alloc: Allocator, opts: Options) ![]u8 { /// Get the XDG cache directory. The returned value is allocated. pub fn cache(alloc: Allocator, opts: Options) ![]u8 { - // On macOS we should use ~/Library/Caches instead of ~/.cache - if (builtin.os.tag == .macos) { - // Get our home dir if not provided - const home = if (opts.home) |h| h else blk: { - var buf: [1024]u8 = undefined; - break :blk try homedir.home(&buf) orelse return error.NoHomeDir; - }; - + if (posix.getenv("XDG_CACHE_HOME")) |env| { return try std.fs.path.join(alloc, &[_][]const u8{ - home, - "Library", - "Caches", + env, opts.subdir orelse "", }); } @@ -164,28 +155,8 @@ test "cache directory paths" { const alloc = testing.allocator; const mock_home = "/Users/test"; - // Test macOS path - if (builtin.os.tag == .macos) { - // Test base path - { - const cache_path = try cache(alloc, .{ .home = mock_home }); - defer alloc.free(cache_path); - try testing.expectEqualStrings("/Users/test/Library/Caches", cache_path); - } - - // Test with subdir - { - const cache_path = try cache(alloc, .{ - .home = mock_home, - .subdir = "ghostty", - }); - defer alloc.free(cache_path); - try testing.expectEqualStrings("/Users/test/Library/Caches/ghostty", cache_path); - } - } - - // Test Linux path (when XDG_CACHE_HOME is not set) - if (builtin.os.tag == .linux) { + // Test when XDG_CACHE_HOME is not set + { // Test base path { const cache_path = try cache(alloc, .{ .home = mock_home }); From 9f44ec7c21cc13985d5e6be06dccda0362932f12 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Mon, 30 Dec 2024 14:13:22 +0800 Subject: [PATCH 040/138] Use bundle ID for macOS cache directory path --- src/crash/sentry.zig | 2 +- src/os/macos.zig | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/crash/sentry.zig b/src/crash/sentry.zig index fba20067d..f381a8840 100644 --- a/src/crash/sentry.zig +++ b/src/crash/sentry.zig @@ -102,7 +102,7 @@ fn initThread(gpa: Allocator) !void { // Determine the Sentry cache directory. const cache_dir = if (builtin.os.tag == .macos) - try internal_os.macos.cacheDir(alloc, "ghostty/sentry") + try internal_os.macos.cacheDir(alloc, "sentry") else try internal_os.xdg.cache(alloc, .{ .subdir = "ghostty/sentry" }); sentry.c.sentry_options_set_database_path_n( diff --git a/src/os/macos.zig b/src/os/macos.zig index 5cf6ab23a..918dde9af 100644 --- a/src/os/macos.zig +++ b/src/os/macos.zig @@ -39,7 +39,10 @@ pub fn cacheDir( alloc: Allocator, sub_path: []const u8, ) CacheDirError![]const u8 { - return try makeCommonPath(alloc, .NSCachesDirectory, &.{sub_path}); + return try makeCommonPath(alloc, .NSCachesDirectory, &.{ + build_config.bundle_id, + sub_path, + }); } pub const SetQosClassError = error{ @@ -150,14 +153,19 @@ test "cacheDir paths" { { const cache_path = try cacheDir(alloc, ""); defer alloc.free(cache_path); - // We don't test the exact path since it comes from NSFileManager try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null); + try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null); } // Test with subdir { - const cache_path = try cacheDir(alloc, "ghostty"); + const cache_path = try cacheDir(alloc, "test"); defer alloc.free(cache_path); - try testing.expect(std.mem.indexOf(u8, cache_path, "Caches/ghostty") != null); + try testing.expect(std.mem.indexOf(u8, cache_path, "Caches") != null); + try testing.expect(std.mem.indexOf(u8, cache_path, build_config.bundle_id) != null); + + const bundle_path = try std.fmt.allocPrint(alloc, "{s}/test", .{build_config.bundle_id}); + defer alloc.free(bundle_path); + try testing.expect(std.mem.indexOf(u8, cache_path, bundle_path) != null); } } From 6fca26972bbe4407f6d0c4c76f13efa8eeda212c Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Fri, 3 Jan 2025 00:21:44 +0800 Subject: [PATCH 041/138] Remove the redundant check and directly use `dir()` --- src/os/xdg.zig | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/os/xdg.zig b/src/os/xdg.zig index 80645dcb5..a5b29abe4 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -30,13 +30,6 @@ pub fn config(alloc: Allocator, opts: Options) ![]u8 { /// Get the XDG cache directory. The returned value is allocated. pub fn cache(alloc: Allocator, opts: Options) ![]u8 { - if (posix.getenv("XDG_CACHE_HOME")) |env| { - return try std.fs.path.join(alloc, &[_][]const u8{ - env, - opts.subdir orelse "", - }); - } - return try dir(alloc, opts, .{ .env = "XDG_CACHE_HOME", .windows_env = "LOCALAPPDATA", From 0c10db9f1414c927fe0a7743832dfc2efb4f5427 Mon Sep 17 00:00:00 2001 From: Nhan Luu <62146587+nhld@users.noreply.github.com> Date: Thu, 2 Jan 2025 23:41:57 +0700 Subject: [PATCH 042/138] chore: fix typos --- 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 038643511..eeb1fd0c5 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -548,7 +548,7 @@ palette: Palette = .{}, /// than 0.01 or greater than 10,000 will be clamped to the nearest valid /// value. /// -/// A value of "1" (default) scrolls te default amount. A value of "2" scrolls +/// A value of "1" (default) scrolls the default amount. A value of "2" scrolls /// double the default amount. A value of "0.5" scrolls half the default amount. /// Et cetera. @"mouse-scroll-multiplier": f64 = 1.0, @@ -724,7 +724,7 @@ fullscreen: bool = false, /// This configuration can be reloaded at runtime. If it is set, the title /// will update for all windows. If it is unset, the next title change escape /// sequence will be honored but previous changes will not retroactively -/// be set. This latter case may require you restart programs such as neovim +/// be set. This latter case may require you to restart programs such as Neovim /// to get the new title. title: ?[:0]const u8 = null, From aa34b91856f34ecad16e06c6684673d22b0bdad6 Mon Sep 17 00:00:00 2001 From: Liam Hupfer Date: Thu, 2 Jan 2025 12:34:42 -0600 Subject: [PATCH 043/138] gtk: Always read gtk-xft-dpi for font scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit ad503b8c4fa7 ("linux: consider Xft.dpi to scale the content") introduced reading gtk-xft-dpi when the X11 build option is enabled. While the name suggests it is X11-specific (perhaps it was at one point), gtk-xft-dpi is a GTK setting that can be modified regardless of GDK backend. GNOME’s Large Text accessibility setting ultimately modifies it. Outside of desktop environments, it can be set via GTK configuration files. Remove the conditional gating the code on X11, since none of the code is actually X11-specific. While we’re here, document scaling behaviors under Config.font-size. Fixes: ad503b8c4fa7 ("linux: consider Xft.dpi to scale the content") Fixes: https://github.com/ghostty-org/ghostty/issues/4338 Link: https://docs.gtk.org/gtk4/class.Settings.html Link: https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html --- src/apprt/gtk/Surface.zig | 16 ++++++++-------- src/config/Config.zig | 4 ++++ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index f61e34a07..079cdbd81 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -794,10 +794,11 @@ pub fn getContentScale(self: *const Surface) !apprt.ContentScale { // can support fractional scaling. const gtk_scale: f32 = @floatFromInt(c.gtk_widget_get_scale_factor(@ptrCast(self.gl_area))); - // If we are on X11, we also have to scale using Xft.dpi - const xft_dpi_scale = if (!x11.is_current_display_server()) 1.0 else xft_scale: { - // Here we use GTK to retrieve gtk-xft-dpi, which is Xft.dpi multiplied - // by 1024. See https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html + // Also scale using font-specific DPI, which is often exposed to the user + // via DE accessibility settings (see https://docs.gtk.org/gtk4/class.Settings.html). + const xft_dpi_scale = xft_scale: { + // gtk-xft-dpi is font DPI multiplied by 1024. See + // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html const settings = c.gtk_settings_get_default(); var value: c.GValue = std.mem.zeroes(c.GValue); @@ -806,10 +807,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 Xft.dpi is multiplied by 1024, so we divide by 1024, - // then divide by the default value of Xft.dpi (96) to derive a scale. - // Note that gtk-xft-dpi can be fractional, so we use floating point - // math here. + // 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; }; diff --git a/src/config/Config.zig b/src/config/Config.zig index 038643511..64fea91eb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -177,6 +177,10 @@ const c = @cImport({ /// depending on your `window-inherit-font-size` setting. If that setting is /// true, only the first window will be affected by this change since all /// subsequent windows will inherit the font size of the previous window. +/// +/// On Linux with GTK, font size is scaled according to both display-wide and +/// text-specific scaling factors, which are often managed by your desktop +/// environment (e.g. the GNOME display scale and large text settings). @"font-size": f32 = switch (builtin.os.tag) { // On macOS we default a little bigger since this tends to look better. This // is purely subjective but this is easy to modify. From 57af5f31067dedd8aff07cb576b30044da14e22f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 11:50:12 -0800 Subject: [PATCH 044/138] crash: prefer XDG cache dir if available --- src/crash/sentry.zig | 21 +++++++++++++++++---- src/os/macos.zig | 20 +++++++++++--------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/crash/sentry.zig b/src/crash/sentry.zig index f381a8840..9e05b427d 100644 --- a/src/crash/sentry.zig +++ b/src/crash/sentry.zig @@ -101,10 +101,23 @@ fn initThread(gpa: Allocator) !void { sentry.c.sentry_options_set_before_send(opts, beforeSend, null); // Determine the Sentry cache directory. - const cache_dir = if (builtin.os.tag == .macos) - try internal_os.macos.cacheDir(alloc, "sentry") - else - try internal_os.xdg.cache(alloc, .{ .subdir = "ghostty/sentry" }); + const cache_dir = cache_dir: { + // On macOS, we prefer to use the NSCachesDirectory value to be + // a more idiomatic macOS application. But if XDG env vars are set + // we will respect them. + if (comptime builtin.os.tag == .macos) macos: { + if (std.posix.getenv("XDG_CACHE_HOME") != null) break :macos; + break :cache_dir try internal_os.macos.cacheDir( + alloc, + "sentry", + ); + } + + break :cache_dir try internal_os.xdg.cache( + alloc, + .{ .subdir = "ghostty/sentry" }, + ); + }; sentry.c.sentry_options_set_database_path_n( opts, cache_dir.ptr, diff --git a/src/os/macos.zig b/src/os/macos.zig index 918dde9af..a956d25e2 100644 --- a/src/os/macos.zig +++ b/src/os/macos.zig @@ -25,10 +25,11 @@ pub fn appSupportDir( alloc: Allocator, sub_path: []const u8, ) AppSupportDirError![]const u8 { - return try makeCommonPath(alloc, .NSApplicationSupportDirectory, &.{ - build_config.bundle_id, - sub_path, - }); + return try commonDir( + alloc, + .NSApplicationSupportDirectory, + &.{ build_config.bundle_id, sub_path }, + ); } pub const CacheDirError = Allocator.Error || error{AppleAPIFailed}; @@ -39,10 +40,11 @@ pub fn cacheDir( alloc: Allocator, sub_path: []const u8, ) CacheDirError![]const u8 { - return try makeCommonPath(alloc, .NSCachesDirectory, &.{ - build_config.bundle_id, - sub_path, - }); + return try commonDir( + alloc, + .NSCachesDirectory, + &.{ build_config.bundle_id, sub_path }, + ); } pub const SetQosClassError = error{ @@ -101,7 +103,7 @@ pub const NSSearchPathDomainMask = enum(c_ulong) { NSUserDomainMask = 1, }; -fn makeCommonPath( +fn commonDir( alloc: Allocator, directory: NSSearchPathDirectory, sub_paths: []const []const u8, From 88674a1957e82ecb548a00e5d8a7e575136f670a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoffer=20T=C3=B8nnessen?= Date: Fri, 27 Dec 2024 21:48:09 +0100 Subject: [PATCH 045/138] Restore hidden titlebar after fullscreen This fixes https://github.com/ghostty-org/ghostty/issues/3535 . There exists an issue in ghostty on mac where if you have hidden your titlebar, then enter fullscreen, the titlebar will reappear after exiting fullscreen. The reason for this is that after exiting fullscreen macos reapplies some styling on the new window created after exiting fullscreen. To combat this we will reapply the styling to hide the titlebar after exiting fullscreen. Required config: ``` macos-titlebar-style = hidden macos-non-native-fullscreen = true ``` Steps to reproduce: - Open Ghostty - Enter fullscreen (non-native) - Exit fullscreen On main you will see the titlebar reappearing after exiting fullscreen, while that does not happen with this patch. --- .../Terminal/TerminalController.swift | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c3b332cd4..331f26c97 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -101,6 +101,12 @@ class TerminalController: BaseTerminalController { // When our fullscreen state changes, we resync our appearance because some // properties change when fullscreen or not. guard let focusedSurface else { return } + if (!(fullscreenStyle?.isFullscreen ?? false) && + ghostty.config.macosTitlebarStyle == "hidden") + { + applyHiddenTitlebarStyle() + } + syncAppearance(focusedSurface.derivedConfig) } @@ -274,6 +280,43 @@ class TerminalController: BaseTerminalController { shouldCascadeWindows = false } + fileprivate func applyHiddenTitlebarStyle() { + guard let window else { return } + + 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, + .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. + if let themeFrame = window.contentView?.superview, + let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { + titleBarContainer.isHidden = true + } + } + override func windowDidLoad() { super.windowDidLoad() guard let window = window as? TerminalWindow else { return } @@ -365,38 +408,7 @@ class TerminalController: BaseTerminalController { // If our titlebar style is "hidden" we adjust the style appropriately if (config.macosTitlebarStyle == "hidden") { - 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, - .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. - if let themeFrame = window.contentView?.superview, - let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { - titleBarContainer.isHidden = true - } + applyHiddenTitlebarStyle() } // In various situations, macOS automatically tabs new windows. Ghostty handles From f184258f0e5514caecfd429c600aa1e8f571e895 Mon Sep 17 00:00:00 2001 From: z-jxy Date: Sat, 28 Dec 2024 16:27:33 -0500 Subject: [PATCH 046/138] expand tilde to HOME in config --- src/config/Config.zig | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 64fea91eb..b679e1757 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4363,6 +4363,33 @@ pub const RepeatablePath = struct { var buf: [std.fs.max_path_bytes]u8 = undefined; const abs = dir.realpath(path, &buf) catch |err| abs: { if (err == error.FileNotFound) { + // Check if the path starts with a tilde and expand it to the home directory on linux/mac + if (path[0] == '~') { + const home_env_var = switch (builtin.os.tag) { + .linux, .macos => std.posix.getenv("HOME"), + .windows => null, + else => null, + }; + if (home_env_var) |home_dir| { + const rest = path[1..]; // Skip the ~ + const expanded_len = home_dir.len + rest.len; + if (expanded_len > buf.len) { + try diags.append(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "error resolving file path {s}: path too long after expanding home directory", + .{path}, + ), + }); + self.value.items[i] = .{ .required = "" }; + continue; + } + @memcpy(buf[0..home_dir.len], home_dir); + @memcpy(buf[home_dir.len..expanded_len], rest); + break :abs buf[0..expanded_len]; + } + } + // The file doesn't exist. Try to resolve the relative path // another way. const resolved = try std.fs.path.resolve(alloc, &.{ base, path }); From 7c9c982df7a18d0aadfd7d2d9756d94a8d14eefb Mon Sep 17 00:00:00 2001 From: z-jxy Date: Sat, 28 Dec 2024 18:06:08 -0500 Subject: [PATCH 047/138] refactor: handle tilde before checking realpath --- src/config/Config.zig | 80 ++++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index b679e1757..2e121dc18 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4361,35 +4361,61 @@ pub const RepeatablePath = struct { // If it isn't absolute, we need to make it absolute relative // to the base. var buf: [std.fs.max_path_bytes]u8 = undefined; - const abs = dir.realpath(path, &buf) catch |err| abs: { - if (err == error.FileNotFound) { - // Check if the path starts with a tilde and expand it to the home directory on linux/mac - if (path[0] == '~') { - const home_env_var = switch (builtin.os.tag) { - .linux, .macos => std.posix.getenv("HOME"), - .windows => null, - else => null, - }; - if (home_env_var) |home_dir| { - const rest = path[1..]; // Skip the ~ - const expanded_len = home_dir.len + rest.len; - if (expanded_len > buf.len) { - try diags.append(alloc, .{ - .message = try std.fmt.allocPrintZ( - alloc, - "error resolving file path {s}: path too long after expanding home directory", - .{path}, - ), - }); - self.value.items[i] = .{ .required = "" }; - continue; - } - @memcpy(buf[0..home_dir.len], home_dir); - @memcpy(buf[home_dir.len..expanded_len], rest); - break :abs buf[0..expanded_len]; - } + + // Check if the path starts with a tilde and expand it to the home directory on linux/mac + if (path[0] == '~') { + const home_env_var = switch (builtin.os.tag) { + .linux, .macos => std.posix.getenv("HOME"), + .windows => null, + else => null, + }; + + if (home_env_var) |home_dir| { + // very unlikely to happen + if (!std.fs.path.isAbsolute(home_dir)) { + try diags.append(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "error resolving file path {s}: HOME environment variable is not an absolute path", + .{path}, + ), + }); + self.value.items[i] = .{ .required = "" }; + continue; } + const rest = path[1..]; // Skip the ~ + const expanded_len = home_dir.len + rest.len; + if (expanded_len > buf.len) { + try diags.append(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "error resolving file path {s}: path too long after expanding home directory", + .{path}, + ), + }); + self.value.items[i] = .{ .required = "" }; + continue; + } + + @memcpy(buf[0..home_dir.len], home_dir); + @memcpy(buf[home_dir.len..expanded_len], rest); + + log.debug( + "expanding file path from home directory: path={s}", + .{buf[0..expanded_len]}, + ); + + switch (self.value.items[i]) { + .optional, .required => |*p| p.* = try alloc.dupeZ(u8, buf[0..expanded_len]), + } + + continue; + } + } + + const abs = dir.realpath(path, &buf) catch |err| abs: { + if (err == error.FileNotFound) { // The file doesn't exist. Try to resolve the relative path // another way. const resolved = try std.fs.path.resolve(alloc, &.{ base, path }); From d27761a49972d8786c40b9666aaec0d828ed76e0 Mon Sep 17 00:00:00 2001 From: z-jxy Date: Sat, 28 Dec 2024 20:04:49 -0500 Subject: [PATCH 048/138] use `home()` from `os/homedir`, check for `~/` rather than `~` --- src/config/Config.zig | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 2e121dc18..6ef84cae7 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4363,27 +4363,9 @@ pub const RepeatablePath = struct { var buf: [std.fs.max_path_bytes]u8 = undefined; // Check if the path starts with a tilde and expand it to the home directory on linux/mac - if (path[0] == '~') { - const home_env_var = switch (builtin.os.tag) { - .linux, .macos => std.posix.getenv("HOME"), - .windows => null, - else => null, - }; - - if (home_env_var) |home_dir| { - // very unlikely to happen - if (!std.fs.path.isAbsolute(home_dir)) { - try diags.append(alloc, .{ - .message = try std.fmt.allocPrintZ( - alloc, - "error resolving file path {s}: HOME environment variable is not an absolute path", - .{path}, - ), - }); - self.value.items[i] = .{ .required = "" }; - continue; - } - + if (std.mem.startsWith(u8, path, "~/")) { + const home_var = try internal_os.home(&buf); // cache this? + if (home_var) |home_dir| { const rest = path[1..]; // Skip the ~ const expanded_len = home_dir.len + rest.len; if (expanded_len > buf.len) { @@ -4398,7 +4380,6 @@ pub const RepeatablePath = struct { continue; } - @memcpy(buf[0..home_dir.len], home_dir); @memcpy(buf[home_dir.len..expanded_len], rest); log.debug( From 138a8f16026a34c83207670dfad00e6c310ec4d6 Mon Sep 17 00:00:00 2001 From: z-jxy Date: Sat, 28 Dec 2024 20:38:58 -0500 Subject: [PATCH 049/138] move tilde expansion functionality to `os/homedir` --- src/config/Config.zig | 35 +++++++++++++---------------------- src/os/homedir.zig | 16 ++++++++++++++++ src/os/main.zig | 1 + 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6ef84cae7..5a9fdcc3f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4364,33 +4364,24 @@ pub const RepeatablePath = struct { // Check if the path starts with a tilde and expand it to the home directory on linux/mac if (std.mem.startsWith(u8, path, "~/")) { - const home_var = try internal_os.home(&buf); // cache this? - if (home_var) |home_dir| { - const rest = path[1..]; // Skip the ~ - const expanded_len = home_dir.len + rest.len; - if (expanded_len > buf.len) { - try diags.append(alloc, .{ - .message = try std.fmt.allocPrintZ( - alloc, - "error resolving file path {s}: path too long after expanding home directory", - .{path}, - ), - }); - self.value.items[i] = .{ .required = "" }; - continue; - } - - @memcpy(buf[home_dir.len..expanded_len], rest); - + if (try internal_os.expandHome(path, &buf)) |expanded_path| { log.debug( "expanding file path from home directory: path={s}", - .{buf[0..expanded_len]}, + .{expanded_path}, ); - switch (self.value.items[i]) { - .optional, .required => |*p| p.* = try alloc.dupeZ(u8, buf[0..expanded_len]), + .optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded_path), } - + continue; + } else { + try diags.append(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "error expanding home path {s}", + .{path}, + ), + }); + self.value.items[i] = .{ .required = "" }; continue; } } diff --git a/src/os/homedir.zig b/src/os/homedir.zig index cf6931f22..b03e7f354 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -110,6 +110,22 @@ fn trimSpace(input: []const u8) []const u8 { return std.mem.trim(u8, input, " \n\t"); } +/// Expands a path that starts with a tilde (~) to the home directory of the user. +/// +/// Errors if `home` fails or if the size of the expanded path is larger than `buf.len`. +/// +/// Returns null if the value returned from `home` is null, otherwise returns a slice to the expanded path. +pub fn expandHome(path: []const u8, buf: []u8) !?[]u8 { + const home_dir = try home(buf) orelse return null; + const rest = path[1..]; // Skip the ~ + const expanded_len = home_dir.len + rest.len; + + if (expanded_len > buf.len) return Error.BufferTooSmall; + @memcpy(buf[home_dir.len..expanded_len], rest); + + return buf[0..expanded_len]; +} + test { const testing = std.testing; diff --git a/src/os/main.zig b/src/os/main.zig index 98e57b4fc..fb1782862 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -38,6 +38,7 @@ pub const freeTmpDir = file.freeTmpDir; pub const isFlatpak = flatpak.isFlatpak; pub const FlatpakHostCommand = flatpak.FlatpakHostCommand; pub const home = homedir.home; +pub const expandHome = homedir.expandHome; pub const ensureLocale = locale.ensureLocale; pub const clickInterval = mouse.clickInterval; pub const open = openpkg.open; From 5ae2cc01ac19da64fe95c7e9717971712ca3625c Mon Sep 17 00:00:00 2001 From: z-jxy Date: Sat, 28 Dec 2024 21:06:56 -0500 Subject: [PATCH 050/138] move current `expandHome` functionality into separate `expandHomeUnix` function --- src/config/Config.zig | 29 ++++++++++++----------------- src/os/homedir.zig | 11 ++++++++++- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 5a9fdcc3f..27b5f9d03 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4364,26 +4364,21 @@ pub const RepeatablePath = struct { // Check if the path starts with a tilde and expand it to the home directory on linux/mac if (std.mem.startsWith(u8, path, "~/")) { - if (try internal_os.expandHome(path, &buf)) |expanded_path| { - log.debug( - "expanding file path from home directory: path={s}", - .{expanded_path}, - ); - switch (self.value.items[i]) { - .optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded_path), - } - continue; - } else { - try diags.append(alloc, .{ - .message = try std.fmt.allocPrintZ( - alloc, - "error expanding home path {s}", - .{path}, - ), - }); + const expanded: []u8 = try internal_os.expandHome(path, &buf) orelse { + // Blank this path so that we don't attempt to resolve it again self.value.items[i] = .{ .required = "" }; continue; + }; + + log.debug( + "expanding file path from home directory: path={s}", + .{expanded}, + ); + + switch (self.value.items[i]) { + .optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded), } + continue; } const abs = dir.realpath(path, &buf) catch |err| abs: { diff --git a/src/os/homedir.zig b/src/os/homedir.zig index b03e7f354..aea7a0017 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -115,7 +115,16 @@ fn trimSpace(input: []const u8) []const u8 { /// Errors if `home` fails or if the size of the expanded path is larger than `buf.len`. /// /// Returns null if the value returned from `home` is null, otherwise returns a slice to the expanded path. -pub fn expandHome(path: []const u8, buf: []u8) !?[]u8 { +pub inline fn expandHome(path: []const u8, buf: []u8) !?[]u8 { + return switch (builtin.os.tag) { + inline .linux, .macos => expandHomeUnix(path, buf), + .ios => return null, + else => @compileError("unimplemented"), + }; +} + +fn expandHomeUnix(path: []const u8, buf: []u8) !?[]u8 { + if (!std.mem.startsWith(u8, path, "~/")) return null; const home_dir = try home(buf) orelse return null; const rest = path[1..]; // Skip the ~ const expanded_len = home_dir.len + rest.len; From 7bd842a53066b65350b5445f7c4e81b96440dddc Mon Sep 17 00:00:00 2001 From: z-jxy Date: Sun, 29 Dec 2024 05:22:39 -0500 Subject: [PATCH 051/138] add test for `expandHomeUnix` --- src/os/homedir.zig | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/os/homedir.zig b/src/os/homedir.zig index aea7a0017..b0247225b 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -110,7 +110,7 @@ fn trimSpace(input: []const u8) []const u8 { return std.mem.trim(u8, input, " \n\t"); } -/// Expands a path that starts with a tilde (~) to the home directory of the user. +/// Expands a path that starts with a tilde (~) to the home directory of the current user. /// /// Errors if `home` fails or if the size of the expanded path is larger than `buf.len`. /// @@ -135,6 +135,35 @@ fn expandHomeUnix(path: []const u8, buf: []u8) !?[]u8 { return buf[0..expanded_len]; } +test "expandHomeUnix" { + const testing = std.testing; + const allocator = testing.allocator; + var buf: [std.fs.max_path_bytes]u8 = undefined; + const home_dir = (try expandHomeUnix("~/", &buf)).?; + // Joining the home directory `~` with the path `/` + // the result should end with a separator here. (e.g. `/home/user/`) + try testing.expect(home_dir[home_dir.len - 1] == std.fs.path.sep); + + const downloads = (try expandHomeUnix("~/Downloads/shader.glsl", &buf)).?; + const expected_downloads = try std.mem.concat(allocator, u8, &[_][]const u8{ home_dir, "Downloads/shader.glsl" }); + defer allocator.free(expected_downloads); + try testing.expectEqualStrings(expected_downloads, downloads); + + try testing.expect(try expandHomeUnix("~", &buf) == null); + try testing.expect(try expandHomeUnix("~abc/", &buf) == null); + try testing.expect(try expandHomeUnix("/home/user", &buf) == null); + try testing.expect(try expandHomeUnix("", &buf) == null); + + // Expect an error if the buffer is large enough to hold the home directory, + // but not the expanded path + var small_buf = try allocator.alloc(u8, home_dir.len); + defer allocator.free(small_buf); + try testing.expectError(error.BufferTooSmall, expandHomeUnix( + "~/Downloads", + small_buf[0..], + )); +} + test { const testing = std.testing; From a94cf4b3a2277c912ede1afd3d46a65b9a891551 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 12:43:50 -0800 Subject: [PATCH 052/138] config: make diagnostic if homedir expansion fails --- src/config/Config.zig | 22 ++++++++++++++++++--- src/os/homedir.zig | 46 +++++++++++++++++++++++++------------------ 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 27b5f9d03..171d9dd12 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4362,11 +4362,26 @@ pub const RepeatablePath = struct { // to the base. var buf: [std.fs.max_path_bytes]u8 = undefined; - // Check if the path starts with a tilde and expand it to the home directory on linux/mac + // Check if the path starts with a tilde and expand it to the + // home directory on Linux/macOS. We explicitly look for "~/" + // because we don't support alternate users such as "~alice/" if (std.mem.startsWith(u8, path, "~/")) { - const expanded: []u8 = try internal_os.expandHome(path, &buf) orelse { - // Blank this path so that we don't attempt to resolve it again + const expanded: []const u8 = internal_os.expandHome( + path, + &buf, + ) catch |err| { + try diags.append(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "error expanding home directory for path {s}: {}", + .{ path, err }, + ), + }); + + // Blank this path so that we don't attempt to resolve it + // again self.value.items[i] = .{ .required = "" }; + continue; }; @@ -4378,6 +4393,7 @@ pub const RepeatablePath = struct { switch (self.value.items[i]) { .optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded), } + continue; } diff --git a/src/os/homedir.zig b/src/os/homedir.zig index b0247225b..b5629fd65 100644 --- a/src/os/homedir.zig +++ b/src/os/homedir.zig @@ -12,7 +12,7 @@ const Error = error{ /// Determine the home directory for the currently executing user. This /// is generally an expensive process so the value should be cached. -pub inline fn home(buf: []u8) !?[]u8 { +pub inline fn home(buf: []u8) !?[]const u8 { return switch (builtin.os.tag) { inline .linux, .macos => try homeUnix(buf), .windows => try homeWindows(buf), @@ -24,7 +24,7 @@ pub inline fn home(buf: []u8) !?[]u8 { }; } -fn homeUnix(buf: []u8) !?[]u8 { +fn homeUnix(buf: []u8) !?[]const u8 { // First: if we have a HOME env var, then we use that. if (posix.getenv("HOME")) |result| { if (buf.len < result.len) return Error.BufferTooSmall; @@ -77,7 +77,7 @@ fn homeUnix(buf: []u8) !?[]u8 { return null; } -fn homeWindows(buf: []u8) !?[]u8 { +fn homeWindows(buf: []u8) !?[]const u8 { const drive_len = blk: { var fba_instance = std.heap.FixedBufferAllocator.init(buf); const fba = fba_instance.allocator(); @@ -110,22 +110,30 @@ fn trimSpace(input: []const u8) []const u8 { return std.mem.trim(u8, input, " \n\t"); } -/// Expands a path that starts with a tilde (~) to the home directory of the current user. +pub const ExpandError = error{ + HomeDetectionFailed, + BufferTooSmall, +}; + +/// Expands a path that starts with a tilde (~) to the home directory of +/// the current user. /// -/// Errors if `home` fails or if the size of the expanded path is larger than `buf.len`. -/// -/// Returns null if the value returned from `home` is null, otherwise returns a slice to the expanded path. -pub inline fn expandHome(path: []const u8, buf: []u8) !?[]u8 { +/// Errors if `home` fails or if the size of the expanded path is larger +/// than `buf.len`. +pub fn expandHome(path: []const u8, buf: []u8) ExpandError![]const u8 { return switch (builtin.os.tag) { - inline .linux, .macos => expandHomeUnix(path, buf), - .ios => return null, + .linux, .macos => try expandHomeUnix(path, buf), + .ios => return path, else => @compileError("unimplemented"), }; } -fn expandHomeUnix(path: []const u8, buf: []u8) !?[]u8 { - if (!std.mem.startsWith(u8, path, "~/")) return null; - const home_dir = try home(buf) orelse return null; +fn expandHomeUnix(path: []const u8, buf: []u8) ExpandError![]const u8 { + if (!std.mem.startsWith(u8, path, "~/")) return path; + const home_dir: []const u8 = if (home(buf)) |home_| + home_ orelse return error.HomeDetectionFailed + else |_| + return error.HomeDetectionFailed; const rest = path[1..]; // Skip the ~ const expanded_len = home_dir.len + rest.len; @@ -139,20 +147,20 @@ test "expandHomeUnix" { const testing = std.testing; const allocator = testing.allocator; var buf: [std.fs.max_path_bytes]u8 = undefined; - const home_dir = (try expandHomeUnix("~/", &buf)).?; + const home_dir = try expandHomeUnix("~/", &buf); // Joining the home directory `~` with the path `/` // the result should end with a separator here. (e.g. `/home/user/`) try testing.expect(home_dir[home_dir.len - 1] == std.fs.path.sep); - const downloads = (try expandHomeUnix("~/Downloads/shader.glsl", &buf)).?; + const downloads = try expandHomeUnix("~/Downloads/shader.glsl", &buf); const expected_downloads = try std.mem.concat(allocator, u8, &[_][]const u8{ home_dir, "Downloads/shader.glsl" }); defer allocator.free(expected_downloads); try testing.expectEqualStrings(expected_downloads, downloads); - try testing.expect(try expandHomeUnix("~", &buf) == null); - try testing.expect(try expandHomeUnix("~abc/", &buf) == null); - try testing.expect(try expandHomeUnix("/home/user", &buf) == null); - try testing.expect(try expandHomeUnix("", &buf) == null); + try testing.expectEqualStrings("~", try expandHomeUnix("~", &buf)); + try testing.expectEqualStrings("~abc/", try expandHomeUnix("~abc/", &buf)); + try testing.expectEqualStrings("/home/user", try expandHomeUnix("/home/user", &buf)); + try testing.expectEqualStrings("", try expandHomeUnix("", &buf)); // Expect an error if the buffer is large enough to hold the home directory, // but not the expanded path From 713dd24ab9278190e8504b33f76b70297811520e Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 2 Jan 2025 15:47:56 -0500 Subject: [PATCH 053/138] macos: make auto-update optional When unset, we use Sparkle's default behavior, which is based on the user's preference stored in the standard user defaults. The rest of the previous behavior is preserved: - When SUEnableAutomaticChecks is explicitly false, auto-updates are disabled. - When 'auto-update' is set, use its value to set Sparkle's auto-update behavior. --- macos/Sources/App/macOS/AppDelegate.swift | 15 ++++++++------- macos/Sources/Ghostty/Ghostty.Config.swift | 11 +++++------ src/config/Config.zig | 5 +++-- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index e6199cccf..8564bbb1e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -486,15 +486,16 @@ class AppDelegate: NSObject, // Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is // explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior - // defined by our "auto-update" configuration. - if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool != false { - updaterController.updater.automaticallyChecksForUpdates = - config.autoUpdate == .check || config.autoUpdate == .download - updaterController.updater.automaticallyDownloadsUpdates = - config.autoUpdate == .download - } else { + // defined by our "auto-update" configuration (if set) or fall back to Sparkle + // user-based defaults. + if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false { updaterController.updater.automaticallyChecksForUpdates = false updaterController.updater.automaticallyDownloadsUpdates = false + } else if let autoUpdate = config.autoUpdate { + updaterController.updater.automaticallyChecksForUpdates = + autoUpdate == .check || autoUpdate == .download + updaterController.updater.automaticallyDownloadsUpdates = + autoUpdate == .download } // Config could change keybindings, so update everything that depends on that diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 1e733c5e1..077f04e9b 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -437,15 +437,14 @@ extension Ghostty { return v; } - var autoUpdate: AutoUpdate { - let defaultValue = AutoUpdate.check - guard let config = self.config else { return defaultValue } + var autoUpdate: AutoUpdate? { + guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "auto-update" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } - guard let ptr = v else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard let ptr = v else { return nil } let str = String(cString: ptr) - return AutoUpdate(rawValue: str) ?? defaultValue + return AutoUpdate(rawValue: str) } var autoUpdateChannel: AutoUpdateChannel { diff --git a/src/config/Config.zig b/src/config/Config.zig index 64fea91eb..3da552f32 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1967,10 +1967,11 @@ term: []const u8 = "xterm-ghostty", /// * `download` - Check for updates, automatically download the update, /// notify the user, but do not automatically install the update. /// -/// The default value is `check`. +/// If unset, we defer to Sparkle's default behavior, which respects the +/// preference stored in the standard user defaults (`defaults(1)`). /// /// Changing this value at runtime works after a small delay. -@"auto-update": AutoUpdate = .check, +@"auto-update": ?AutoUpdate = null, /// The release channel to use for auto-updates. /// From d58b618c74216004da556413c0fafad2e9650c5d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 12:55:38 -0800 Subject: [PATCH 054/138] config: windows can't expand homedir (yet) --- src/config/Config.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 171d9dd12..e9052a66e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4365,7 +4365,10 @@ pub const RepeatablePath = struct { // Check if the path starts with a tilde and expand it to the // home directory on Linux/macOS. We explicitly look for "~/" // because we don't support alternate users such as "~alice/" - if (std.mem.startsWith(u8, path, "~/")) { + if (std.mem.startsWith(u8, path, "~/")) expand: { + // Windows isn't supported yet + if (comptime builtin.os.tag == .windows) break :expand; + const expanded: []const u8 = internal_os.expandHome( path, &buf, From 5ced72498e122bcdf0b5c390f72a389ddbf2f1f5 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Sun, 29 Dec 2024 11:23:54 -0600 Subject: [PATCH 055/138] feat(linux): allow setting an intial start position --- include/ghostty.h | 1 + src/Surface.zig | 10 ++++++++++ src/apprt/action.zig | 11 +++++++++++ src/apprt/glfw.zig | 17 +++++++++++++++++ src/apprt/gtk/App.zig | 15 +++++++++++++++ src/apprt/gtk/Surface.zig | 6 ++++++ src/config/Config.zig | 13 +++++++++++++ 7 files changed, 73 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 4b8d409e9..dc147de0d 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -577,6 +577,7 @@ typedef enum { GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_INITIAL_SIZE, + GHOSTTY_ACTION_INITIAL_POSITION, GHOSTTY_ACTION_CELL_SIZE, GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, diff --git a/src/Surface.zig b/src/Surface.zig index c359efd8a..ace392bb3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -621,6 +621,8 @@ pub fn init( const width = @max(config.@"window-width" * cell_size.width, 640); const width_f32: f32 = @floatFromInt(width); const height_f32: f32 = @floatFromInt(height); + const position_x = config.@"window-position-x"; + const position_y = config.@"window-position-y"; // The final values are affected by content scale and we need to // account for the padding so we get the exact correct grid size. @@ -642,6 +644,14 @@ pub fn init( // an initial size shouldn't stop our terminal from working. log.warn("unable to set initial window size: {s}", .{err}); }; + + rt_app.performAction( + .{ .surface = self }, + .initial_position, + .{ .x = position_x, .y = position_y }, + ) catch |err| { + log.warn("unable to set initial window position: {s}", .{err}); + }; } if (config.title) |title| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index de6758d6c..35ad3c0ce 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -136,6 +136,11 @@ pub const Action = union(Key) { /// after the surface is initialized it should be ignored. initial_size: InitialSize, + // Specifies the initial position of the target terminal. This will be + // sent only during the initialization of a surface. If it is received + // after the surface is initialized it should be ignored. + initial_position: InitialPosition, + /// The cell size has changed to the given dimensions in pixels. cell_size: CellSize, @@ -237,6 +242,7 @@ pub const Action = union(Key) { present_terminal, size_limit, initial_size, + initial_position, cell_size, inspector, render_inspector, @@ -427,6 +433,11 @@ pub const InitialSize = extern struct { height: u32, }; +pub const InitialPosition = extern struct { + x: i32, + y: i32, +}; + pub const CellSize = extern struct { width: u32, height: u32, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 64b0cbe81..a17b18a29 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -178,6 +178,14 @@ pub const App = struct { ), }, + .initial_position => switch (target) { + .app => {}, + .surface => |surface| surface.rt_surface.setInitialWindowPosition( + value.x, + value.y, + ), + }, + .toggle_fullscreen => self.toggleFullscreen(target), .open_config => try configpkg.edit.open(self.app.alloc), @@ -663,6 +671,15 @@ pub const Surface = struct { }); } + /// Set the initial window position. This is called exactly once at + /// surface initialization time. This may be called before "self" + /// is fully initialized. + fn setInitialWindowPosition(self: *const Surface, x: i32, y: i32) void { + log.debug("setting initial window position ({},{})", .{ x, y }); + + self.window.setPos(.{ .x = x, .y = y }); + } + /// Set the size limits of the window. /// Note: this interface is not good, we should redo it if we plan /// to use this more. i.e. you can't set max width but no max height, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c10ba7993..24e1f4346 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -786,6 +786,21 @@ fn setInitialSize( ), } } + +fn setInitialPosition( + _: *App, + target: apprt.Target, + value: apprt.action.InitialPosition, +) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.setInitialWindowPosition( + value.x, + value.y, + ), + } +} + fn showDesktopNotification( self: *App, target: apprt.Target, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 079cdbd81..546d5ac33 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -840,6 +840,12 @@ pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void ); } +pub fn setInitialWindowPosition(self: *const Surface, x: i32, y: i32) !void { + // We need the surface's window to set the position. + const window = self.container.window() orelse return; + c.gtk_window_move(@ptrCast(window.window), x, y); +} + 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 diff --git a/src/config/Config.zig b/src/config/Config.zig index 64fea91eb..13a25ad5a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1108,6 +1108,19 @@ keybind: Keybinds = .{}, @"window-height": u32 = 0, @"window-width": u32 = 0, +/// The initial window position. This position is in pixels and is relative +/// to the top-left corner of the screen. Both values must be set to take +/// effect. If only one value is set, it is ignored. +/// +/// Note that the window manager may put limits on the position or override +/// the position. For example, a tiling window manager may force the window +/// to be a certain position to fit within the grid. There is nothing Ghostty +/// will do about this, but it will make an effort. +/// +/// This will default to the top-left corner of the screen if not set (0, 0). +@"window-position-x": i32 = 0, +@"window-position-y": i32 = 0, + /// 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 9a58de6d5a5af905e6bf0347d68e9722f77f85f2 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Sun, 29 Dec 2024 11:49:08 -0600 Subject: [PATCH 056/138] feat(macos): allow setting an intial start position --- include/ghostty.h | 7 +++++++ macos/Sources/Ghostty/Ghostty.App.swift | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index dc147de0d..6dab5c27d 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -514,6 +514,12 @@ typedef struct { uint32_t height; } ghostty_action_initial_size_s; +// apprt.action.InitialPosition +typedef struct { + int32_t x; + int32_t y; +} ghostty_action_initial_position_s; + // apprt.action.CellSize typedef struct { uint32_t width; @@ -606,6 +612,7 @@ typedef union { ghostty_action_resize_split_s resize_split; ghostty_action_size_limit_s size_limit; ghostty_action_initial_size_s initial_size; + ghostty_action_initial_position_s initial_position; ghostty_action_cell_size_s cell_size; ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2d9822d6e..2d679122a 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -517,6 +517,9 @@ extension Ghostty { case GHOSTTY_ACTION_INITIAL_SIZE: setInitialSize(app, target: target, v: action.action.initial_size) + case GHOSTTY_ACTION_INITIAL_POSITION: + setInitialPosition(app, target: target, v: action.action.initial_position) + case GHOSTTY_ACTION_CELL_SIZE: setCellSize(app, target: target, v: action.action.cell_size) @@ -1069,6 +1072,26 @@ extension Ghostty { } } + private static func setInitialPosition( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_initial_position_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("mouse over link 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 } + surfaceView.initialPosition = NSMakePoint(Double(v.x), Double(v.y)) + + + default: + assertionFailure() + } + } + private static func setCellSize( _ app: ghostty_app_t, target: ghostty_target_s, From 7195bda7a24cc7abbb19ffeccce56d2ad1bc0a8d Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Sun, 29 Dec 2024 13:08:34 -0600 Subject: [PATCH 057/138] chore: add missing case in switch statement --- src/apprt/glfw.zig | 4 ++-- src/apprt/gtk/App.zig | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index a17b18a29..8f5519a56 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -180,7 +180,7 @@ pub const App = struct { .initial_position => switch (target) { .app => {}, - .surface => |surface| surface.rt_surface.setInitialWindowPosition( + .surface => |surface| try surface.rt_surface.setInitialWindowPosition( value.x, value.y, ), @@ -674,7 +674,7 @@ pub const Surface = struct { /// Set the initial window position. This is called exactly once at /// surface initialization time. This may be called before "self" /// is fully initialized. - fn setInitialWindowPosition(self: *const Surface, x: i32, y: i32) void { + fn setInitialWindowPosition(self: *const Surface, x: i32, y: i32) !void { log.debug("setting initial window position ({},{})", .{ x, y }); self.window.setPos(.{ .x = x, .y = y }); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 24e1f4346..c7331968f 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -474,6 +474,7 @@ pub fn performAction( .pwd => try self.setPwd(target, value), .present_terminal => self.presentTerminal(target), .initial_size => try self.setInitialSize(target, value), + .initial_position => self.setInitialPosition(target, value), .mouse_visibility => self.setMouseVisibility(target, value), .mouse_shape => try self.setMouseShape(target, value), .mouse_over_link => self.setMouseOverLink(target, value), @@ -794,7 +795,7 @@ fn setInitialPosition( ) void { switch (target) { .app => {}, - .surface => |v| v.rt_surface.setInitialWindowPosition( + .surface => |v| try v.rt_surface.setInitialWindowPosition( value.x, value.y, ), From 568f1f9d720365ee6e5d7b3c71c85b5f9774ccf9 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Sun, 29 Dec 2024 21:33:44 -0600 Subject: [PATCH 058/138] chore: removed setInitialWindowPosition from gtk and renamed window-position-{x,y} to start-position-{x,y} for clarity --- src/Surface.zig | 4 ++-- src/apprt/gtk/App.zig | 15 --------------- src/apprt/gtk/Surface.zig | 6 ------ src/config/Config.zig | 7 +++++-- 4 files changed, 7 insertions(+), 25 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index ace392bb3..ccc3ba0ce 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -621,8 +621,8 @@ pub fn init( const width = @max(config.@"window-width" * cell_size.width, 640); const width_f32: f32 = @floatFromInt(width); const height_f32: f32 = @floatFromInt(height); - const position_x = config.@"window-position-x"; - const position_y = config.@"window-position-y"; + const position_x = config.@"start-position-x"; + const position_y = config.@"start-position-y"; // The final values are affected by content scale and we need to // account for the padding so we get the exact correct grid size. diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c7331968f..3d05bc2c5 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -474,7 +474,6 @@ pub fn performAction( .pwd => try self.setPwd(target, value), .present_terminal => self.presentTerminal(target), .initial_size => try self.setInitialSize(target, value), - .initial_position => self.setInitialPosition(target, value), .mouse_visibility => self.setMouseVisibility(target, value), .mouse_shape => try self.setMouseShape(target, value), .mouse_over_link => self.setMouseOverLink(target, value), @@ -788,20 +787,6 @@ fn setInitialSize( } } -fn setInitialPosition( - _: *App, - target: apprt.Target, - value: apprt.action.InitialPosition, -) void { - switch (target) { - .app => {}, - .surface => |v| try v.rt_surface.setInitialWindowPosition( - value.x, - value.y, - ), - } -} - fn showDesktopNotification( self: *App, target: apprt.Target, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 546d5ac33..079cdbd81 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -840,12 +840,6 @@ pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void ); } -pub fn setInitialWindowPosition(self: *const Surface, x: i32, y: i32) !void { - // We need the surface's window to set the position. - const window = self.container.window() orelse return; - c.gtk_window_move(@ptrCast(window.window), x, y); -} - 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 diff --git a/src/config/Config.zig b/src/config/Config.zig index 13a25ad5a..5ff110cf4 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1117,9 +1117,12 @@ keybind: Keybinds = .{}, /// to be a certain position to fit within the grid. There is nothing Ghostty /// will do about this, but it will make an effort. /// +/// Important note: Setting this value will only work on macOs and glfw builds +/// on Linux. GTK 4.0 does not support setting the window position. +/// /// This will default to the top-left corner of the screen if not set (0, 0). -@"window-position-x": i32 = 0, -@"window-position-y": i32 = 0, +@"start-position-x": i32 = 0, +@"start-position-y": i32 = 0, /// Whether to enable saving and restoring window state. Window state includes /// their position, size, tabs, splits, etc. Some window state requires shell From a7e3e5915c73228b5dcbed1c42f21b290151dc1e Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Sun, 29 Dec 2024 21:55:21 -0600 Subject: [PATCH 059/138] docs: fix spelling of macOS --- 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 5ff110cf4..ad862540a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1117,7 +1117,7 @@ keybind: Keybinds = .{}, /// to be a certain position to fit within the grid. There is nothing Ghostty /// will do about this, but it will make an effort. /// -/// Important note: Setting this value will only work on macOs and glfw builds +/// Important note: Setting this value will only work on macOS and glfw builds /// on Linux. GTK 4.0 does not support setting the window position. /// /// This will default to the top-left corner of the screen if not set (0, 0). From 13d935a401004918759072585fd68555b23f68b5 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Sun, 29 Dec 2024 21:56:09 -0600 Subject: [PATCH 060/138] revert: renaming of window-position-{x,y} --- src/Surface.zig | 4 ++-- src/config/Config.zig | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index ccc3ba0ce..ace392bb3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -621,8 +621,8 @@ pub fn init( const width = @max(config.@"window-width" * cell_size.width, 640); const width_f32: f32 = @floatFromInt(width); const height_f32: f32 = @floatFromInt(height); - const position_x = config.@"start-position-x"; - const position_y = config.@"start-position-y"; + const position_x = config.@"window-position-x"; + const position_y = config.@"window-position-y"; // The final values are affected by content scale and we need to // account for the padding so we get the exact correct grid size. diff --git a/src/config/Config.zig b/src/config/Config.zig index ad862540a..c9cf188b5 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1121,8 +1121,8 @@ keybind: Keybinds = .{}, /// on Linux. GTK 4.0 does not support setting the window position. /// /// This will default to the top-left corner of the screen if not set (0, 0). -@"start-position-x": i32 = 0, -@"start-position-y": i32 = 0, +@"window-position-x": i32 = 0, +@"window-position-y": i32 = 0, /// Whether to enable saving and restoring window state. Window state includes /// their position, size, tabs, splits, etc. Some window state requires shell From 970e45559b4e0a6677bcc5cf5b77311bd48b8d21 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Mon, 30 Dec 2024 07:39:56 -0600 Subject: [PATCH 061/138] apprt/glfw: handle setting initial window position when window is created --- include/ghostty.h | 8 -------- src/Surface.zig | 10 ---------- src/apprt/action.zig | 11 ----------- src/apprt/glfw.zig | 28 +++++++++++++--------------- src/config/Config.zig | 4 ++-- 5 files changed, 15 insertions(+), 46 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6dab5c27d..4b8d409e9 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -514,12 +514,6 @@ typedef struct { uint32_t height; } ghostty_action_initial_size_s; -// apprt.action.InitialPosition -typedef struct { - int32_t x; - int32_t y; -} ghostty_action_initial_position_s; - // apprt.action.CellSize typedef struct { uint32_t width; @@ -583,7 +577,6 @@ typedef enum { GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_INITIAL_SIZE, - GHOSTTY_ACTION_INITIAL_POSITION, GHOSTTY_ACTION_CELL_SIZE, GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, @@ -612,7 +605,6 @@ typedef union { ghostty_action_resize_split_s resize_split; ghostty_action_size_limit_s size_limit; ghostty_action_initial_size_s initial_size; - ghostty_action_initial_position_s initial_position; ghostty_action_cell_size_s cell_size; ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; diff --git a/src/Surface.zig b/src/Surface.zig index ace392bb3..c359efd8a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -621,8 +621,6 @@ pub fn init( const width = @max(config.@"window-width" * cell_size.width, 640); const width_f32: f32 = @floatFromInt(width); const height_f32: f32 = @floatFromInt(height); - const position_x = config.@"window-position-x"; - const position_y = config.@"window-position-y"; // The final values are affected by content scale and we need to // account for the padding so we get the exact correct grid size. @@ -644,14 +642,6 @@ pub fn init( // an initial size shouldn't stop our terminal from working. log.warn("unable to set initial window size: {s}", .{err}); }; - - rt_app.performAction( - .{ .surface = self }, - .initial_position, - .{ .x = position_x, .y = position_y }, - ) catch |err| { - log.warn("unable to set initial window position: {s}", .{err}); - }; } if (config.title) |title| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 35ad3c0ce..de6758d6c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -136,11 +136,6 @@ pub const Action = union(Key) { /// after the surface is initialized it should be ignored. initial_size: InitialSize, - // Specifies the initial position of the target terminal. This will be - // sent only during the initialization of a surface. If it is received - // after the surface is initialized it should be ignored. - initial_position: InitialPosition, - /// The cell size has changed to the given dimensions in pixels. cell_size: CellSize, @@ -242,7 +237,6 @@ pub const Action = union(Key) { present_terminal, size_limit, initial_size, - initial_position, cell_size, inspector, render_inspector, @@ -433,11 +427,6 @@ pub const InitialSize = extern struct { height: u32, }; -pub const InitialPosition = extern struct { - x: i32, - y: i32, -}; - pub const CellSize = extern struct { width: u32, height: u32, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 8f5519a56..3481e4833 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -149,10 +149,14 @@ pub const App = struct { value: apprt.Action.Value(action), ) !void { switch (action) { - .new_window => _ = try self.newSurface(switch (target) { - .app => null, - .surface => |v| v, - }), + .new_window => { + var surface = try self.newSurface(switch (target) { + .app => null, + .surface => |v| v, + }); + + try surface.setInitialWindowPosition(self.config.@"window-position-x", self.config.@"window-position-y"); + }, .new_tab => try self.newTab(switch (target) { .app => null, @@ -178,14 +182,6 @@ pub const App = struct { ), }, - .initial_position => switch (target) { - .app => {}, - .surface => |surface| try surface.rt_surface.setInitialWindowPosition( - value.x, - value.y, - ), - }, - .toggle_fullscreen => self.toggleFullscreen(target), .open_config => try configpkg.edit.open(self.app.alloc), @@ -674,10 +670,12 @@ pub const Surface = struct { /// Set the initial window position. This is called exactly once at /// surface initialization time. This may be called before "self" /// is fully initialized. - fn setInitialWindowPosition(self: *const Surface, x: i32, y: i32) !void { - log.debug("setting initial window position ({},{})", .{ x, y }); + fn setInitialWindowPosition(self: *const Surface, x: ?i16, y: ?i16) !void { + const start_position_x = x orelse return; + const start_position_y = y orelse return; - self.window.setPos(.{ .x = x, .y = y }); + log.debug("setting initial window position ({},{})", .{ start_position_x, start_position_y }); + self.window.setPos(.{ .x = start_position_x, .y = start_position_y }); } /// Set the size limits of the window. diff --git a/src/config/Config.zig b/src/config/Config.zig index c9cf188b5..7fba7ddd9 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1121,8 +1121,8 @@ keybind: Keybinds = .{}, /// on Linux. GTK 4.0 does not support setting the window position. /// /// This will default to the top-left corner of the screen if not set (0, 0). -@"window-position-x": i32 = 0, -@"window-position-y": i32 = 0, +@"window-position-x": ?i16 = null, +@"window-position-y": ?i16 = null, /// Whether to enable saving and restoring window state. Window state includes /// their position, size, tabs, splits, etc. Some window state requires shell From 200d0d642be5a10fd9de0de0ddb4583b8deabd59 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Tue, 31 Dec 2024 00:50:07 -0600 Subject: [PATCH 062/138] macos: handle setting initial window position when window is created --- .../Terminal/TerminalController.swift | 33 ++++++++++++++++--- macos/Sources/Ghostty/Ghostty.App.swift | 23 ------------- macos/Sources/Ghostty/Ghostty.Config.swift | 14 ++++++++ src/config/c_get.zig | 5 +++ 4 files changed, 48 insertions(+), 27 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 331f26c97..873bbe7cc 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -368,10 +368,10 @@ class TerminalController: BaseTerminalController { } } - // Center the window to start, we'll move the window frame automatically - // when cascading. - window.center() - + // Set our window positioning to coordinates if config value exists, otherwise + // fallback to original centering behavior + setInitialWindowPosition(window, x: config.windowPositionX, y: config.windowPositionY, windowDecorations: config.windowDecorations) + // Make sure our theme is set on the window so styling is correct. if let windowTheme = config.windowTheme { window.windowTheme = .init(rawValue: windowTheme) @@ -468,6 +468,31 @@ class TerminalController: BaseTerminalController { let data = TerminalRestorableState(from: self) data.encode(with: state) } + + func setInitialWindowPosition(_ window: NSWindow, x: Int16?, y: Int16?, windowDecorations: Bool) { + if let primaryScreen = NSScreen.screens.first { + let frame = primaryScreen.visibleFrame + + if let windowPositionX = x, let windowPositionY = y { + // Offset titlebar if needed, otherwise use default padding of 12 + // NOTE: Not 100% certain where this extra padding comes from but I'd love + // to calculate it dynamically if possible + let titlebarHeight = windowDecorations ? window.frame.height - (window.contentView?.frame.height ?? 0) : 12 + + // Orient based on the top left of the primary monitor + let startPositionX = frame.origin.x + CGFloat(windowPositionX) + let startPositionY = (frame.origin.y + frame.height) - (CGFloat(windowPositionY) + window.frame.height) + titlebarHeight + + window.setFrameOrigin(NSPoint(x: startPositionX, y: startPositionY)) + } else { + // Fallback to original centering behavior + window.center() + } + } else { + // Fallback to original centering behavior + window.center() + } + } // MARK: First Responder diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2d679122a..2d9822d6e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -517,9 +517,6 @@ extension Ghostty { case GHOSTTY_ACTION_INITIAL_SIZE: setInitialSize(app, target: target, v: action.action.initial_size) - case GHOSTTY_ACTION_INITIAL_POSITION: - setInitialPosition(app, target: target, v: action.action.initial_position) - case GHOSTTY_ACTION_CELL_SIZE: setCellSize(app, target: target, v: action.action.cell_size) @@ -1072,26 +1069,6 @@ extension Ghostty { } } - private static func setInitialPosition( - _ app: ghostty_app_t, - target: ghostty_target_s, - v: ghostty_action_initial_position_s) { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - Ghostty.logger.warning("mouse over link 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 } - surfaceView.initialPosition = NSMakePoint(Double(v.x), Double(v.y)) - - - default: - assertionFailure() - } - } - private static func setCellSize( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 1e733c5e1..82d17a2de 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -149,6 +149,20 @@ extension Ghostty { guard let ptr = v else { return "" } return String(cString: ptr) } + + var windowPositionX: Int16? { + guard let config = self.config else { return nil } + var v: Int16 = 0 + let key = "window-position-x" + return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + } + + var windowPositionY: Int16? { + guard let config = self.config else { return nil } + var v: Int16 = 0 + let key = "window-position-y" + return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + } var windowNewTabPosition: String { guard let config = self.config else { return "" } diff --git a/src/config/c_get.zig b/src/config/c_get.zig index dd7c7cce8..d3f38415e 100644 --- a/src/config/c_get.zig +++ b/src/config/c_get.zig @@ -42,6 +42,11 @@ fn getValue(ptr_raw: *anyopaque, value: anytype) bool { ptr.* = @intCast(value); }, + i16 => { + const ptr: *c_short = @ptrCast(@alignCast(ptr_raw)); + ptr.* = @intCast(value); + }, + f32, f64 => |Float| { const ptr: *Float = @ptrCast(@alignCast(ptr_raw)); ptr.* = @floatCast(value); From 16bf3b8820c8e1948740c46405dfe2ef02e58517 Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Tue, 31 Dec 2024 00:51:13 -0600 Subject: [PATCH 063/138] docs: update config docs to reflect window positioning changes --- src/config/Config.zig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 7fba7ddd9..93f107bb2 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1109,7 +1109,7 @@ keybind: Keybinds = .{}, @"window-width": u32 = 0, /// The initial window position. This position is in pixels and is relative -/// to the top-left corner of the screen. Both values must be set to take +/// to the top-left corner of the primary monitor. Both values must be set to take /// effect. If only one value is set, it is ignored. /// /// Note that the window manager may put limits on the position or override @@ -1119,8 +1119,6 @@ keybind: Keybinds = .{}, /// /// Important note: Setting this value will only work on macOS and glfw builds /// on Linux. GTK 4.0 does not support setting the window position. -/// -/// This will default to the top-left corner of the screen if not set (0, 0). @"window-position-x": ?i16 = null, @"window-position-y": ?i16 = null, From f9250e28b52c779d154fbf13db74b418e74c172c Mon Sep 17 00:00:00 2001 From: Adam Wolf Date: Tue, 31 Dec 2024 10:35:23 -0600 Subject: [PATCH 064/138] chore: rename window-position-{x,y} to window-initial-position-{x,y} --- macos/Sources/Features/Terminal/TerminalController.swift | 2 +- macos/Sources/Ghostty/Ghostty.Config.swift | 8 ++++---- src/apprt/glfw.zig | 2 +- src/config/Config.zig | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 873bbe7cc..e3e5ac3f6 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -370,7 +370,7 @@ class TerminalController: BaseTerminalController { // Set our window positioning to coordinates if config value exists, otherwise // fallback to original centering behavior - setInitialWindowPosition(window, x: config.windowPositionX, y: config.windowPositionY, windowDecorations: config.windowDecorations) + setInitialWindowPosition(window, x: config.windowInitialPositionX, y: config.windowInitialPositionY, windowDecorations: config.windowDecorations) // Make sure our theme is set on the window so styling is correct. if let windowTheme = config.windowTheme { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 82d17a2de..b6ae5de96 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -150,17 +150,17 @@ extension Ghostty { return String(cString: ptr) } - var windowPositionX: Int16? { + var windowInitialPositionX: Int16? { guard let config = self.config else { return nil } var v: Int16 = 0 - let key = "window-position-x" + let key = "window-initial-position-x" return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil } - var windowPositionY: Int16? { + var windowInitialPositionY: Int16? { guard let config = self.config else { return nil } var v: Int16 = 0 - let key = "window-position-y" + let key = "window-initial-position-y" return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil } diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 3481e4833..5f48e0dd7 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -155,7 +155,7 @@ pub const App = struct { .surface => |v| v, }); - try surface.setInitialWindowPosition(self.config.@"window-position-x", self.config.@"window-position-y"); + try surface.setInitialWindowPosition(self.config.@"window-initial-position-x", self.config.@"window-initial-position-y"); }, .new_tab => try self.newTab(switch (target) { diff --git a/src/config/Config.zig b/src/config/Config.zig index 93f107bb2..7d8d648df 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1119,8 +1119,8 @@ keybind: Keybinds = .{}, /// /// Important note: Setting this value will only work on macOS and glfw builds /// on Linux. GTK 4.0 does not support setting the window position. -@"window-position-x": ?i16 = null, -@"window-position-y": ?i16 = null, +@"window-initial-position-x": ?i16 = null, +@"window-initial-position-y": ?i16 = null, /// Whether to enable saving and restoring window state. Window state includes /// their position, size, tabs, splits, etc. Some window state requires shell From 29b96be84fb3e1498172e02cdc42a481b4e2e998 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 13:04:30 -0800 Subject: [PATCH 065/138] tweaks to window position --- .../Terminal/TerminalController.swift | 54 +++++++++---------- macos/Sources/Ghostty/Ghostty.Config.swift | 8 +-- src/apprt/glfw.zig | 23 ++++---- src/config/Config.zig | 22 ++++++-- 4 files changed, 61 insertions(+), 46 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index e3e5ac3f6..1e8e4c214 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -273,6 +273,28 @@ class TerminalController: BaseTerminalController { } } + private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) { + guard let window else { return } + + // If we don't have both an X and Y we center. + guard let x, let y else { + window.center() + return + } + + // Prefer the screen our window is being placed on otherwise our primary screen. + guard let screen = window.screen ?? NSScreen.screens.first else { + window.center() + return + } + + // Orient based on the top left of the primary monitor + let frame = screen.visibleFrame + window.setFrameOrigin(.init( + x: frame.minX + CGFloat(x), + y: frame.maxY - (CGFloat(y) + window.frame.height))) + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -370,8 +392,11 @@ class TerminalController: BaseTerminalController { // Set our window positioning to coordinates if config value exists, otherwise // fallback to original centering behavior - setInitialWindowPosition(window, x: config.windowInitialPositionX, y: config.windowInitialPositionY, windowDecorations: config.windowDecorations) - + setInitialWindowPosition( + x: config.windowPositionX, + y: config.windowPositionY, + windowDecorations: config.windowDecorations) + // Make sure our theme is set on the window so styling is correct. if let windowTheme = config.windowTheme { window.windowTheme = .init(rawValue: windowTheme) @@ -468,31 +493,6 @@ class TerminalController: BaseTerminalController { let data = TerminalRestorableState(from: self) data.encode(with: state) } - - func setInitialWindowPosition(_ window: NSWindow, x: Int16?, y: Int16?, windowDecorations: Bool) { - if let primaryScreen = NSScreen.screens.first { - let frame = primaryScreen.visibleFrame - - if let windowPositionX = x, let windowPositionY = y { - // Offset titlebar if needed, otherwise use default padding of 12 - // NOTE: Not 100% certain where this extra padding comes from but I'd love - // to calculate it dynamically if possible - let titlebarHeight = windowDecorations ? window.frame.height - (window.contentView?.frame.height ?? 0) : 12 - - // Orient based on the top left of the primary monitor - let startPositionX = frame.origin.x + CGFloat(windowPositionX) - let startPositionY = (frame.origin.y + frame.height) - (CGFloat(windowPositionY) + window.frame.height) + titlebarHeight - - window.setFrameOrigin(NSPoint(x: startPositionX, y: startPositionY)) - } else { - // Fallback to original centering behavior - window.center() - } - } else { - // Fallback to original centering behavior - window.center() - } - } // MARK: First Responder diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index b6ae5de96..82d17a2de 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -150,17 +150,17 @@ extension Ghostty { return String(cString: ptr) } - var windowInitialPositionX: Int16? { + var windowPositionX: Int16? { guard let config = self.config else { return nil } var v: Int16 = 0 - let key = "window-initial-position-x" + let key = "window-position-x" return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil } - var windowInitialPositionY: Int16? { + var windowPositionY: Int16? { guard let config = self.config else { return nil } var v: Int16 = 0 - let key = "window-initial-position-y" + let key = "window-position-y" return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil } diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 5f48e0dd7..3fbef0f22 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -149,14 +149,10 @@ pub const App = struct { value: apprt.Action.Value(action), ) !void { switch (action) { - .new_window => { - var surface = try self.newSurface(switch (target) { - .app => null, - .surface => |v| v, - }); - - try surface.setInitialWindowPosition(self.config.@"window-initial-position-x", self.config.@"window-initial-position-y"); - }, + .new_window => _ = try self.newSurface(switch (target) { + .app => null, + .surface => |v| v, + }), .new_tab => try self.newTab(switch (target) { .app => null, @@ -514,6 +510,13 @@ pub const Surface = struct { ) orelse return glfw.mustGetErrorCode(); errdefer win.destroy(); + // Setup our + setInitialWindowPosition( + win, + app.config.@"window-position-x", + app.config.@"window-position-y", + ); + // Get our physical DPI - debug only because we don't have a use for // this but the logging of it may be useful if (builtin.mode == .Debug) { @@ -670,12 +673,12 @@ pub const Surface = struct { /// Set the initial window position. This is called exactly once at /// surface initialization time. This may be called before "self" /// is fully initialized. - fn setInitialWindowPosition(self: *const Surface, x: ?i16, y: ?i16) !void { + fn setInitialWindowPosition(win: glfw.Window, x: ?i16, y: ?i16) void { const start_position_x = x orelse return; const start_position_y = y orelse return; log.debug("setting initial window position ({},{})", .{ start_position_x, start_position_y }); - self.window.setPos(.{ .x = start_position_x, .y = start_position_y }); + win.setPos(.{ .x = start_position_x, .y = start_position_y }); } /// Set the size limits of the window. diff --git a/src/config/Config.zig b/src/config/Config.zig index 7d8d648df..153ede2e5 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1108,7 +1108,7 @@ keybind: Keybinds = .{}, @"window-height": u32 = 0, @"window-width": u32 = 0, -/// The initial window position. This position is in pixels and is relative +/// The starting window position. This position is in pixels and is relative /// to the top-left corner of the primary monitor. Both values must be set to take /// effect. If only one value is set, it is ignored. /// @@ -1117,10 +1117,22 @@ keybind: Keybinds = .{}, /// to be a certain position to fit within the grid. There is nothing Ghostty /// will do about this, but it will make an effort. /// -/// Important note: Setting this value will only work on macOS and glfw builds -/// on Linux. GTK 4.0 does not support setting the window position. -@"window-initial-position-x": ?i16 = null, -@"window-initial-position-y": ?i16 = null, +/// Also note that negative values are also up to the operating system and +/// window manager. Some window managers may not allow windows to be placed +/// off-screen. +/// +/// Invalid positions are runtime-specific, but generally the positions are +/// clamped to the nearest valid position. +/// +/// On macOS, the window position is relative to the top-left corner of +/// the visible screen area. This means that if the menu bar is visible, the +/// window will be placed below the menu bar. +/// +/// Note: this is only supported on macOS and Linux GLFW builds. The GTK +/// runtime does not support setting the window position (this is a limitation +/// of GTK 4.0). +@"window-position-x": ?i16 = null, +@"window-position-y": ?i16 = null, /// Whether to enable saving and restoring window state. Window state includes /// their position, size, tabs, splits, etc. Some window state requires shell From 0778c674299c72957ee980ce25ce999293e0d4ec Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 2 Nov 2024 13:10:22 -0500 Subject: [PATCH 066/138] gtk: refactor gtk & adw notebook implementations Put GTK and libadwaita notebook implementations into separate structs/ files for clarity. --- src/apprt/gtk/Tab.zig | 2 +- src/apprt/gtk/Window.zig | 21 +- src/apprt/gtk/notebook.zig | 449 +++++---------------------------- src/apprt/gtk/notebook_adw.zig | 162 ++++++++++++ src/apprt/gtk/notebook_gtk.zig | 283 +++++++++++++++++++++ 5 files changed, 518 insertions(+), 399 deletions(-) create mode 100644 src/apprt/gtk/notebook_adw.zig create mode 100644 src/apprt/gtk/notebook_gtk.zig diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index 82384a44a..ed0804fd3 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -76,7 +76,7 @@ pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void { // Set the userdata of the box to point to this tab. c.g_object_set_data(@ptrCast(box_widget), GHOSTTY_TAB, self); - try window.notebook.addTab(self, "Ghostty"); + window.notebook.addTab(self, "Ghostty"); // Attach all events _ = c.g_signal_connect_data(box_widget, "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 c9e274ea0..0bcb19cc0 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -122,12 +122,12 @@ pub fn init(self: *Window, app: *App) !void { const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); // Setup our notebook - self.notebook = Notebook.create(self); + self.notebook.init(); // If we are using Adwaita, then we can support the tab overview. self.tab_overview = if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 4, 0)) overview: { const tab_overview = c.adw_tab_overview_new(); - c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view); + c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view); c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1); _ = c.g_signal_connect_data( tab_overview, @@ -189,7 +189,7 @@ pub fn init(self: *Window, app: *App) !void { .hidden => btn: { const btn = c.adw_tab_button_new(); - c.adw_tab_button_set_view(@ptrCast(btn), self.notebook.adw_tab_view); + 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; }, @@ -267,8 +267,8 @@ pub fn init(self: *Window, app: *App) !void { // If we have a tab overview then we can set it on our notebook. if (self.tab_overview) |tab_overview| { if (comptime !adwaita.versionAtLeast(1, 3, 0)) unreachable; - assert(self.notebook == .adw_tab_view); - c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view); + assert(self.notebook == .adw); + c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw.tab_view); } self.context_menu = c.gtk_popover_menu_new_from_model(@ptrCast(@alignCast(self.app.context_menu))); @@ -305,7 +305,7 @@ pub fn init(self: *Window, app: *App) !void { if (self.app.config.@"gtk-tabs-location" != .hidden) { const tab_bar = c.adw_tab_bar_new(); - c.adw_tab_bar_set_view(tab_bar, self.notebook.adw_tab_view); + c.adw_tab_bar_set_view(tab_bar, self.notebook.adw.tab_view); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); @@ -338,9 +338,8 @@ pub fn init(self: *Window, app: *App) !void { ); } else tab_bar: { switch (self.notebook) { - .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) { + .adw => |*adw| if (comptime adwaita.versionAtLeast(0, 0, 0)) { if (app.config.@"gtk-tabs-location" == .hidden) break :tab_bar; - // In earlier adwaita versions, we need to add the tabbar manually since we do not use // an AdwToolbarView. const tab_bar: *c.AdwTabBar = c.adw_tab_bar_new().?; @@ -360,12 +359,12 @@ pub fn init(self: *Window, app: *App) !void { ), .hidden => unreachable, } - c.adw_tab_bar_set_view(tab_bar, tab_view); + c.adw_tab_bar_set_view(tab_bar, adw.tab_view); if (!app.config.@"gtk-wide-tabs") c.adw_tab_bar_set_expand_tabs(tab_bar, 0); }, - .gtk_notebook => {}, + .gtk => {}, } // The box is our main child @@ -570,7 +569,7 @@ fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwT const alloc = self.app.core_app.alloc; const surface = self.actionSurface(); const tab = Tab.create(alloc, self, surface) catch return null; - return c.adw_tab_view_get_page(self.notebook.adw_tab_view, @ptrCast(@alignCast(tab.box))); + return c.adw_tab_view_get_page(self.notebook.adw.tab_view, @ptrCast(@alignCast(tab.box))); } fn adwTabOverviewOpen( diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index 9d5f07f05..4676c2529 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -4,161 +4,76 @@ const c = @import("c.zig").c; const Window = @import("Window.zig"); const Tab = @import("Tab.zig"); +const NotebookAdw = @import("notebook_adw.zig").NotebookAdw; +const NotebookGtk = @import("notebook_gtk.zig").NotebookGtk; const adwaita = @import("adwaita.zig"); const log = std.log.scoped(.gtk); const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; +/// An abstraction over the GTK notebook and Adwaita tab view to manage +/// all the terminal tabs in a window. /// An abstraction over the GTK notebook and Adwaita tab view to manage /// all the terminal tabs in a window. pub const Notebook = union(enum) { - adw_tab_view: *AdwTabView, - gtk_notebook: *c.GtkNotebook, + adw: NotebookAdw, + gtk: NotebookGtk, - pub fn create(window: *Window) Notebook { + pub fn init(self: *Notebook) void { + const window: *Window = @fieldParentPtr("notebook", self); const app = window.app; - if (adwaita.enabled(&app.config)) return initAdw(window); - return initGtk(window); + if (adwaita.enabled(&app.config)) return NotebookAdw.init(self); + + return NotebookGtk.init(self); } - fn initGtk(window: *Window) Notebook { - const app = window.app; - - // Create a notebook to hold our tabs. - const notebook_widget: *c.GtkWidget = c.gtk_notebook_new(); - const notebook: *c.GtkNotebook = @ptrCast(notebook_widget); - const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { - .top, .hidden => c.GTK_POS_TOP, - .bottom => c.GTK_POS_BOTTOM, - .left => c.GTK_POS_LEFT, - .right => c.GTK_POS_RIGHT, - }; - c.gtk_notebook_set_tab_pos(notebook, notebook_tab_pos); - c.gtk_notebook_set_scrollable(notebook, 1); - c.gtk_notebook_set_show_tabs(notebook, 0); - c.gtk_notebook_set_show_border(notebook, 0); - - // This enables all Ghostty terminal tabs to be exchanged across windows. - c.gtk_notebook_set_group_name(notebook, "ghostty-terminal-tabs"); - - // This is important so the notebook expands to fit available space. - // Otherwise, it will be zero/zero in the box below. - c.gtk_widget_set_vexpand(notebook_widget, 1); - c.gtk_widget_set_hexpand(notebook_widget, 1); - - // Remove the background from the stack widget - const stack = c.gtk_widget_get_last_child(notebook_widget); - c.gtk_widget_add_css_class(stack, "transparent"); - - // All of our events - _ = c.g_signal_connect_data(notebook, "page-added", c.G_CALLBACK(>kPageAdded), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT); - _ = c.g_signal_connect_data(notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT); - - return .{ .gtk_notebook = notebook }; - } - - fn initAdw(window: *Window) Notebook { - const app = window.app; - assert(adwaita.enabled(&app.config)); - - const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; - - if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) { - // Adwaita enables all of the shortcuts by default. - // We want to manage keybindings ourselves. - c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); - } - - _ = 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, "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); - - return .{ .adw_tab_view = tab_view }; - } - - pub fn asWidget(self: Notebook) *c.GtkWidget { - return switch (self) { - .adw_tab_view => |tab_view| @ptrCast(@alignCast(tab_view)), - .gtk_notebook => |notebook| @ptrCast(@alignCast(notebook)), + pub fn asWidget(self: *Notebook) *c.GtkWidget { + return switch (self.*) { + .adw => |*adw| adw.asWidget(), + .gtk => |*gtk| gtk.asWidget(), }; } - pub fn nPages(self: Notebook) c_int { - return switch (self) { - .gtk_notebook => |notebook| c.gtk_notebook_get_n_pages(notebook), - .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) - c.adw_tab_view_get_n_pages(tab_view) - else - unreachable, + pub fn nPages(self: *Notebook) c_int { + return switch (self.*) { + .adw => |*adw| adw.nPages(), + .gtk => |*gtk| gtk.nPages(), }; } /// Returns the index of the currently selected page. /// Returns null if the notebook has no pages. - fn currentPage(self: Notebook) ?c_int { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null; - return c.adw_tab_view_get_page_position(tab_view, page); - }, - - .gtk_notebook => |notebook| { - const current = c.gtk_notebook_get_current_page(notebook); - return if (current == -1) null else current; - }, - } + fn currentPage(self: *Notebook) ?c_int { + return switch (self.*) { + .adw => |*adw| adw.currentPage(), + .gtk => |*gtk| gtk.currentPage(), + }; } /// Returns the currently selected tab or null if there are none. - pub fn currentTab(self: Notebook) ?*Tab { - const child = switch (self) { - .adw_tab_view => |tab_view| child: { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_selected_page(tab_view) orelse return null; - const child = c.adw_tab_page_get_child(page); - break :child child; - }, - - .gtk_notebook => |notebook| child: { - const page = self.currentPage() orelse return null; - break :child c.gtk_notebook_get_nth_page(notebook, page); - }, + pub fn currentTab(self: *Notebook) ?*Tab { + return switch (self.*) { + .adw => |*adw| adw.currentTab(), + .gtk => |*gtk| gtk.currentTab(), }; - return @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, - )); } - pub fn gotoNthTab(self: Notebook, position: c_int) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page_to_select = c.adw_tab_view_get_nth_page(tab_view, position); - c.adw_tab_view_set_selected_page(tab_view, page_to_select); - }, - .gtk_notebook => |notebook| c.gtk_notebook_set_current_page(notebook, position), + pub fn gotoNthTab(self: *Notebook, position: c_int) void { + switch (self.*) { + .adw => |*adw| adw.gotoNthTab(position), + .gtk => |*gtk| gtk.gotoNthTab(position), } } - pub fn getTabPosition(self: Notebook, tab: *Tab) ?c_int { - return switch (self) { - .adw_tab_view => |tab_view| page_idx: { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return null; - break :page_idx c.adw_tab_view_get_page_position(tab_view, page); - }, - .gtk_notebook => |notebook| page_idx: { - const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return null; - break :page_idx getNotebookPageIndex(page); - }, + pub fn getTabPosition(self: *Notebook, tab: *Tab) ?c_int { + return switch (self.*) { + .adw => |*adw| adw.getTabPosition(tab), + .gtk => |*gtk| gtk.getTabPosition(tab), }; } - pub fn gotoPreviousTab(self: Notebook, tab: *Tab) void { + pub fn gotoPreviousTab(self: *Notebook, tab: *Tab) void { const page_idx = self.getTabPosition(tab) orelse return; // The next index is the previous or we wrap around. @@ -173,7 +88,7 @@ pub const Notebook = union(enum) { self.gotoNthTab(next_idx); } - pub fn gotoNextTab(self: Notebook, tab: *Tab) void { + pub fn gotoNextTab(self: *Notebook, tab: *Tab) void { const page_idx = self.getTabPosition(tab) orelse return; const max = self.nPages() -| 1; @@ -183,7 +98,7 @@ pub const Notebook = union(enum) { self.gotoNthTab(next_idx); } - pub fn moveTab(self: Notebook, tab: *Tab, position: c_int) void { + pub fn moveTab(self: *Notebook, tab: *Tab, position: c_int) void { const page_idx = self.getTabPosition(tab) orelse return; const max = self.nPages() -| 1; @@ -199,42 +114,28 @@ pub const Notebook = union(enum) { self.reorderPage(tab, new_position); } - pub fn reorderPage(self: Notebook, tab: *Tab, position: c_int) void { - switch (self) { - .gtk_notebook => |notebook| { - c.gtk_notebook_reorder_child(notebook, @ptrCast(tab.box), position); - }, - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - _ = c.adw_tab_view_reorder_page(tab_view, page, position); - }, + pub fn reorderPage(self: *Notebook, tab: *Tab, position: c_int) void { + switch (self.*) { + .adw => |*adw| adw.reorderPage(tab, position), + .gtk => |*gtk| gtk.reorderPage(tab, position), } } - pub fn setTabLabel(self: Notebook, tab: *Tab, title: [:0]const u8) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - c.adw_tab_page_set_title(page, title.ptr); - }, - .gtk_notebook => c.gtk_label_set_text(tab.label_text, title.ptr), + pub fn setTabLabel(self: *Notebook, tab: *Tab, title: [:0]const u8) void { + switch (self.*) { + .adw => |*adw| adw.setTabLabel(tab, title), + .gtk => |*gtk| gtk.setTabLabel(tab, title), } } - pub fn setTabTooltip(self: Notebook, tab: *Tab, tooltip: [:0]const u8) void { - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)); - c.adw_tab_page_set_tooltip(page, tooltip.ptr); - }, - .gtk_notebook => c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr), + pub fn setTabTooltip(self: *Notebook, tab: *Tab, tooltip: [:0]const u8) void { + switch (self.*) { + .adw => |*adw| adw.setTabTooltip(tab, tooltip), + .gtk => |*gtk| gtk.setTabTooltip(tab, tooltip), } } - fn newTabInsertPosition(self: Notebook, tab: *Tab) c_int { + fn newTabInsertPosition(self: *Notebook, tab: *Tab) c_int { const numPages = self.nPages(); return switch (tab.window.app.config.@"window-new-tab-position") { .current => if (self.currentPage()) |page| page + 1 else numPages, @@ -243,249 +144,23 @@ pub const Notebook = union(enum) { } /// Adds a new tab with the given title to the notebook. - pub fn addTab(self: Notebook, tab: *Tab, title: [:0]const u8) !void { - const box_widget: *c.GtkWidget = @ptrCast(tab.box); - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - - const page = c.adw_tab_view_insert(tab_view, box_widget, self.newTabInsertPosition(tab)); - c.adw_tab_page_set_title(page, title.ptr); - - // Switch to the new tab - c.adw_tab_view_set_selected_page(tab_view, page); - }, - .gtk_notebook => |notebook| { - // Build the tab label - const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0); - const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget)); - const label_text_widget = c.gtk_label_new(title.ptr); - const label_text: *c.GtkLabel = @ptrCast(label_text_widget); - c.gtk_box_append(label_box, label_text_widget); - tab.label_text = label_text; - - const window = tab.window; - if (window.app.config.@"gtk-wide-tabs") { - c.gtk_widget_set_hexpand(label_box_widget, 1); - c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL); - c.gtk_widget_set_hexpand(label_text_widget, 1); - c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL); - - // This ensures that tabs are always equal width. If they're too - // long, they'll be truncated with an ellipsis. - c.gtk_label_set_max_width_chars(label_text, 1); - c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END); - - // We need to set a minimum width so that at a certain point - // the notebook will have an arrow button rather than shrinking tabs - // to an unreadably small size. - c.gtk_widget_set_size_request(label_text_widget, 100, 1); - } - - // Build the close button for the tab - const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic"); - const label_close: *c.GtkButton = @ptrCast(label_close_widget); - c.gtk_button_set_has_frame(label_close, 0); - c.gtk_box_append(label_box, label_close_widget); - - const page_idx = c.gtk_notebook_insert_page( - notebook, - box_widget, - label_box_widget, - self.newTabInsertPosition(tab), - ); - - // Clicks - const gesture_tab_click = c.gtk_gesture_click_new(); - 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); - - // Tab settings - c.gtk_notebook_set_tab_reorderable(notebook, box_widget, 1); - c.gtk_notebook_set_tab_detachable(notebook, box_widget, 1); - - if (self.nPages() > 1) { - c.gtk_notebook_set_show_tabs(notebook, 1); - } - - // Switch to the new tab - c.gtk_notebook_set_current_page(notebook, page_idx); - }, + pub fn addTab(self: *Notebook, tab: *Tab, title: [:0]const u8) void { + const position = self.newTabInsertPosition(tab); + switch (self.*) { + .adw => |*adw| adw.addTab(tab, position, title), + .gtk => |*gtk| gtk.addTab(tab, position, title), } } - pub fn closeTab(self: Notebook, tab: *Tab) void { - const window = tab.window; - switch (self) { - .adw_tab_view => |tab_view| { - if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; - - const page = c.adw_tab_view_get_page(tab_view, @ptrCast(tab.box)) orelse return; - c.adw_tab_view_close_page(tab_view, page); - - // If we have no more tabs we close the window - if (self.nPages() == 0) { - // 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 - // warning from GTK, but I don't know any other workaround. - // Note: I'm not actually sure if 1.4.0 contains the fix, - // I just know that 1.3.x is broken and 1.5.1 is fixed. - // If we know that 1.4.0 is fixed, we can change this. - if (!adwaita.versionAtLeast(1, 4, 0)) { - c.g_object_unref(tab.box); - } - - c.gtk_window_destroy(window.window); - } - }, - .gtk_notebook => |notebook| { - const page = c.gtk_notebook_get_page(notebook, @ptrCast(tab.box)) orelse return; - - // Find page and tab which we're closing - const page_idx = getNotebookPageIndex(page); - - // Remove the page. This will destroy the GTK widgets in the page which - // will trigger Tab cleanup. The `tab` variable is therefore unusable past that point. - c.gtk_notebook_remove_page(notebook, page_idx); - - const remaining = self.nPages(); - switch (remaining) { - // If we have no more tabs we close the window - 0 => c.gtk_window_destroy(tab.window.window), - - // If we have one more tab we hide the tab bar - 1 => c.gtk_notebook_set_show_tabs(notebook, 0), - - else => {}, - } - - // If we have remaining tabs, we need to make sure we grab focus. - if (remaining > 0) window.focusCurrentTab(); - }, + pub fn closeTab(self: *Notebook, tab: *Tab) void { + switch (self.*) { + .adw => |*adw| adw.closeTab(tab), + .gtk => |*gtk| gtk.closeTab(tab), } } - - fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int { - var value: c.GValue = std.mem.zeroes(c.GValue); - defer c.g_value_unset(&value); - _ = c.g_value_init(&value, c.G_TYPE_INT); - c.g_object_get_property( - @ptrCast(@alignCast(page)), - "position", - &value, - ); - - return c.g_value_get_int(&value); - } }; -fn gtkPageRemoved( - _: *c.GtkNotebook, - _: *c.GtkWidget, - _: c.guint, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud.?)); - - const notebook: *c.GtkNotebook = self.notebook.gtk_notebook; - - // Hide the tab bar if we only have one tab after removal - const remaining = c.gtk_notebook_get_n_pages(notebook); - if (remaining == 1) { - c.gtk_notebook_set_show_tabs(notebook, 0); - } -} - -fn adwPageAttached(tab_view: *AdwTabView, page: *c.AdwTabPage, position: c_int, ud: ?*anyopaque) callconv(.C) void { - _ = position; - _ = tab_view; - const self: *Window = @ptrCast(@alignCast(ud.?)); - - 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)); - tab.window = self; - - self.focusCurrentTab(); -} - -fn gtkPageAdded( - notebook: *c.GtkNotebook, - _: *c.GtkWidget, - page_idx: c.guint, - ud: ?*anyopaque, -) callconv(.C) void { - const self: *Window = @ptrCast(@alignCast(ud.?)); - - // The added page can come from another window with drag and drop, thus we migrate the tab - // window to be self. - const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx)); - const tab: *Tab = @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return, - )); - tab.window = self; - - // Whenever a new page is added, we always grab focus of the - // currently selected page. This was added specifically so that when - // we drag a tab out to create a new window ("create-window" event) - // we grab focus in the new window. Without this, the terminal didn't - // have focus. - self.focusCurrentTab(); -} - -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); -} - -fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void { - const window: *Window = @ptrCast(@alignCast(ud.?)); - const gtk_label_box = @as(*c.GtkWidget, @ptrCast(c.gtk_notebook_get_tab_label(window.notebook.gtk_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); -} - -fn adwTabViewCreateWindow( - _: *AdwTabView, - ud: ?*anyopaque, -) callconv(.C) ?*AdwTabView { - const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); - const window = createWindow(currentWindow) catch |err| { - log.warn("error creating new window error={}", .{err}); - return null; - }; - return window.notebook.adw_tab_view; -} - -fn gtkNotebookCreateWindow( - _: *c.GtkNotebook, - page: *c.GtkWidget, - ud: ?*anyopaque, -) callconv(.C) ?*c.GtkNotebook { - // The tab for the page is stored in the widget data. - const tab: *Tab = @ptrCast(@alignCast( - c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null, - )); - - const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); - const window = createWindow(currentWindow) catch |err| { - log.warn("error creating new window error={}", .{err}); - return null; - }; - - // And add it to the new window. - tab.window = window; - - return window.notebook.gtk_notebook; -} - -fn createWindow(currentWindow: *Window) !*Window { +pub fn createWindow(currentWindow: *Window) !*Window { const alloc = currentWindow.app.core_app.alloc; const app = currentWindow.app; diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig new file mode 100644 index 000000000..04294c4fe --- /dev/null +++ b/src/apprt/gtk/notebook_adw.zig @@ -0,0 +1,162 @@ +const std = @import("std"); +const assert = std.debug.assert; +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const Tab = @import("Tab.zig"); +const Notebook = @import("notebook.zig").Notebook; +const createWindow = @import("notebook.zig").createWindow; +const adwaita = @import("adwaita.zig"); + +const log = std.log.scoped(.gtk); + +const AdwTabView = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabView else anyopaque; +const AdwTabPage = if (adwaita.versionAtLeast(0, 0, 0)) c.AdwTabPage else anyopaque; + +pub const NotebookAdw = struct { + /// the tab view + tab_view: *AdwTabView, + + pub fn init(notebook: *Notebook) void { + const window: *Window = @fieldParentPtr("notebook", notebook); + const app = window.app; + assert(adwaita.enabled(&app.config)); + + const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; + + if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) { + // Adwaita enables all of the shortcuts by default. + // We want to manage keybindings ourselves. + c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); + } + + notebook.* = .{ + .adw = .{ + .tab_view = tab_view, + }, + }; + + _ = 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, "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); + } + + pub fn asWidget(self: *NotebookAdw) *c.GtkWidget { + return @ptrCast(@alignCast(self.tab_view)); + } + + pub fn nPages(self: *NotebookAdw) c_int { + if (comptime adwaita.versionAtLeast(0, 0, 0)) + return c.adw_tab_view_get_n_pages(self.tab_view) + else + unreachable; + } + + /// Returns the index of the currently selected page. + /// Returns null if the notebook has no pages. + pub fn currentPage(self: *NotebookAdw) ?c_int { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null; + return c.adw_tab_view_get_page_position(self.tab_view, page); + } + + /// Returns the currently selected tab or null if there are none. + pub fn currentTab(self: *NotebookAdw) ?*Tab { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_selected_page(self.tab_view) orelse return null; + const child = c.adw_tab_page_get_child(page); + return @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, + )); + } + + pub fn gotoNthTab(self: *NotebookAdw, position: c_int) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page_to_select = c.adw_tab_view_get_nth_page(self.tab_view, position); + c.adw_tab_view_set_selected_page(self.tab_view, page_to_select); + } + + pub fn getTabPosition(self: *NotebookAdw, tab: *Tab) ?c_int { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)) orelse return null; + return c.adw_tab_view_get_page_position(self.tab_view, page); + } + + pub fn reorderPage(self: *NotebookAdw, tab: *Tab, position: c_int) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)); + _ = c.adw_tab_view_reorder_page(self.tab_view, page, position); + } + + pub fn setTabLabel(self: *NotebookAdw, tab: *Tab, title: [:0]const u8) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)); + c.adw_tab_page_set_title(page, title.ptr); + } + + pub fn setTabTooltip(self: *NotebookAdw, tab: *Tab, tooltip: [:0]const u8) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const page = c.adw_tab_view_get_page(self.tab_view, @ptrCast(tab.box)); + c.adw_tab_page_set_tooltip(page, tooltip.ptr); + } + + pub fn addTab(self: *NotebookAdw, tab: *Tab, position: c_int, title: [:0]const u8) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + const box_widget: *c.GtkWidget = @ptrCast(tab.box); + const page = c.adw_tab_view_insert(self.tab_view, box_widget, position); + c.adw_tab_page_set_title(page, title.ptr); + c.adw_tab_view_set_selected_page(self.tab_view, page); + } + + pub fn closeTab(self: *NotebookAdw, tab: *Tab) void { + if (comptime !adwaita.versionAtLeast(0, 0, 0)) unreachable; + + 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); + + // If we have no more tabs we close the window + if (self.nPages() == 0) { + // 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 + // warning from GTK, but I don't know any other workaround. + // Note: I'm not actually sure if 1.4.0 contains the fix, + // I just know that 1.3.x is broken and 1.5.1 is fixed. + // If we know that 1.4.0 is fixed, we can change this. + if (!adwaita.versionAtLeast(1, 4, 0)) { + c.g_object_unref(tab.box); + } + + c.gtk_window_destroy(tab.window.window); + } + } +}; + +fn adwPageAttached(_: *AdwTabView, page: *c.AdwTabPage, _: c_int, ud: ?*anyopaque) callconv(.C) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + + 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)); + tab.window = window; + + window.focusCurrentTab(); +} + +fn adwTabViewCreateWindow( + _: *AdwTabView, + ud: ?*anyopaque, +) callconv(.C) ?*AdwTabView { + const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); + const window = createWindow(currentWindow) catch |err| { + log.warn("error creating new window error={}", .{err}); + return null; + }; + return window.notebook.adw.tab_view; +} + +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); +} diff --git a/src/apprt/gtk/notebook_gtk.zig b/src/apprt/gtk/notebook_gtk.zig new file mode 100644 index 000000000..96c5afadd --- /dev/null +++ b/src/apprt/gtk/notebook_gtk.zig @@ -0,0 +1,283 @@ +const std = @import("std"); +const assert = std.debug.assert; +const c = @import("c.zig").c; + +const Window = @import("Window.zig"); +const Tab = @import("Tab.zig"); +const Notebook = @import("notebook.zig").Notebook; +const createWindow = @import("notebook.zig").createWindow; + +const log = std.log.scoped(.gtk); + +/// An abstraction over the GTK notebook and Adwaita tab view to manage +/// all the terminal tabs in a window. +pub const NotebookGtk = struct { + notebook: *c.GtkNotebook, + + pub fn init(notebook: *Notebook) void { + const window: *Window = @fieldParentPtr("notebook", notebook); + const app = window.app; + + // Create a notebook to hold our tabs. + const notebook_widget: *c.GtkWidget = c.gtk_notebook_new(); + const gtk_notebook: *c.GtkNotebook = @ptrCast(notebook_widget); + const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { + .top, .hidden => c.GTK_POS_TOP, + .bottom => c.GTK_POS_BOTTOM, + .left => c.GTK_POS_LEFT, + .right => c.GTK_POS_RIGHT, + }; + c.gtk_notebook_set_tab_pos(gtk_notebook, notebook_tab_pos); + c.gtk_notebook_set_scrollable(gtk_notebook, 1); + c.gtk_notebook_set_show_tabs(gtk_notebook, 0); + c.gtk_notebook_set_show_border(gtk_notebook, 0); + + // This enables all Ghostty terminal tabs to be exchanged across windows. + c.gtk_notebook_set_group_name(gtk_notebook, "ghostty-terminal-tabs"); + + // This is important so the notebook expands to fit available space. + // Otherwise, it will be zero/zero in the box below. + c.gtk_widget_set_vexpand(notebook_widget, 1); + c.gtk_widget_set_hexpand(notebook_widget, 1); + + // Remove the background from the stack widget + const stack = c.gtk_widget_get_last_child(notebook_widget); + c.gtk_widget_add_css_class(stack, "transparent"); + + notebook.* = .{ + .gtk = .{ + .notebook = gtk_notebook, + }, + }; + + // All of our events + _ = c.g_signal_connect_data(gtk_notebook, "page-added", c.G_CALLBACK(>kPageAdded), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "page-removed", c.G_CALLBACK(>kPageRemoved), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "switch-page", c.G_CALLBACK(>kSwitchPage), window, null, c.G_CONNECT_DEFAULT); + _ = c.g_signal_connect_data(gtk_notebook, "create-window", c.G_CALLBACK(>kNotebookCreateWindow), window, null, c.G_CONNECT_DEFAULT); + } + + /// return the underlying widget as a generic GtkWidget + pub fn asWidget(self: *NotebookGtk) *c.GtkWidget { + return @ptrCast(@alignCast(self.notebook)); + } + + /// returns the number of pages in the notebook + pub fn nPages(self: *NotebookGtk) c_int { + return c.gtk_notebook_get_n_pages(self.notebook); + } + + /// Returns the index of the currently selected page. + /// Returns null if the notebook has no pages. + pub fn currentPage(self: *NotebookGtk) ?c_int { + const current = c.gtk_notebook_get_current_page(self.notebook); + return if (current == -1) null else current; + } + + /// Returns the currently selected tab or null if there are none. + pub fn currentTab(self: *NotebookGtk) ?*Tab { + log.warn("currentTab", .{}); + const page = self.currentPage() orelse return null; + const child = c.gtk_notebook_get_nth_page(self.notebook, page); + return @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(child), Tab.GHOSTTY_TAB) orelse return null, + )); + } + + /// focus the nth tab + pub fn gotoNthTab(self: *NotebookGtk, position: c_int) void { + c.gtk_notebook_set_current_page(self.notebook, position); + } + + /// get the position of the current tab + pub fn getTabPosition(self: *NotebookGtk, tab: *Tab) ?c_int { + const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return null; + return getNotebookPageIndex(page); + } + + pub fn reorderPage(self: *NotebookGtk, tab: *Tab, position: c_int) void { + c.gtk_notebook_reorder_child(self.notebook, @ptrCast(tab.box), position); + } + + pub fn setTabLabel(_: *NotebookGtk, tab: *Tab, title: [:0]const u8) void { + c.gtk_label_set_text(tab.label_text, title.ptr); + } + + pub fn setTabTooltip(_: *NotebookGtk, tab: *Tab, tooltip: [:0]const u8) void { + c.gtk_widget_set_tooltip_text(@ptrCast(@alignCast(tab.label_text)), tooltip.ptr); + } + + /// Adds a new tab with the given title to the notebook. + pub fn addTab(self: *NotebookGtk, tab: *Tab, position: c_int, title: [:0]const u8) void { + const box_widget: *c.GtkWidget = @ptrCast(tab.box); + + // Build the tab label + const label_box_widget = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 0); + const label_box = @as(*c.GtkBox, @ptrCast(label_box_widget)); + const label_text_widget = c.gtk_label_new(title.ptr); + const label_text: *c.GtkLabel = @ptrCast(label_text_widget); + c.gtk_box_append(label_box, label_text_widget); + tab.label_text = label_text; + + const window = tab.window; + if (window.app.config.@"gtk-wide-tabs") { + c.gtk_widget_set_hexpand(label_box_widget, 1); + c.gtk_widget_set_halign(label_box_widget, c.GTK_ALIGN_FILL); + c.gtk_widget_set_hexpand(label_text_widget, 1); + c.gtk_widget_set_halign(label_text_widget, c.GTK_ALIGN_FILL); + + // This ensures that tabs are always equal width. If they're too + // long, they'll be truncated with an ellipsis. + c.gtk_label_set_max_width_chars(label_text, 1); + c.gtk_label_set_ellipsize(label_text, c.PANGO_ELLIPSIZE_END); + + // We need to set a minimum width so that at a certain point + // the notebook will have an arrow button rather than shrinking tabs + // to an unreadably small size. + c.gtk_widget_set_size_request(label_text_widget, 100, 1); + } + + // Build the close button for the tab + const label_close_widget = c.gtk_button_new_from_icon_name("window-close-symbolic"); + const label_close: *c.GtkButton = @ptrCast(label_close_widget); + c.gtk_button_set_has_frame(label_close, 0); + c.gtk_box_append(label_box, label_close_widget); + + const page_idx = c.gtk_notebook_insert_page( + self.notebook, + box_widget, + label_box_widget, + position, + ); + + // Clicks + const gesture_tab_click = c.gtk_gesture_click_new(); + 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); + + // Tab settings + c.gtk_notebook_set_tab_reorderable(self.notebook, box_widget, 1); + c.gtk_notebook_set_tab_detachable(self.notebook, box_widget, 1); + + if (self.nPages() > 1) { + c.gtk_notebook_set_show_tabs(self.notebook, 1); + } + + // Switch to the new tab + c.gtk_notebook_set_current_page(self.notebook, page_idx); + } + + pub fn closeTab(self: *NotebookGtk, tab: *Tab) void { + const page = c.gtk_notebook_get_page(self.notebook, @ptrCast(tab.box)) orelse return; + + // Find page and tab which we're closing + const page_idx = getNotebookPageIndex(page); + + // Remove the page. This will destroy the GTK widgets in the page which + // will trigger Tab cleanup. The `tab` variable is therefore unusable past that point. + c.gtk_notebook_remove_page(self.notebook, page_idx); + + const remaining = self.nPages(); + switch (remaining) { + // If we have no more tabs we close the window + 0 => c.gtk_window_destroy(tab.window.window), + + // If we have one more tab we hide the tab bar + 1 => c.gtk_notebook_set_show_tabs(self.notebook, 0), + + else => {}, + } + + // If we have remaining tabs, we need to make sure we grab focus. + if (remaining > 0) + (self.currentTab() orelse return).window.focusCurrentTab(); + } +}; + +fn getNotebookPageIndex(page: *c.GtkNotebookPage) c_int { + var value: c.GValue = std.mem.zeroes(c.GValue); + defer c.g_value_unset(&value); + _ = c.g_value_init(&value, c.G_TYPE_INT); + c.g_object_get_property( + @ptrCast(@alignCast(page)), + "position", + &value, + ); + + return c.g_value_get_int(&value); +} + +fn gtkPageAdded( + notebook: *c.GtkNotebook, + _: *c.GtkWidget, + page_idx: c.guint, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Window = @ptrCast(@alignCast(ud.?)); + + // The added page can come from another window with drag and drop, thus we migrate the tab + // window to be self. + const page = c.gtk_notebook_get_nth_page(notebook, @intCast(page_idx)); + const tab: *Tab = @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return, + )); + tab.window = self; + + // Whenever a new page is added, we always grab focus of the + // currently selected page. This was added specifically so that when + // we drag a tab out to create a new window ("create-window" event) + // we grab focus in the new window. Without this, the terminal didn't + // have focus. + self.focusCurrentTab(); +} + +fn gtkPageRemoved( + _: *c.GtkNotebook, + _: *c.GtkWidget, + _: c.guint, + ud: ?*anyopaque, +) callconv(.C) void { + log.warn("gtkPageRemoved", .{}); + const window: *Window = @ptrCast(@alignCast(ud.?)); + + // Hide the tab bar if we only have one tab after removal + const remaining = c.gtk_notebook_get_n_pages(window.notebook.gtk.notebook); + + if (remaining == 1) { + c.gtk_notebook_set_show_tabs(window.notebook.gtk.notebook, 0); + } +} + +fn gtkSwitchPage(_: *c.GtkNotebook, page: *c.GtkWidget, _: usize, ud: ?*anyopaque) callconv(.C) void { + const window: *Window = @ptrCast(@alignCast(ud.?)); + const self = &window.notebook.gtk; + 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); +} + +fn gtkNotebookCreateWindow( + _: *c.GtkNotebook, + page: *c.GtkWidget, + ud: ?*anyopaque, +) callconv(.C) ?*c.GtkNotebook { + // The tab for the page is stored in the widget data. + const tab: *Tab = @ptrCast(@alignCast( + c.g_object_get_data(@ptrCast(page), Tab.GHOSTTY_TAB) orelse return null, + )); + + const currentWindow: *Window = @ptrCast(@alignCast(ud.?)); + const newWindow = createWindow(currentWindow) catch |err| { + log.warn("error creating new window error={}", .{err}); + return null; + }; + + // And add it to the new window. + tab.window = newWindow; + + return newWindow.notebook.gtk.notebook; +} From cb8d30f938e873f1d14485bc10bf7ebd201cd436 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 29 Dec 2024 16:10:44 -0600 Subject: [PATCH 067/138] core: add build option to disable sentry --- .github/workflows/test.yml | 60 ++++++++++++++++++++++++++++++++++++++ build.zig | 29 +++++++++++++----- src/build_config.zig | 2 ++ src/crash/sentry.zig | 9 +++++- 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f8d2671c..8aa147935 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -376,6 +376,66 @@ jobs: -Dgtk-adwaita=${{ matrix.adwaita }} \ -Dgtk-x11=${{ matrix.x11 }} + test-sentry-linux: + strategy: + fail-fast: false + matrix: + sentry: ["true", "false"] + name: Build -Dsentry=${{ matrix.sentry }} on Linux + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@v1.2.0 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test Sentry Build + run: | + nix develop -c zig build -Dsentry=${{ matrix.sentry }} + + test-sentry-macos: + strategy: + fail-fast: false + matrix: + sentry: ["true", "false"] + name: Build -Dsentry=${{ matrix.sentry }} on macOS + runs-on: namespace-profile-ghostty-macos + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v15 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Test Sentry Build + run: | + nix develop -c zig build -Dsentry=${{ matrix.sentry }} + test-macos: runs-on: namespace-profile-ghostty-macos needs: test diff --git a/build.zig b/build.zig index 414b66804..ac664deb7 100644 --- a/build.zig +++ b/build.zig @@ -152,6 +152,17 @@ pub fn build(b: *std.Build) !void { } }; + 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, + else => break :sentry false, + } + }; + const pie = b.option( bool, "pie", @@ -1245,13 +1256,17 @@ fn addDeps( } // Sentry - const sentry_dep = b.dependency("sentry", .{ - .target = target, - .optimize = optimize, - .backend = .breakpad, - }); - step.root_module.addImport("sentry", sentry_dep.module("sentry")); - if (target.result.os.tag != .windows) { + if (config.sentry) sentry: { + if (target.result.os.tag == .windows) break :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()); diff --git a/src/build_config.zig b/src/build_config.zig index 35c429564..c70615144 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, + sentry: bool = true, app_runtime: apprt.Runtime = .none, renderer: rendererpkg.Impl = .opengl, font_backend: font.Backend = .freetype, @@ -43,6 +44,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, "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); diff --git a/src/crash/sentry.zig b/src/crash/sentry.zig index 9e05b427d..e9c49048c 100644 --- a/src/crash/sentry.zig +++ b/src/crash/sentry.zig @@ -3,7 +3,8 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); -const sentry = @import("sentry"); +const build_options = @import("build_options"); +const sentry = if (build_options.sentry) @import("sentry"); const internal_os = @import("../os/main.zig"); const crash = @import("main.zig"); const state = &@import("../global.zig").state; @@ -47,6 +48,8 @@ pub threadlocal var thread_state: ?ThreadState = null; /// It is up to the user to grab the logs and manually send them to us /// (or they own Sentry instance) if they want to. pub fn init(gpa: Allocator) !void { + if (comptime !build_options.sentry) return; + // Not supported on Windows currently, doesn't build. if (comptime builtin.os.tag == .windows) return; @@ -76,6 +79,8 @@ pub fn init(gpa: Allocator) !void { } fn initThread(gpa: Allocator) !void { + if (comptime !build_options.sentry) return; + var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const alloc = arena.allocator(); @@ -145,6 +150,8 @@ fn initThread(gpa: Allocator) !void { /// Process-wide deinitialization of our Sentry client. This ensures all /// our data is flushed. pub fn deinit() void { + if (comptime !build_options.sentry) return; + if (comptime builtin.os.tag == .windows) return; // If we're still initializing then wait for init to finish. This From 7a5ef3da2b4fe39cf25919dd451d3adb88ed883e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 13:34:11 -0800 Subject: [PATCH 068/138] remove sentry test for macOS, remove windows check --- .github/workflows/test.yml | 27 +-------------------------- build.zig | 7 ++++--- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8aa147935..41bc58925 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -381,7 +381,7 @@ jobs: fail-fast: false matrix: sentry: ["true", "false"] - name: Build -Dsentry=${{ matrix.sentry }} on Linux + name: Build -Dsentry=${{ matrix.sentry }} runs-on: namespace-profile-ghostty-sm needs: test env: @@ -411,31 +411,6 @@ jobs: run: | nix develop -c zig build -Dsentry=${{ matrix.sentry }} - test-sentry-macos: - strategy: - fail-fast: false - matrix: - sentry: ["true", "false"] - name: Build -Dsentry=${{ matrix.sentry }} on macOS - runs-on: namespace-profile-ghostty-macos - needs: test - steps: - - name: Checkout code - uses: actions/checkout@v4 - - # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 - with: - nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 - with: - name: ghostty - authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - - name: Test Sentry Build - run: | - nix develop -c zig build -Dsentry=${{ matrix.sentry }} - test-macos: runs-on: namespace-profile-ghostty-macos needs: test diff --git a/build.zig b/build.zig index ac664deb7..2a2eda794 100644 --- a/build.zig +++ b/build.zig @@ -159,6 +159,9 @@ pub fn build(b: *std.Build) !void { ) 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, } }; @@ -1256,9 +1259,7 @@ fn addDeps( } // Sentry - if (config.sentry) sentry: { - if (target.result.os.tag == .windows) break :sentry; - + if (config.sentry) { const sentry_dep = b.dependency("sentry", .{ .target = target, .optimize = optimize, From 6a4842f110abdcdc4eb7f708b4af9c4f09d922c7 Mon Sep 17 00:00:00 2001 From: Matt Rochford Date: Sun, 29 Dec 2024 13:47:03 -0800 Subject: [PATCH 069/138] Don't steal focus on mouse events that are within 1 px --- src/apprt/gtk/Surface.zig | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 079cdbd81..c53190ccc 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1426,15 +1426,23 @@ fn gtkMouseMotion( .y = @floatCast(scaled.y), }; - // Our pos changed, update - self.cursor_pos = pos; + // 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. + 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 (c.gtk_widget_has_focus(gl_widget) == 0 and self.app.config.@"focus-follows-mouse") { + if (!is_cursor_still and 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); From e7354e73082ec833e5e2a907609eb0da3be20473 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 14:05:49 -0800 Subject: [PATCH 070/138] Update src/config/Config.zig Co-authored-by: Aarni Koskela --- 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 d84f326a6..546374096 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -560,7 +560,7 @@ palette: Palette = .{}, /// On macOS, background opacity is disabled when the terminal enters native /// fullscreen. This is because the background becomes gray and it can cause /// widgets to show through which isn't generally desirable. -/// On macOs, this setting cannot be reloaded and needs a restart +/// 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 From bed37ac8446af17a2ef7baeb8e22fba1ab1d8bdd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 14:06:18 -0800 Subject: [PATCH 071/138] update wording --- src/config/Config.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 546374096..14d6efc6f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -560,6 +560,7 @@ palette: Palette = .{}, /// On macOS, background opacity is disabled when the terminal enters native /// fullscreen. This is because the background becomes gray and it can cause /// widgets to show through which isn't generally desirable. +/// /// On macOS, changing this configuration requires restarting Ghostty completely. @"background-opacity": f64 = 1.0, From e6bb1a56ebd1ef21709fa81846bbc9f06ec891de Mon Sep 17 00:00:00 2001 From: Yotam Gurfinkel Date: Tue, 31 Dec 2024 14:43:26 +0200 Subject: [PATCH 072/138] config: Add the option `toast_on_clipboard_copy` Add a config option to enable/disable the toast shown on clipboard copy --- src/apprt/gtk/Window.zig | 4 +++- src/config/Config.zig | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 0bcb19cc0..352d47e5d 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -894,7 +894,9 @@ fn gtkActionCopy( return; }; - self.sendToast("Copied to clipboard"); + if (self.app.config.@"toast-on-clipboard-copy") { + self.sendToast("Copied to clipboard"); + } } fn gtkActionPaste( diff --git a/src/config/Config.zig b/src/config/Config.zig index d8b46d46b..447da5281 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1276,6 +1276,9 @@ keybind: Keybinds = .{}, @"clipboard-read": ClipboardAccess = .ask, @"clipboard-write": ClipboardAccess = .allow, +/// Enables or disables the toast message on a clipboard copy action. +@"toast-on-clipboard-copy": bool = true, + /// Trims trailing whitespace on data that is copied to the clipboard. This does /// not affect data sent to the clipboard via `clipboard-write`. @"clipboard-trim-trailing-spaces": bool = true, From fb8c83e07c7997dfaddc9a21372764579e76cdc3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 14:15:09 -0800 Subject: [PATCH 073/138] config: change toast config to packed struct --- src/apprt/gtk/Window.zig | 2 +- src/config/Config.zig | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 352d47e5d..40fe0bd67 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -894,7 +894,7 @@ fn gtkActionCopy( return; }; - if (self.app.config.@"toast-on-clipboard-copy") { + if (self.app.config.@"adw-toast".@"clipboard-copy") { self.sendToast("Copied to clipboard"); } } diff --git a/src/config/Config.zig b/src/config/Config.zig index 447da5281..bf694f2f5 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1276,9 +1276,6 @@ keybind: Keybinds = .{}, @"clipboard-read": ClipboardAccess = .ask, @"clipboard-write": ClipboardAccess = .allow, -/// Enables or disables the toast message on a clipboard copy action. -@"toast-on-clipboard-copy": bool = true, - /// Trims trailing whitespace on data that is copied to the clipboard. This does /// not affect data sent to the clipboard via `clipboard-write`. @"clipboard-trim-trailing-spaces": bool = true, @@ -1945,6 +1942,22 @@ 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. +/// +/// Valid values are: +/// +/// - `clipboard-copy` (default: true) - Show a toast when text is copied +/// to the clipboard. +/// +/// You can prefix any value with `no-` to disable it. For example, +/// `no-clipboard-copy` will disable the clipboard copy toast. Multiple +/// values can be set by separating them with commas. +/// +/// 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, @@ -5445,6 +5458,11 @@ pub const AdwToolbarStyle = enum { @"raised-border", }; +/// See adw-toast +pub const AdwToast = packed struct { + @"clipboard-copy": bool = true, +}; + /// See mouse-shift-capture pub const MouseShiftCapture = enum { false, From 8e47d0267bc468483d3fb53acf3aa07fa1e87dea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 15:04:49 -0800 Subject: [PATCH 074/138] Move resource limits to a dedicated struct, restore before preexec --- src/Command.zig | 8 ++++---- src/global.zig | 29 +++++++++++++++++++++-------- src/os/file.zig | 3 +-- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/Command.zig b/src/Command.zig index 2801def36..6e30eae13 100644 --- a/src/Command.zig +++ b/src/Command.zig @@ -176,13 +176,13 @@ fn startPosix(self: *Command, arena: Allocator) !void { // We don't log because that'll show up in the output. }; + // Restore any rlimits that were set by Ghostty. This might fail but + // any failures are ignored (its best effort). + global_state.rlimits.restore(); + // If the user requested a pre exec callback, call it now. if (self.pre_exec) |f| f(self); - if (global_state.rlimits.nofile) |lim| { - internal_os.restoreMaxFiles(lim); - } - // Finally, replace our process. _ = posix.execveZ(pathZ, argsZ, envp) catch null; diff --git a/src/global.zig b/src/global.zig index b3f35fde5..c00ce27a4 100644 --- a/src/global.zig +++ b/src/global.zig @@ -27,12 +27,7 @@ pub const GlobalState = struct { alloc: std.mem.Allocator, action: ?cli.Action, logging: Logging, - /// If we change any resource limits for our own purposes, we save the - /// old limits so that they can be restored before we execute any child - /// processes. - rlimits: struct { - nofile: ?internal_os.rlimit = null, - } = .{}, + rlimits: ResourceLimits = .{}, /// The app resources directory, equivalent to zig-out/share when we build /// from source. This is null if we can't detect it. @@ -130,8 +125,8 @@ pub const GlobalState = struct { std.log.info("renderer={}", .{renderer.Renderer}); std.log.info("libxev backend={}", .{xev.backend}); - // First things first, we fix our file descriptors - self.rlimits.nofile = internal_os.fixMaxFiles(); + // As early as possible, initialize our resource limits. + self.rlimits = ResourceLimits.init(); // Initialize our crash reporting. crash.init(self.alloc) catch |err| { @@ -181,3 +176,21 @@ pub const GlobalState = struct { } } }; + +/// Maintains the Unix resource limits that we set for our process. This +/// can be used to restore the limits to their original values. +pub const ResourceLimits = struct { + nofile: ?internal_os.rlimit = null, + + pub fn init() ResourceLimits { + return .{ + // Maximize the number of file descriptors we can have open + // because we can consume a lot of them if we make many terminals. + .nofile = internal_os.fixMaxFiles(), + }; + } + + pub fn restore(self: *const ResourceLimits) void { + if (self.nofile) |lim| internal_os.restoreMaxFiles(lim); + } +}; diff --git a/src/os/file.zig b/src/os/file.zig index d89c05e3e..875dd2c25 100644 --- a/src/os/file.zig +++ b/src/os/file.zig @@ -23,9 +23,8 @@ pub fn fixMaxFiles() ?rlimit { return old; } - var lim = old; - // Do a binary search for the limit. + var lim = old; var min: posix.rlim_t = lim.cur; var max: posix.rlim_t = 1 << 20; // But if there's a defined upper bound, don't search, just set it. From 0ef24f3c75fb8ba5bca760210a9e43dd896268db Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 15:08:29 -0800 Subject: [PATCH 075/138] ci: only test pkgs on Linux --- .github/workflows/test.yml | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f07faf2f..497051c28 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -484,7 +484,7 @@ jobs: fail-fast: false matrix: pkg: ["wuffs"] - name: Run pkg/${{ matrix.pkg }} tests on Linux + name: Test pkg/${{ matrix.pkg }} runs-on: namespace-profile-ghostty-sm needs: test env: @@ -513,28 +513,3 @@ jobs: - name: Test ${{ matrix.pkg }} Build run: | nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test" - - test-pkg-macos: - strategy: - fail-fast: false - matrix: - pkg: ["wuffs"] - name: Run pkg/${{ matrix.pkg }} tests on macOS - runs-on: namespace-profile-ghostty-macos - needs: test - steps: - - name: Checkout code - uses: actions/checkout@v4 - - # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v30 - with: - nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 - with: - name: ghostty - authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - - name: Test ${{ matrix.pkg }} Build - run: | - nix develop -c sh -c "cd pkg/${{ matrix.pkg }} ; zig build test" From fe9bbec92e6ac9de08529b0d72922b4356b3ec08 Mon Sep 17 00:00:00 2001 From: roshal Date: Thu, 25 Jan 2001 20:41:04 +0300 Subject: [PATCH 076/138] config: allow other base numbers for palette indexes --- src/config/Config.zig | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 162a1b8b5..d3d1d412b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -447,6 +447,10 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// the 256 colors in the terminal color table) and `COLOR` is a typical RGB /// color code such as `#AABBCC` or `AABBCC`, or a named X11 color. /// +/// The palette index can be in decimal, binary, octal, or hexadecimal. +/// Decimal is assumed unless a prefix is used: `0b` for binary, `0o` for octal, +/// and `0x` for hexadecimal. +/// /// For definitions on the color indices and what they canonically map to, /// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet). palette: Palette = .{}, @@ -4125,7 +4129,7 @@ pub const Palette = struct { const eqlIdx = std.mem.indexOf(u8, value, "=") orelse return error.InvalidValue; - const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 10); + const key = try std.fmt.parseInt(u8, value[0..eqlIdx], 0); const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]); self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; } @@ -4165,6 +4169,28 @@ pub const Palette = struct { try testing.expect(p.value[0].b == 0xCC); } + test "parseCLI base" { + const testing = std.testing; + + var p: Self = .{}; + + try p.parseCLI("0b1=#014589"); + try p.parseCLI("0o7=#234567"); + try p.parseCLI("0xF=#ABCDEF"); + + try testing.expect(p.value[0b1].r == 0x01); + try testing.expect(p.value[0b1].g == 0x45); + try testing.expect(p.value[0b1].b == 0x89); + + try testing.expect(p.value[0o7].r == 0x23); + try testing.expect(p.value[0o7].g == 0x45); + try testing.expect(p.value[0o7].b == 0x67); + + try testing.expect(p.value[0xF].r == 0xAB); + try testing.expect(p.value[0xF].g == 0xCD); + try testing.expect(p.value[0xF].b == 0xEF); + } + test "parseCLI overflow" { const testing = std.testing; From 8d7ed3e0fc2e1e0c220d7c330c121ed56298df2b Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Fri, 3 Jan 2025 00:20:54 +0100 Subject: [PATCH 077/138] 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 82695edaff26de7a51c69c01f3d6c69de8c92b58 Mon Sep 17 00:00:00 2001 From: Gabriel Moreno Date: Wed, 1 Jan 2025 14:31:23 -0400 Subject: [PATCH 078/138] macos: fix window borders on dark mode --- macos/Sources/Features/Terminal/TerminalController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c3b332cd4..6ee81c340 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -244,7 +244,9 @@ class TerminalController: BaseTerminalController { let backgroundColor: OSColor if let surfaceTree { if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { - backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.0) + // Similar to above, an alpha component of "0" causes compositor issues, so + // we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308 + backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001) } else { // We don't have a focused surface or our surface doesn't border the // top. We choose to match the color of the top-left most surface. From 95b73f197fbfccc15c4635630e285cb5a4acbb69 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 15:38:41 -0800 Subject: [PATCH 079/138] Add docs for performable --- src/Surface.zig | 14 +++++++++++--- src/config/Config.zig | 9 +++++++++ src/input/Binding.zig | 15 +++++++++++++-- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index ce70d56ff..5d25a61e9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1941,16 +1941,24 @@ fn maybeHandleBinding( return .closed; } - // If we have the performable flag and the - // action was not performed do nothing at all + // If we have the performable flag and the action was not performed, + // then we act as though a binding didn't exist. if (leaf.flags.performable and !performed) { + // If we're in a sequence, we treat this as if we pressed a key + // that doesn't exist in the sequence. Reset our sequence and flush + // any queued events. + if (self.keyboard.bindings != null) { + self.keyboard.bindings = null; + self.endKeySequence(.flush, .retain); + } + return null; } // If we consume this event, then we are done. If we don't consume // it, we processed the action but we still want to process our // encodings, too. - if (consumed) { + if (performed and consumed) { // If we had queued events, we deinit them since we consumed self.endKeySequence(.drop, .retain); diff --git a/src/config/Config.zig b/src/config/Config.zig index dca3bec0d..9692caae1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -907,6 +907,15 @@ class: ?[:0]const u8 = null, /// Since they are not associated with a specific terminal surface, /// they're never encoded. /// +/// * `performable:` - Only consume the input if the action is able to be +/// performed. For example, the `copy_to_clipboard` action will only +/// consume the input if there is a selection to copy. If there is no +/// selection, Ghostty behaves as if the keybind was not set. This has +/// no effect with `global:` or `all:`-prefixed keybinds. For key +/// sequences, this will reset the sequence if the action is not +/// performable (acting identically to not having a keybind set at +/// all). +/// /// Keybind triggers are not unique per prefix combination. For example, /// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind /// set later will overwrite the keybind set earlier. In this case, the diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 3380896b4..529ca1902 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -37,8 +37,9 @@ pub const Flags = packed struct { /// See the keybind config documentation for more information. global: bool = false, - /// True if this binding can be performed then the action is - /// triggered otherwise it acts as if it doesn't exist. + /// True if this binding should only be triggered if the action can be + /// performed. If the action can't be performed then the binding acts as + /// if it doesn't exist. performable: bool = false, }; @@ -1654,6 +1655,16 @@ test "parse: triggers" { .flags = .{ .consumed = false }, }, try parseSingle("unconsumed:physical:a+shift=ignore")); + // performable keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .performable = true }, + }, try parseSingle("performable:shift+a=ignore")); + // invalid key try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore")); From a0de1be65fc4a38164d78bdb9b15a3d32a5ce01e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 2 Jan 2025 12:23:05 -0600 Subject: [PATCH 080/138] gtk: fix non-notebook separator colors --- src/apprt/gtk/Window.zig | 1 + src/apprt/gtk/notebook_adw.zig | 1 + src/apprt/gtk/notebook_gtk.zig | 2 ++ src/apprt/gtk/style-dark.css | 2 +- src/apprt/gtk/style.css | 2 +- 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 40fe0bd67..516ea7fc5 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), "terminal-window"); // GTK4 grabs F10 input by default to focus the menubar icon. We want // to disable this so that terminal programs can capture F10 (such as htop) diff --git a/src/apprt/gtk/notebook_adw.zig b/src/apprt/gtk/notebook_adw.zig index 04294c4fe..85083a97e 100644 --- a/src/apprt/gtk/notebook_adw.zig +++ b/src/apprt/gtk/notebook_adw.zig @@ -23,6 +23,7 @@ pub const NotebookAdw = struct { assert(adwaita.enabled(&app.config)); const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; + c.gtk_widget_add_css_class(@ptrCast(@alignCast(tab_view)), "notebook"); if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) { // Adwaita enables all of the shortcuts by default. diff --git a/src/apprt/gtk/notebook_gtk.zig b/src/apprt/gtk/notebook_gtk.zig index 96c5afadd..6e8b016ba 100644 --- a/src/apprt/gtk/notebook_gtk.zig +++ b/src/apprt/gtk/notebook_gtk.zig @@ -20,6 +20,8 @@ pub const NotebookGtk = struct { // Create a notebook to hold our tabs. const notebook_widget: *c.GtkWidget = c.gtk_notebook_new(); + c.gtk_widget_add_css_class(notebook_widget, "notebook"); + const gtk_notebook: *c.GtkNotebook = @ptrCast(notebook_widget); const notebook_tab_pos: c_uint = switch (app.config.@"gtk-tabs-location") { .top, .hidden => c.GTK_POS_TOP, diff --git a/src/apprt/gtk/style-dark.css b/src/apprt/gtk/style-dark.css index b56fa14f2..dcd4bcab9 100644 --- a/src/apprt/gtk/style-dark.css +++ b/src/apprt/gtk/style-dark.css @@ -2,7 +2,7 @@ background-color: transparent; } -separator { +.terminal-window .notebook separator { background-color: rgba(36, 36, 36, 1); background-clip: content-box; } diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index edafc84c7..bf0ee62f6 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -41,7 +41,7 @@ window.without-window-decoration-and-with-titlebar { background-color: transparent; } -separator { +.terminal-window .notebook separator { background-color: rgba(250, 250, 250, 1); background-clip: content-box; } From e6399c947a7c6e3d151985bdc32dc15f78045bdb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 15:49:35 -0800 Subject: [PATCH 081/138] update our default bindings that are performable --- src/Surface.zig | 15 ++++++--------- src/config/Config.zig | 27 ++++++++++++++++++--------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 5d25a61e9..389e7f7e4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1156,7 +1156,6 @@ pub fn updateConfig( } // If we are in the middle of a key sequence, clear it. - self.keyboard.bindings = null; self.endKeySequence(.drop, .free); // Before sending any other config changes, we give the renderer a new font @@ -1853,9 +1852,6 @@ fn maybeHandleBinding( if (self.keyboard.bindings != null and !event.key.modifier()) { - // Reset to the root set - self.keyboard.bindings = null; - // Encode everything up to this point self.endKeySequence(.flush, .retain); } @@ -1947,10 +1943,7 @@ fn maybeHandleBinding( // If we're in a sequence, we treat this as if we pressed a key // that doesn't exist in the sequence. Reset our sequence and flush // any queued events. - if (self.keyboard.bindings != null) { - self.keyboard.bindings = null; - self.endKeySequence(.flush, .retain); - } + self.endKeySequence(.flush, .retain); return null; } @@ -1958,7 +1951,7 @@ fn maybeHandleBinding( // If we consume this event, then we are done. If we don't consume // it, we processed the action but we still want to process our // encodings, too. - if (performed and consumed) { + if (consumed) { // If we had queued events, we deinit them since we consumed self.endKeySequence(.drop, .retain); @@ -2000,6 +1993,10 @@ fn endKeySequence( ); }; + // No matter what we clear our current binding set. This restores + // the set we look at to the root set. + self.keyboard.bindings = null; + if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { diff --git a/src/config/Config.zig b/src/config/Config.zig index 9692caae1..1f136b227 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2133,45 +2133,53 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { ); // Expand Selection - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .left }, .mods = .{ .shift = true } }, .{ .adjust_selection = .left }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .right }, .mods = .{ .shift = true } }, .{ .adjust_selection = .right }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .up }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .down }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_up }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_down }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, .{ .adjust_selection = .home }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, .{ .adjust_selection = .end }, + .{ .performable = true }, ); // Tabs common to all platforms @@ -2421,10 +2429,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .q }, .mods = .{ .super = true } }, .{ .quit = {} }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .k }, .mods = .{ .super = true } }, .{ .clear_screen = {} }, + .{ .performable = true }, ); try result.keybind.set.put( alloc, From bcd4b3a68094467454503cd88b7c0839ce455fb5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 16:07:26 -0800 Subject: [PATCH 082/138] config: improve adw-toast docs --- src/config/Config.zig | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index d3d1d412b..4bc6870a1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1959,14 +1959,21 @@ keybind: Keybinds = .{}, /// that appear overlaid on top of the terminal window. They are used to /// show information that is not critical but may be important. /// -/// Valid values are: +/// Possible toasts are: /// /// - `clipboard-copy` (default: true) - Show a toast when text is copied /// to the clipboard. /// -/// You can prefix any value with `no-` to disable it. For example, -/// `no-clipboard-copy` will disable the clipboard copy toast. Multiple -/// values can be set by separating them with commas. +/// 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 = .{}, From c9dfcd27811303b71ffb689917dfe13c19333004 Mon Sep 17 00:00:00 2001 From: David Leadbeater Date: Fri, 3 Jan 2025 11:12:33 +1100 Subject: [PATCH 083/138] kittygfx: Ensure temporary files are named per spec Temporary files used with Kitty graphics must have "tty-graphics-protocol" somewhere in their full path. https://sw.kovidgoyal.net/kitty/graphics-protocol/#the-transmission-medium --- src/terminal/kitty/graphics_exec.zig | 1 + src/terminal/kitty/graphics_image.zig | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index cc87d6c9d..25c819b10 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -382,6 +382,7 @@ fn encodeError(r: *Response, err: EncodeableError) void { error.DecompressionFailed => r.message = "EINVAL: decompression failed", error.FilePathTooLong => r.message = "EINVAL: file path too long", error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir", + error.TemporaryFileNotNamedCorrectly => r.message = "EINVAL: temporary file not named correctly", error.UnsupportedFormat => r.message = "EINVAL: unsupported format", error.UnsupportedMedium => r.message = "EINVAL: unsupported medium", error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth", diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index ff498cbb8..7a107208b 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -220,6 +220,9 @@ pub const LoadingImage = struct { // Temporary file logic if (medium == .temporary_file) { if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir; + if (std.mem.indexOf(u8, path, "tty-graphics-protocol") == null) { + return error.TemporaryFileNotNamedCorrectly; + } } defer if (medium == .temporary_file) { posix.unlink(path) catch |err| { @@ -469,6 +472,7 @@ pub const Image = struct { DimensionsTooLarge, FilePathTooLong, TemporaryFileNotInTempDir, + TemporaryFileNotNamedCorrectly, UnsupportedFormat, UnsupportedMedium, UnsupportedDepth, From 5c39d09053c360bb58b91dbc71c3151c3b23d99e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 1 Jan 2025 18:06:31 -0600 Subject: [PATCH 084/138] core: detect what desktop environment the user is using --- src/cli/version.zig | 2 ++ src/os/desktop.zig | 25 +++++++++++++++++++++++++ src/os/main.zig | 1 + 3 files changed, 28 insertions(+) diff --git a/src/cli/version.zig b/src/cli/version.zig index 29ab7f63f..177558ac9 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -3,6 +3,7 @@ const build_options = @import("build_options"); const Allocator = std.mem.Allocator; const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); +const internal_os = @import("../os/main.zig"); const xev = @import("xev"); const renderer = @import("../renderer.zig"); const gtk = if (build_config.app_runtime == .gtk) @import("../apprt/gtk/c.zig").c else void; @@ -36,6 +37,7 @@ pub fn run(alloc: Allocator) !u8 { try stdout.print(" - font engine: {}\n", .{build_config.font_backend}); try stdout.print(" - renderer : {}\n", .{renderer.Renderer}); try stdout.print(" - libxev : {}\n", .{xev.backend}); + try stdout.print(" - desktop env: {s}\n", .{@tagName(internal_os.desktopEnvironment())}); if (comptime build_config.app_runtime == .gtk) { try stdout.print(" - GTK version:\n", .{}); try stdout.print(" build : {d}.{d}.{d}\n", .{ diff --git a/src/os/desktop.zig b/src/os/desktop.zig index 103127dfa..20738f191 100644 --- a/src/os/desktop.zig +++ b/src/os/desktop.zig @@ -59,3 +59,28 @@ pub fn launchedFromDesktop() bool { else => @compileError("unsupported platform"), }; } + +pub const DesktopEnvironment = enum { + gnome, + macos, + other, + 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. +pub fn desktopEnvironment() DesktopEnvironment { + return switch (comptime builtin.os.tag) { + .macos => .macos, + .windows => .windows, + .linux => de: { + // 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; + break :de .other; + }, + else => .other, + }; +} diff --git a/src/os/main.zig b/src/os/main.zig index b529a470d..e652a7981 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -32,6 +32,7 @@ pub const getenv = env.getenv; pub const setenv = env.setenv; pub const unsetenv = env.unsetenv; pub const launchedFromDesktop = desktop.launchedFromDesktop; +pub const desktopEnvironment = desktop.desktopEnvironment; pub const rlimit = file.rlimit; pub const fixMaxFiles = file.fixMaxFiles; pub const restoreMaxFiles = file.restoreMaxFiles; From c89df01e13cbb712179bfb6cdc79f76a8a07dd29 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 1 Jan 2025 18:49:26 -0600 Subject: [PATCH 085/138] core: prohibit checking for the desktop environment on linux during comptime --- src/os/desktop.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/os/desktop.zig b/src/os/desktop.zig index 20738f191..3a61e2eaa 100644 --- a/src/os/desktop.zig +++ b/src/os/desktop.zig @@ -75,6 +75,7 @@ pub fn desktopEnvironment() DesktopEnvironment { .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 // https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop= const de = posix.getenv("XDG_SESSION_DESKTOP") orelse break :de .other; From 3c93f00d04a379da77bda22eda916d8ef3711c8c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 2 Jan 2025 11:52:20 -0600 Subject: [PATCH 086/138] cli: only print out DE when using the GTK apprt --- src/cli/version.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/version.zig b/src/cli/version.zig index 177558ac9..99f03384b 100644 --- a/src/cli/version.zig +++ b/src/cli/version.zig @@ -37,8 +37,8 @@ pub fn run(alloc: Allocator) !u8 { try stdout.print(" - font engine: {}\n", .{build_config.font_backend}); try stdout.print(" - renderer : {}\n", .{renderer.Renderer}); try stdout.print(" - libxev : {}\n", .{xev.backend}); - try stdout.print(" - desktop env: {s}\n", .{@tagName(internal_os.desktopEnvironment())}); if (comptime build_config.app_runtime == .gtk) { + try stdout.print(" - desktop env: {s}\n", .{@tagName(internal_os.desktopEnvironment())}); try stdout.print(" - GTK version:\n", .{}); try stdout.print(" build : {d}.{d}.{d}\n", .{ gtk.GTK_MAJOR_VERSION, From 4cb2fd4f79624de671e57c9dea7217349620b4a0 Mon Sep 17 00:00:00 2001 From: David Leadbeater Date: Fri, 3 Jan 2025 11:52:24 +1100 Subject: [PATCH 087/138] Add negative test for temporary filename and fix other tests --- src/terminal/kitty/graphics_image.zig | 39 ++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 7a107208b..094e1622b 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -686,7 +686,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" try testing.expect(img.compression == .none); } -test "image load: rgb, not compressed, temporary file" { +test "image load: temporary file without correct path" { const testing = std.testing; const alloc = testing.allocator; @@ -701,6 +701,39 @@ test "image load: rgb, not compressed, temporary file" { var buf: [std.fs.max_path_bytes]u8 = undefined; const path = try tmp_dir.dir.realpath("image.data", &buf); + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd)); + + // Temporary file should still be there + try tmp_dir.dir.access(path, .{}); +} + +test "image load: rgb, not compressed, temporary file" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try internal_os.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); + var cmd: command.Command = .{ .control = .{ .transmit = .{ .format = .rgb, @@ -766,12 +799,12 @@ test "image load: png, not compressed, regular file" { defer tmp_dir.deinit(); const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data"); try tmp_dir.dir.writeFile(.{ - .sub_path = "image.data", + .sub_path = "tty-graphics-protocol-image.data", .data = data, }); var buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("image.data", &buf); + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); var cmd: command.Command = .{ .control = .{ .transmit = .{ From ac524b6c34a7465e9b62aad507f4eba2daa49c2f Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:55:21 +0800 Subject: [PATCH 088/138] Correct typos and update typos.toml --- src/renderer/OpenGL.zig | 2 +- typos.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 72e0457e9..6521226a3 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -146,7 +146,7 @@ image_bg_end: u32 = 0, image_text_end: u32 = 0, image_virtual: bool = false, -/// Defererred OpenGL operation to update the screen size. +/// Deferred OpenGL operation to update the screen size. const SetScreenSize = struct { size: renderer.Size, diff --git a/typos.toml b/typos.toml index a72944e5f..87b41336b 100644 --- a/typos.toml +++ b/typos.toml @@ -42,6 +42,7 @@ wdth = "wdth" Strat = "Strat" grey = "gray" greyscale = "grayscale" +DECID = "DECID" [type.swift.extend-words] inout = "inout" From bec46fc2fcb8d0ec02b56472b4bf9f21139e5f45 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 2 Jan 2025 19:17:34 -0800 Subject: [PATCH 089/138] Revert "gtk: equalize on double clicking the split handle (#3557)" This reverts commit 09470ede55c26e042a3c9805a8175e972b7cc89b, reversing changes made to 6139cb00cf6a50df2d47989dfb91b97286dd7879. --- src/apprt/gtk/Split.zig | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 7ac78df00..2d428acb2 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -111,16 +111,6 @@ pub fn init( // Keep a long-lived reference, which we unref in destroy. _ = c.g_object_ref(paned); - // Clicks - const gesture_click = c.gtk_gesture_click_new(); - errdefer c.g_object_unref(gesture_click); - c.gtk_event_controller_set_propagation_phase(@ptrCast(gesture_click), c.GTK_PHASE_CAPTURE); - c.gtk_gesture_single_set_button(@ptrCast(gesture_click), 1); - c.gtk_widget_add_controller(paned, @ptrCast(gesture_click)); - - // Signals - _ = c.g_signal_connect_data(gesture_click, "pressed", c.G_CALLBACK(>kMouseDown), self, null, c.G_CONNECT_DEFAULT); - // Update all of our containers to point to the right place. // The split has to point to where the sibling pointed to because // we're inheriting its parent. The sibling points to its location @@ -246,19 +236,6 @@ pub fn equalize(self: *Split) f64 { return weight; } -fn gtkMouseDown( - _: *c.GtkGestureClick, - n_press: c.gint, - _: c.gdouble, - _: c.gdouble, - ud: ?*anyopaque, -) callconv(.C) void { - if (n_press == 2) { - const self: *Split = @ptrCast(@alignCast(ud)); - _ = equalize(self); - } -} - // maxPosition returns the maximum position of the GtkPaned, which is the // "max-position" attribute. fn maxPosition(self: *Split) f64 { From 29f040716c951c823d126d68568f6735bff81cdb Mon Sep 17 00:00:00 2001 From: azhn Date: Fri, 3 Jan 2025 17:47:50 +1100 Subject: [PATCH 090/138] Fix cimgui version string to match pkg/cimgui/vendor/ and the upstream version --- pkg/cimgui/build.zig.zon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cimgui/build.zig.zon b/pkg/cimgui/build.zig.zon index 9d537c79a..2f2c9cfa0 100644 --- a/pkg/cimgui/build.zig.zon +++ b/pkg/cimgui/build.zig.zon @@ -1,10 +1,10 @@ .{ .name = "cimgui", - .version = "1.89.9", + .version = "1.90.6", // -docking branch .paths = .{""}, .dependencies = .{ // This should be kept in sync with the submodule in the cimgui source - // code to be safe that they're compatible. + // code in ./vendor/ to be safe that they're compatible. .imgui = .{ .url = "https://github.com/ocornut/imgui/archive/e391fe2e66eb1c96b1624ae8444dc64c23146ef4.tar.gz", .hash = "1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402", From 65a0fa4f35c221d11ea1c0aa3787aff07e176d0e Mon Sep 17 00:00:00 2001 From: azhn Date: Fri, 3 Jan 2025 18:20:36 +1100 Subject: [PATCH 091/138] Fix: Update pkg/simdutf/build.zig.zon to match vendored version --- pkg/simdutf/build.zig.zon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/simdutf/build.zig.zon b/pkg/simdutf/build.zig.zon index 07afe182c..63c5f41b5 100644 --- a/pkg/simdutf/build.zig.zon +++ b/pkg/simdutf/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = "simdutf", - .version = "4.0.9", + .version = "5.2.8", .paths = .{""}, .dependencies = .{ .apple_sdk = .{ .path = "../apple-sdk" }, 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 092/138] 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 093/138] 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 094/138] 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 095/138] 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 9d286de834f91f8070ec780c223404273316d5b1 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Fri, 3 Jan 2025 18:39:11 +0100 Subject: [PATCH 096/138] don't build fontconfig when system integration is enabled --- pkg/fontconfig/build.zig | 70 +++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/pkg/fontconfig/build.zig b/pkg/fontconfig/build.zig index 5a4ce8eeb..fb4dbfb36 100644 --- a/pkg/fontconfig/build.zig +++ b/pkg/fontconfig/build.zig @@ -13,7 +13,56 @@ pub fn build(b: *std.Build) !void { ) orelse (target.result.os.tag != .windows); const freetype_enabled = b.option(bool, "enable-freetype", "Build freetype") orelse true; - const module = b.addModule("fontconfig", .{ .root_source_file = b.path("main.zig") }); + const module = b.addModule("fontconfig", .{ + .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, + }; + + const 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); + + if (b.systemIntegrationOption("fontconfig", .{})) { + module.linkSystemLibrary("fontconfig", dynamic_link_opts); + test_exe.linkSystemLibrary2("fontconfig", dynamic_link_opts); + } else { + const lib = try buildLib(b, module, .{ + .target = target, + .optimize = optimize, + + .libxml2_enabled = libxml2_enabled, + .libxml2_iconv_enabled = libxml2_iconv_enabled, + .freetype_enabled = freetype_enabled, + + .dynamic_link_opts = dynamic_link_opts, + }); + + test_exe.linkLibrary(lib); + } +} + +pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const target = options.target; + const optimize = options.optimize; + + const libxml2_enabled = options.libxml2_enabled; + const libxml2_iconv_enabled = options.libxml2_iconv_enabled; + const freetype_enabled = options.freetype_enabled; const upstream = b.dependency("fontconfig", .{}); const lib = b.addStaticLibrary(.{ @@ -131,13 +180,7 @@ pub fn build(b: *std.Build) !void { } } - // 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, - }; + const dynamic_link_opts = options.dynamic_link_opts; // Freetype2 _ = b.systemIntegrationOption("freetype", .{}); // So it shows up in help @@ -194,16 +237,7 @@ pub fn build(b: *std.Build) !void { b.installArtifact(lib); - 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 headers = &.{ From e03c428728c1bb224c1114caa4207f05aa82ff19 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Jan 2025 09:46:13 -0800 Subject: [PATCH 097/138] os: directory functions should prefer cached home if available This fixes tests as well if env vars are set. --- src/os/xdg.zig | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/os/xdg.zig b/src/os/xdg.zig index a5b29abe4..1383679fe 100644 --- a/src/os/xdg.zig +++ b/src/os/xdg.zig @@ -58,6 +58,15 @@ fn dir( opts: Options, internal_opts: InternalOptions, ) ![]u8 { + // If we have a cached home dir, use that. + if (opts.home) |home| { + return try std.fs.path.join(alloc, &[_][]const u8{ + home, + internal_opts.default_subdir, + opts.subdir orelse "", + }); + } + // First check the env var. On Windows we have to allocate so this tracks // both whether we have the env var and whether we own it. // on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME` @@ -93,15 +102,6 @@ fn dir( return try alloc.dupe(u8, env); } - // If we have a cached home dir, use that. - if (opts.home) |home| { - return try std.fs.path.join(alloc, &[_][]const u8{ - home, - internal_opts.default_subdir, - opts.subdir orelse "", - }); - } - // Get our home dir var buf: [1024]u8 = undefined; if (try homedir.home(&buf)) |home| { From 45d005ce659e0016d524424017be4d2e26f0bb85 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 3 Jan 2025 09:46:13 -0800 Subject: [PATCH 098/138] input: legacy encoding falls back to mapping of logical key for ctrlseq Fixes #4518 If our UTF8 encoding is not recognized, we fall back to the ASCII mapping of the logical key for the control sequence. This allows cyrillic control characters to work. I also verified that non-cyrllic (US) and alternate layouts (Dvorak) work as expected still. --- src/input/KeyEncoder.zig | 76 ++++++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig index 734885097..2e7935214 100644 --- a/src/input/KeyEncoder.zig +++ b/src/input/KeyEncoder.zig @@ -282,7 +282,12 @@ fn legacy( // If we match a control sequence, we output that directly. For // ctrlSeq we have to use all mods because we want it to only // match ctrl+. - if (ctrlSeq(self.event.utf8, self.event.unshifted_codepoint, all_mods)) |char| { + if (ctrlSeq( + self.event.key, + self.event.utf8, + self.event.unshifted_codepoint, + all_mods, + )) |char| { // C0 sequences support alt-as-esc prefixing. if (binding_mods.alt) { if (buf.len < 2) return error.OutOfMemory; @@ -538,19 +543,17 @@ fn pcStyleFunctionKey( /// into a C0 byte. There are many cases for this and you should read /// the source code to understand them. fn ctrlSeq( + logical_key: key.Key, utf8: []const u8, unshifted_codepoint: u21, mods: key.Mods, ) ?u8 { + const ctrl_only = comptime (key.Mods{ .ctrl = true }).int(); + // If ctrl is not pressed then we never do anything. if (!mods.ctrl) return null; - // If we don't have exactly one byte in our utf8 sequence, then - // we don't do anything, since all our ctrl keys are based on ASCII. - if (utf8.len != 1) return null; - const char, const unset_mods = unset_mods: { - var char = utf8[0]; var unset_mods = mods; // Remove alt from our modifiers because it does not impact whether @@ -558,6 +561,34 @@ fn ctrlSeq( // logic separately. unset_mods.alt = false; + var char: u8 = char: { + // If we have exactly one UTF8 byte, we assume that is the + // character we want to convert to a C0 byte. + if (utf8.len == 1) break :char utf8[0]; + + // If we have a logical key that maps to a single byte + // printable character, we use that. History to explain this: + // this was added to support cyrillic keyboard layouts such + // as Russian and Mongolian. These layouts have a `c` key that + // maps to U+0441 (cyrillic small letter "c") but every + // terminal I've tested encodes this as ctrl+c. + if (logical_key.codepoint()) |cp| { + if (std.math.cast(u8, cp)) |byte| { + // For this specific case, we only map to the key if + // we have exactly ctrl pressed. This is because shift + // would modify the key and we don't know how to do that + // properly here (don't have the layout). And we want + // to encode shift as CSIu. + if (unset_mods.int() != ctrl_only) return null; + break :char byte; + } + } + + // Otherwise we don't have a character to convert that + // we can reliably map to a C0 byte. + return null; + }; + // Remove shift if we have something outside of the US letter // range. This is so that characters such as `ctrl+shift+-` // generate the correct ctrl-seq (used by emacs). @@ -596,7 +627,6 @@ fn ctrlSeq( }; // After unsetting, we only continue if we have ONLY control set. - const ctrl_only = comptime (key.Mods{ .ctrl = true }).int(); if (unset_mods.int() != ctrl_only) return null; // From Kitty's key encoding logic. I tried to discern the exact @@ -2132,36 +2162,52 @@ test "legacy: hu layout ctrl+ő sends proper codepoint" { const actual = try enc.legacy(&buf); try testing.expectEqualStrings("[337;5u", actual[1..]); } + test "ctrlseq: normal ctrl c" { - const seq = ctrlSeq("c", 'c', .{ .ctrl = true }); + const seq = ctrlSeq(.invalid, "c", 'c', .{ .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: normal ctrl c, right control" { - const seq = ctrlSeq("c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } }); + const seq = ctrlSeq(.invalid, "c", 'c', .{ .ctrl = true, .sides = .{ .ctrl = .right } }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: alt should be allowed" { - const seq = ctrlSeq("c", 'c', .{ .alt = true, .ctrl = true }); + const seq = ctrlSeq(.invalid, "c", 'c', .{ .alt = true, .ctrl = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: no ctrl does nothing" { - try testing.expect(ctrlSeq("c", 'c', .{}) == null); + try testing.expect(ctrlSeq(.invalid, "c", 'c', .{}) == null); } test "ctrlseq: shifted non-character" { - const seq = ctrlSeq("_", '-', .{ .ctrl = true, .shift = true }); + const seq = ctrlSeq(.invalid, "_", '-', .{ .ctrl = true, .shift = true }); try testing.expectEqual(@as(u8, 0x1F), seq.?); } test "ctrlseq: caps ascii letter" { - const seq = ctrlSeq("C", 'c', .{ .ctrl = true, .caps_lock = true }); + const seq = ctrlSeq(.invalid, "C", 'c', .{ .ctrl = true, .caps_lock = true }); try testing.expectEqual(@as(u8, 0x03), seq.?); } test "ctrlseq: shift does not generate ctrl seq" { - try testing.expect(ctrlSeq("C", 'c', .{ .shift = true }) == null); - try testing.expect(ctrlSeq("C", 'c', .{ .shift = true, .ctrl = true }) == null); + try testing.expect(ctrlSeq(.invalid, "C", 'c', .{ .shift = true }) == null); + try testing.expect(ctrlSeq(.invalid, "C", 'c', .{ .shift = true, .ctrl = true }) == null); +} + +test "ctrlseq: russian ctrl c" { + const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true }); + try testing.expectEqual(@as(u8, 0x03), seq.?); +} + +test "ctrlseq: russian shifted ctrl c" { + const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true, .shift = true }); + try testing.expect(seq == null); +} + +test "ctrlseq: russian alt ctrl c" { + const seq = ctrlSeq(.c, "с", 0x0441, .{ .ctrl = true, .alt = true }); + try testing.expectEqual(@as(u8, 0x03), seq.?); } From 6459e5c8cac4a5d5f2d6274139c3d199715e1fca Mon Sep 17 00:00:00 2001 From: Peter Cock Date: Fri, 3 Jan 2025 19:00:45 +0000 Subject: [PATCH 099/138] Fixing a few typos in the source code comments (#4529) --- src/apprt/gtk/App.zig | 2 +- src/apprt/gtk/key.zig | 2 +- src/config/Config.zig | 2 +- src/input/key.zig | 4 ++-- src/inspector/termio.zig | 2 +- src/renderer/OpenGL.zig | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index d74b07570..12bac989a 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1644,7 +1644,7 @@ fn gtkActionPresentSurface( // Convert that u64 to pointer to a core surface. A value of zero // means that there was no target surface for the notification so - // we dont' focus any surface. + // we don't focus any surface. const ptr_int: u64 = c.g_variant_get_uint64(parameter); if (ptr_int == 0) return; const surface: *CoreSurface = @ptrFromInt(ptr_int); diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 80191b7f5..311bff0da 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -129,7 +129,7 @@ pub fn eventMods( // 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 - // presssed (i.e. left control). + // pressed (i.e. left control). break :mods translateMods(c.gdk_device_get_modifier_state(device)); }; diff --git a/src/config/Config.zig b/src/config/Config.zig index e1a7483ea..8283c2a22 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2916,7 +2916,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { self.@"config-default-files" = true; // Keep track of the replay steps up to this point so we - // can replay if we are disgarding the default files. + // can replay if we are discarding the default files. const replay_len_start = self._replay_steps.items.len; // Keep track of font families because if they are set from the CLI diff --git a/src/input/key.zig b/src/input/key.zig index a875611d0..766498d54 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -295,7 +295,7 @@ pub const Key = enum(c_int) { eight, nine, - // puncuation + // punctuation semicolon, space, apostrophe, @@ -411,7 +411,7 @@ pub const Key = enum(c_int) { /// may be from the number row or the keypad, but it always maps /// to '.zero'. /// - /// This is what we want, we awnt people to create keybindings that + /// This is what we want, we want people to create keybindings that /// are independent of the physical key. pub fn fromASCII(ch: u8) ?Key { return switch (ch) { diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 78b35e19b..7fea4ecf8 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -35,7 +35,7 @@ pub const VTEvent = struct { const Kind = enum { print, execute, csi, esc, osc, dcs, apc }; const Metadata = std.StringHashMap([:0]const u8); - /// Initiaze the event information for the given parser action. + /// Initialize the event information for the given parser action. pub fn init( alloc: Allocator, surface: *Surface, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 6521226a3..5953d50a4 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -764,7 +764,7 @@ pub fn updateFrame( // We used to share terminal state, but we've since learned through // analysis that it is faster to copy the terminal state than to - // hold the lock wile rebuilding GPU cells. + // hold the lock while rebuilding GPU cells. var screen_copy = try state.terminal.screen.clone( self.alloc, .{ .viewport = .{} }, From 25a112469c7dabea552b33165868206d3caeeb5a Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 3 Jan 2025 14:19:19 -0500 Subject: [PATCH 100/138] 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 063868b311912f4a3c11f0e3ae12dd93085bf121 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 3 Jan 2025 14:08:00 -0600 Subject: [PATCH 101/138] gtk: add class names to the rest of the windows --- src/apprt/gtk/ClipboardConfirmationWindow.zig | 1 + src/apprt/gtk/ConfigErrorsWindow.zig | 1 + src/apprt/gtk/inspector.zig | 1 + 3 files changed, 3 insertions(+) diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index f0b60a2c6..b6db7c5ef 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)), "clipboard-confirmation-window"); _ = c.g_signal_connect_data( window, "destroy", diff --git a/src/apprt/gtk/ConfigErrorsWindow.zig b/src/apprt/gtk/ConfigErrorsWindow.zig index 3ff52908e..ff2791997 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)), "config-errors-window"); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, c.G_CONNECT_DEFAULT); // Set some state diff --git a/src/apprt/gtk/inspector.zig b/src/apprt/gtk/inspector.zig index 119e20a6c..0c5514ce8 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)), "inspector-window"); // Initialize our imgui widget try self.imgui_widget.init(); From dc90ef776eccf5c1986993aefed4955b536d4bf8 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Fri, 3 Jan 2025 21:27:22 +0100 Subject: [PATCH 102/138] don't build oniguruma when system integration is enabled --- pkg/oniguruma/build.zig | 56 ++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig index a7a5e1bcf..886bfc5bd 100644 --- a/pkg/oniguruma/build.zig +++ b/pkg/oniguruma/build.zig @@ -5,36 +5,59 @@ pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const module = b.addModule("oniguruma", .{ .root_source_file = b.path("main.zig") }); + const module = b.addModule("oniguruma", .{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); - const upstream = b.dependency("oniguruma", .{}); - const lib = try buildOniguruma(b, upstream, target, optimize); - module.addIncludePath(upstream.path("src")); - b.installArtifact(lib); + // 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()) { - const test_exe = b.addTest(.{ + 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 tests_run = b.addRunArtifact(test_exe.?); const test_step = b.step("test", "Run tests"); test_step.dependOn(&tests_run.step); // Uncomment this if we're debugging tests - b.installArtifact(test_exe); + b.installArtifact(test_exe.?); + } + + if (b.systemIntegrationOption("oniguruma", .{})) { + module.linkSystemLibrary("oniguruma", dynamic_link_opts); + + if (test_exe) |exe| { + exe.linkSystemLibrary2("oniguruma", dynamic_link_opts); + } + } else { + const lib = try buildLib(b, module, .{ + .target = target, + .optimize = optimize, + }); + + if (test_exe) |exe| { + exe.linkLibrary(lib); + } } } -fn buildOniguruma( - b: *std.Build, - upstream: *std.Build.Dependency, - target: std.Build.ResolvedTarget, - optimize: std.builtin.OptimizeMode, -) !*std.Build.Step.Compile { +pub fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile { + const target = options.target; + const optimize = options.optimize; + + const upstream = b.dependency("oniguruma", .{}); const lib = b.addStaticLibrary(.{ .name = "oniguruma", .target = target, @@ -43,6 +66,7 @@ fn buildOniguruma( const t = target.result; lib.linkLibC(); lib.addIncludePath(upstream.path("src")); + module.addIncludePath(upstream.path("src")); if (target.result.isDarwin()) { const apple_sdk = @import("apple_sdk"); @@ -134,5 +158,7 @@ fn buildOniguruma( .{ .include_extensions = &.{".h"} }, ); + b.installArtifact(lib); + return lib; } From 2610f5b4e2ac6b2029ac8bb18987a914b1a1c1c0 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Fri, 3 Jan 2025 14:32:39 -0600 Subject: [PATCH 103/138] Docs: update goto_split documentation In #4388, documentation was added for goto_split but in #3427 this documentation was made outdated but not updated. This makes the documentation up to date and brings the ordering in line with new_split --- 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 3168e8c03..33ed80c8b 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -341,7 +341,7 @@ 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:top`. Valid values are top, bottom, left, right, 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 72e0fb14fe1cf0453d585c1d58836e3a888107c7 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Fri, 3 Jan 2025 22:41:15 +0100 Subject: [PATCH 104/138] 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 105/138] 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 106/138] 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 6fd901fd3debf85377d774a68e4741c1fde6e286 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Fri, 3 Jan 2025 23:12:11 +0100 Subject: [PATCH 107/138] don't error when gtk4 could not be found via pkg-config --- build.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.zig b/build.zig index da722a2fa..d92d3e719 100644 --- a/build.zig +++ b/build.zig @@ -143,7 +143,7 @@ pub fn build(b: *std.Build) !void { break :x11 false; } std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); - return error.Unexpected; + break :x11 false; }, inline else => |code| { std.log.warn("pkg-config: {s} with code {d}", .{ @tagName(term), code }); From 69e4428d802272417fc0e40a7d0d04edc5bb43c3 Mon Sep 17 00:00:00 2001 From: dkmar Date: Fri, 3 Jan 2025 23:34:39 -0800 Subject: [PATCH 108/138] 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 109/138] 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 110/138] 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 111/138] [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 112/138] 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 113/138] 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 114/138] 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 115/138] 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 116/138] 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 117/138] 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 118/138] 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 119/138] 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 120/138] 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 121/138] 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 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 122/138] 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 123/138] 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 124/138] 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 125/138] 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 126/138] 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 127/138] 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 128/138] 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 129/138] 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 31439f311d511421690cd134d9f613960ea3de33 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Thu, 2 Jan 2025 21:44:16 +0800 Subject: [PATCH 130/138] 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 131/138] 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 132/138] 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 133/138] 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 134/138] 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 135/138] 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 136/138] 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 137/138] 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 138/138] 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;