From 1e5b02302baecf680ae6f84d3b3985ef88f325f2 Mon Sep 17 00:00:00 2001 From: DeftDawg Date: Mon, 2 Dec 2024 02:47:42 -0500 Subject: [PATCH 001/135] - 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/135] 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/135] 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 fa8314058552bda5c7c09f769f7fef7c97aa39f4 Mon Sep 17 00:00:00 2001 From: Zein Hajj-Ali Date: Sat, 28 Dec 2024 21:07:43 -0500 Subject: [PATCH 004/135] Set alpha component for fullscreen background colour --- macos/Sources/Features/Terminal/TerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7fd1802dc..e6f5befff 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -247,7 +247,7 @@ class TerminalController: BaseTerminalController { let backgroundColor: OSColor if let surfaceTree { if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) { - backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor) + backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.0) } 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 bfde326bcb2defdf90e98fb60caf083db80b2a97 Mon Sep 17 00:00:00 2001 From: sin-ack Date: Sun, 29 Dec 2024 02:05:37 +0000 Subject: [PATCH 005/135] font/freetype: Rewrite monoToGrayscale algorithm The original version had issues converting properly and caused broken glyphs. This version tries to be as simple as possible in order to make it easy to understand. I haven't measured the performance but in practice this will only happen during the first render of the glyph after a face change (i.e. during launch or when changing font size). --- src/font/face/freetype_convert.zig | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/font/face/freetype_convert.zig b/src/font/face/freetype_convert.zig index 298aad8a0..6df350bfa 100644 --- a/src/font/face/freetype_convert.zig +++ b/src/font/face/freetype_convert.zig @@ -43,26 +43,14 @@ pub fn monoToGrayscale(alloc: Allocator, bm: Bitmap) Allocator.Error!Bitmap { var buf = try alloc.alloc(u8, bm.width * bm.rows); errdefer alloc.free(buf); - // width divided by 8 because each byte has 8 pixels. This is therefore - // the number of bytes in each row. - const bytes_per_row = bm.width >> 3; - - var source_i: usize = 0; - var target_i: usize = 0; - var i: usize = bm.rows; - while (i > 0) : (i -= 1) { - var j: usize = bytes_per_row; - while (j > 0) : (j -= 1) { - var bit: u4 = 8; - while (bit > 0) : (bit -= 1) { - const mask = @as(u8, 1) << @as(u3, @intCast(bit - 1)); - const bitval: u8 = if (bm.buffer[source_i + (j - 1)] & mask > 0) 0xFF else 0; - buf[target_i] = bitval; - target_i += 1; - } + for (0..bm.rows) |y| { + const row_offset = y * @as(usize, @intCast(bm.pitch)); + for (0..bm.width) |x| { + const byte_offset = row_offset + @divTrunc(x, 8); + const mask = @as(u8, 1) << @intCast(7 - (x % 8)); + const bit: u8 = @intFromBool((bm.buffer[byte_offset] & mask) != 0); + buf[y * bm.width + x] = bit * 255; } - - source_i += @intCast(bm.pitch); } var copy = bm; From ea8fe9a4b0c438c3421ad030e70435a4efb65929 Mon Sep 17 00:00:00 2001 From: sin-ack Date: Sun, 29 Dec 2024 02:08:25 +0000 Subject: [PATCH 006/135] font/freetype: Enable bitmap glyphs for non-color faces This allows for crisp bitmap font rendering once again. --- src/font/face/freetype.zig | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index bc503a3af..3f180ad68 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -288,7 +288,6 @@ pub const Face = struct { self.face.loadGlyph(glyph_id, .{ .render = true, .color = self.face.hasColor(), - .no_bitmap = !self.face.hasColor(), }) catch return false; // If the glyph is SVG we assume colorized @@ -323,14 +322,6 @@ pub const Face = struct { // glyph properties before render so we don't render here. .render = !self.synthetic.bold, - // Disable bitmap strikes for now since it causes issues with - // our cell metrics and rasterization. In the future, this is - // all fixable so we can enable it. - // - // This must be enabled for color faces though because those are - // often colored bitmaps, which we support. - .no_bitmap = !self.face.hasColor(), - // use options from config .no_hinting = !self.load_flags.hinting, .force_autohint = !self.load_flags.@"force-autohint", From 1a6d9590a21ff600b5de49521cb08daccb35d53f Mon Sep 17 00:00:00 2001 From: sin-ack Date: Sun, 29 Dec 2024 04:55:29 +0000 Subject: [PATCH 007/135] font/freetype: Add test for crisp bitmap font rendering Now we can be certain that bitmap fonts stay crisp. :^) --- src/font/embedded.zig | 3 ++ src/font/face/freetype.zig | 52 +++++++++++++++++++++++++++ src/font/res/README.md | 3 ++ src/font/res/TerminusTTF-Regular.ttf | Bin 0 -> 500668 bytes 4 files changed, 58 insertions(+) create mode 100644 src/font/res/TerminusTTF-Regular.ttf diff --git a/src/font/embedded.zig b/src/font/embedded.zig index 098aa3eb4..31b07ff31 100644 --- a/src/font/embedded.zig +++ b/src/font/embedded.zig @@ -34,3 +34,6 @@ pub const cozette = @embedFile("res/CozetteVector.ttf"); /// Monaspace has weird ligature behaviors we want to test in our shapers /// so we embed it here. pub const monaspace_neon = @embedFile("res/MonaspaceNeon-Regular.otf"); + +/// Terminus TTF is a scalable font with bitmap glyphs at various sizes. +pub const terminus_ttf = @embedFile("res/TerminusTTF-Regular.ttf"); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 3f180ad68..0a822cbc7 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -996,3 +996,55 @@ test "svg font table" { try testing.expectEqual(430, table.len); } + +const terminus_i = + \\........ + \\........ + \\...#.... + \\...#.... + \\........ + \\..##.... + \\...#.... + \\...#.... + \\...#.... + \\...#.... + \\...#.... + \\..###... + \\........ + \\........ + \\........ + \\........ +; +// Including the newline +const terminus_i_pitch = 9; + +test "bitmap glyph" { + const alloc = testing.allocator; + const testFont = font.embedded.terminus_ttf; + + var lib = try Library.init(); + defer lib.deinit(); + + var atlas = try font.Atlas.init(alloc, 512, .grayscale); + defer atlas.deinit(alloc); + + // Any glyph at 12pt @ 96 DPI is a bitmap + var ft_font = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); + defer ft_font.deinit(); + + // glyph 77 = 'i' + const glyph = try ft_font.renderGlyph(alloc, &atlas, 77, .{}); + + // should render crisp + try testing.expectEqual(8, glyph.width); + try testing.expectEqual(16, glyph.height); + for (0..glyph.height) |y| { + for (0..glyph.width) |x| { + const pixel = terminus_i[y * terminus_i_pitch + x]; + try testing.expectEqual( + @as(u8, if (pixel == '#') 255 else 0), + atlas.data[(glyph.atlas_y + y) * atlas.size + (glyph.atlas_x + x)], + ); + } + } +} diff --git a/src/font/res/README.md b/src/font/res/README.md index 3195a8916..5ad4b274f 100644 --- a/src/font/res/README.md +++ b/src/font/res/README.md @@ -25,6 +25,9 @@ This project uses several fonts which fall under the SIL Open Font License (OFL- - [Copyright 2013 Google LLC](https://github.com/googlefonts/noto-emoji/blob/main/LICENSE) - Cozette (MIT) - [Copyright (c) 2020, Slavfox](https://github.com/slavfox/Cozette/blob/main/LICENSE) +- Terminus TTF (OFL-1.1) + - [Copyright (c) 2010-2020 Dimitar Toshkov Zhekov with Reserved Font Name "Terminus Font"](https://sourceforge.net/projects/terminus-font/) + - [Copyright (c) 2011-2023 Tilman Blumenbach with Reserved Font Name "Terminus (TTF)"](https://files.ax86.net/terminus-ttf/) A full copy of the OFL license can be found at [OFL.txt](./OFL.txt). An accompanying FAQ is also available at . diff --git a/src/font/res/TerminusTTF-Regular.ttf b/src/font/res/TerminusTTF-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d125e6347fcd4fb5a0804598393d150aef5059c1 GIT binary patch literal 500668 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 qKl?9)5TCntMSS|oT3nMW?g=5j` Date: Sun, 29 Dec 2024 04:56:17 +0000 Subject: [PATCH 008/135] font/freetype: Downgrade pixfmt conversion log to debug This is an expected occurrence with bitmap glyphs and causes unnecessary spam when using the terminal with one. --- src/font/face/freetype.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 0a822cbc7..d63cf99f1 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -376,7 +376,7 @@ pub const Face = struct { return error.UnsupportedPixelMode; }; - log.warn("converting from pixel_mode={} to atlas_format={}", .{ + log.debug("converting from pixel_mode={} to atlas_format={}", .{ bitmap_ft.pixel_mode, atlas.format, }); From e24f33ae6b011a526a2468864a270b5254edd167 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Fri, 27 Dec 2024 17:55:48 -0600 Subject: [PATCH 009/135] surface: handle hyperlinks more reliably We refresh the link hover state in two (generic) cases 1. When the modifiers change 2. When the cursor changes position Each of these have additional state qualifiers. Modify the qualifiers such that we refresh links under the following scenarios: 1. Modifiers change - Control is pressed (this is handled in the renderer) - Mouse reporting is off OR Mouse reporting is on AND shift is pressed AND we are NOT reporting shift to the terminal 2. Cursor changes position - Control is pressed (this is handled in the renderer) - We previously were over a link - The position changed (or we had no previous position) - Mouse reporting is off OR Mouse reporting is on AND shift is pressed AND we are NOT reporting shift to the terminal This fixes a few issues with the previous implementation: 1. If mouse reporting was on and you were over a link, pressing ctrl would enable link hover state. If you moved your mouse, you would exit that state. The logic in the keyCallback and the cursorPosCallback was not the same. Now, they both check for the same set of conditions 2. If mouse reporting was off, you could hold control and move the mouse to discover links. If mouse reporting was on, holding control + shift would not allow you to discover links. You had to be hovering one when you pressed the modifiers. Previously, we only refreshed links if we *weren't* reporting the mouse event. Now, we refresh links even even if we report a mouse event (ie a mouse motion event with the shift modifier pressed *will* hover links and also report events) --- src/Surface.zig | 98 ++++++++++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index eedeb4fb5..2c6e24a2c 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1703,16 +1703,37 @@ pub fn keyCallback( // Update our modifiers, this will update mouse mods too self.modsChanged(event.mods); - // Refresh our link state - const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; - self.mouseRefreshLinks( - pos, - self.posToViewport(pos.x, pos.y), - self.mouse.over_link, - ) catch |err| { - log.warn("failed to refresh links err={}", .{err}); - break :mouse_mods; - }; + // We only refresh links if + // 1. mouse reporting is off + // OR + // 2. mouse reporting is on and we are not reporting shift to the terminal + if (self.io.terminal.flags.mouse_event == .none or + (self.mouse.mods.shift and !self.mouseShiftCapture(false))) + { + // Refresh our link state + const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; + self.mouseRefreshLinks( + pos, + self.posToViewport(pos.x, pos.y), + self.mouse.over_link, + ) catch |err| { + log.warn("failed to refresh links err={}", .{err}); + break :mouse_mods; + }; + } else if (self.io.terminal.flags.mouse_event != .none and !self.mouse.mods.shift) { + // If we have mouse reports on and we don't have shift pressed, we reset state + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + self.io.terminal.mouse_shape, + ); + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + .{ .url = "" }, + ); + try self.queueRender(); + } } // Process the cursor state logic. This will update the cursor shape if @@ -3343,6 +3364,27 @@ pub fn cursorPosCallback( try self.queueRender(); } + // Handle link hovering + // We refresh links when + // 1. we were previously over a link + // OR + // 2. the cursor position has changed (either we have no previous state, or the state has + // changed) + // AND + // 1. mouse reporting is off + // OR + // 2. mouse reporting is on and we are not reporting shift to the terminal + if ((over_link or + self.mouse.link_point == null or + (self.mouse.link_point != null and !self.mouse.link_point.?.eql(pos_vp))) and + (self.io.terminal.flags.mouse_event == .none or + (self.mouse.mods.shift and !self.mouseShiftCapture(false)))) + { + // If we were previously over a link, we always update. We do this so that if the text + // changed underneath us, even if the mouse didn't move, we update the URL hints and state + try self.mouseRefreshLinks(pos, pos_vp, over_link); + } + // Do a mouse report if (self.io.terminal.flags.mouse_event != .none) report: { // Shift overrides mouse "grabbing" in the window, taken from Kitty. @@ -3363,18 +3405,6 @@ pub fn cursorPosCallback( try self.mouseReport(button, .motion, self.mouse.mods, pos); - // If we were previously over a link, we need to undo the link state. - // We also queue a render so the renderer can undo the rendered link - // state. - if (over_link) { - try self.rt_app.performAction( - .{ .surface = self }, - .mouse_over_link, - .{ .url = "" }, - ); - try self.queueRender(); - } - // If we're doing mouse motion tracking, we do not support text // selection. return; @@ -3430,30 +3460,6 @@ pub fn cursorPosCallback( return; } - - // Handle link hovering - if (self.mouse.link_point) |last_vp| { - // Mark the link's row as dirty. - if (over_link) { - self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; - } - - // If our last link viewport point is unchanged, then don't process - // links. This avoids constantly reprocessing regular expressions - // for every pixel change. - if (last_vp.eql(pos_vp)) { - // We have to restore old values that are always cleared - if (over_link) { - self.mouse.over_link = over_link; - self.renderer_state.mouse.point = pos_vp; - } - - return; - } - } - - // We can process new links. - try self.mouseRefreshLinks(pos, pos_vp, over_link); } /// Double-click dragging moves the selection one "word" at a time. From 2bb3353672bde4dc34f4c1af82ff9c303dd18292 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Sun, 29 Dec 2024 22:06:30 +0100 Subject: [PATCH 010/135] 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 011/135] 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 b3290f68870b0b5ee977fea59a5504dba3502eaa Mon Sep 17 00:00:00 2001 From: Iain H Date: Sun, 29 Dec 2024 16:20:20 -0600 Subject: [PATCH 012/135] Correct the comptime GTK atLeast() function The comptime path of the GTK `atLeast()` version function fails to take the preceeding portion of the version into account. For example version 5.1.0 is incorrectly marked as less than 4.16.7 due to the minor version (1) being less than the minor we are comparing against (16). For example, building against GTK 4.17.1: Before: version.atLeast(4,16,2) -> false After: version.atLeast(4,16,2) -> true --- src/apprt/gtk/version.zig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/version.zig b/src/apprt/gtk/version.zig index c61e940fb..8d98f6a40 100644 --- a/src/apprt/gtk/version.zig +++ b/src/apprt/gtk/version.zig @@ -19,8 +19,9 @@ pub inline fn atLeast( // compiling against unknown symbols and makes runtime checks // very slightly faster. if (comptime c.GTK_MAJOR_VERSION < major or - c.GTK_MINOR_VERSION < minor or - c.GTK_MICRO_VERSION < micro) return false; + (c.GTK_MAJOR_VERSION == major and c.GTK_MINOR_VERSION < minor) or + (c.GTK_MAJOR_VERSION == major and c.GTK_MINOR_VERSION == minor and c.GTK_MICRO_VERSION < micro)) + return false; // If we're in comptime then we can't check the runtime version. if (@inComptime()) return true; From 936d0c0d580293ba1fa4cb3d4cf8ed62a72568d6 Mon Sep 17 00:00:00 2001 From: Iain H Date: Sun, 29 Dec 2024 21:01:18 -0600 Subject: [PATCH 013/135] Add unit tests --- src/apprt/gtk/version.zig | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/apprt/gtk/version.zig b/src/apprt/gtk/version.zig index 8d98f6a40..c470a872c 100644 --- a/src/apprt/gtk/version.zig +++ b/src/apprt/gtk/version.zig @@ -39,3 +39,20 @@ pub inline fn atLeast( return false; } + +test "atLeast" { + const std = @import("std"); + const testing = std.testing; + + try testing.expectEqual(true, atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expectEqual(false, atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expectEqual(false, atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expectEqual(false, atLeast(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expectEqual(true, atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expectEqual(true, atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expectEqual(true, atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + + try testing.expectEqual(true, atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); +} From 8607b1f844844c8f7e77060e2d91efe2a7510bae Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Sun, 29 Dec 2024 21:23:16 -0600 Subject: [PATCH 014/135] macos: correctly save terminal fullscreen style --- .../Sources/Features/Terminal/BaseTerminalController.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 68c243004..b77e38f7c 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -309,11 +309,11 @@ class BaseTerminalController: NSWindowController, // We consider our mode changed if the types change (obvious) but // also if its nil (not obvious) because nil means that the style has // likely changed but we don't support it. - if newStyle == nil || type(of: newStyle) != type(of: oldStyle) { + if newStyle == nil || type(of: newStyle!) != type(of: oldStyle) { // Our mode changed. Exit fullscreen (since we're toggling anyways) - // and then unset the style so that we replace it next time. + // and then set the new style for future use oldStyle.exit() - self.fullscreenStyle = nil + self.fullscreenStyle = newStyle // We're done return From 0fd65035c58f5b23265dd2a865d9c365911090b3 Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Sun, 29 Dec 2024 16:45:28 -0600 Subject: [PATCH 015/135] apprt/gtk: fix the combination of gtk-titlebar=false and gtk-tabs-location=hidden Fixes: #3178 Signed-off-by: Tristan Partin --- src/apprt/gtk/Window.zig | 66 +++++++++++++++------------------------- 1 file changed, 24 insertions(+), 42 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 6f43d06c3..1303b9f44 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -83,7 +83,7 @@ pub fn init(self: *Window, app: *App) !void { // Create the window const window: *c.GtkWidget = window: { - if (self.isAdwWindow()) { + if ((comptime adwaita.versionAtLeast(0, 0, 0)) and adwaita.enabled(&self.app.config)) { const window = c.adw_application_window_new(app.app); c.gtk_widget_add_css_class(@ptrCast(window), "adw"); break :window window; @@ -125,7 +125,7 @@ pub fn init(self: *Window, app: *App) !void { self.notebook = Notebook.create(self); // If we are using Adwaita, then we can support the tab overview. - self.tab_overview = if ((comptime adwaita.versionAtLeast(1, 3, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 3, 0)) 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_enable_new_tab(@ptrCast(tab_overview), 1); @@ -167,7 +167,7 @@ pub fn init(self: *Window, app: *App) !void { // If we're using an AdwWindow then we can support the tab overview. if (self.tab_overview) |tab_overview| { if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; - assert(self.isAdwWindow()); + assert(self.app.config.@"gtk-adwaita" and adwaita.versionAtLeast(1, 4, 0)); const btn = switch (app.config.@"gtk-tabs-location") { .top, .bottom, .left, .right => btn: { const btn = c.gtk_toggle_button_new(); @@ -279,12 +279,18 @@ pub fn init(self: *Window, app: *App) !void { // Our actions for the menu initActions(self); - if (self.isAdwWindow()) { - if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; + if ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new()); - const header_widget: *c.GtkWidget = self.header.?.asWidget(); - c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); + if (self.header) |header| { + const header_widget = header.asWidget(); + c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); + + // If we are not decorated then we hide the titlebar. + if (!app.config.@"window-decoration") { + c.gtk_widget_set_visible(header_widget, 0); + } + } if (self.app.config.@"gtk-tabs-location" != .hidden) { const tab_bar = c.adw_tab_bar_new(); @@ -310,28 +316,15 @@ pub fn init(self: *Window, app: *App) !void { c.adw_toolbar_view_set_top_bar_style(toolbar_view, toolbar_style); c.adw_toolbar_view_set_bottom_bar_style(toolbar_view, toolbar_style); - // If we are not decorated then we hide the titlebar. - if (!app.config.@"window-decoration") { - c.gtk_widget_set_visible(header_widget, 0); - } - - // Set our application window content. The content depends on if - // we're using an AdwTabOverview or not. - if (self.tab_overview) |tab_overview| { - c.adw_tab_overview_set_child( - @ptrCast(tab_overview), - @ptrCast(@alignCast(toolbar_view)), - ); - c.adw_application_window_set_content( - @ptrCast(gtk_window), - @ptrCast(@alignCast(tab_overview)), - ); - } else { - c.adw_application_window_set_content( - @ptrCast(gtk_window), - @ptrCast(@alignCast(toolbar_view)), - ); - } + // Set our application window content. + c.adw_tab_overview_set_child( + @ptrCast(self.tab_overview), + @ptrCast(@alignCast(toolbar_view)), + ); + c.adw_application_window_set_content( + @ptrCast(gtk_window), + @ptrCast(@alignCast(self.tab_overview)), + ); } else tab_bar: { switch (self.notebook) { .adw_tab_view => |tab_view| if (comptime adwaita.versionAtLeast(0, 0, 0)) { @@ -415,17 +408,6 @@ pub fn deinit(self: *Window) void { } } -/// Returns true if this window should use an Adwaita window. -/// -/// This must be `inline` so that the comptime check noops conditional -/// paths that are not enabled. -inline fn isAdwWindow(self: *Window) bool { - return (comptime adwaita.versionAtLeast(1, 4, 0)) and - adwaita.enabled(&self.app.config) and - adwaita.versionAtLeast(1, 4, 0) and - self.app.config.@"gtk-titlebar"; -} - /// Add a new tab to this window. pub fn newTab(self: *Window, parent: ?*CoreSurface) !void { const alloc = self.app.core_app.alloc; @@ -557,7 +539,7 @@ fn gtkTabNewClick(_: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { /// because we need to return an AdwTabPage from this function. fn gtkNewTabFromOverview(_: *c.GtkWidget, ud: ?*anyopaque) callconv(.C) ?*c.AdwTabPage { const self: *Window = userdataSelf(ud.?); - assert(self.isAdwWindow()); + assert((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)); const alloc = self.app.core_app.alloc; const surface = self.actionSurface(); @@ -739,7 +721,7 @@ fn gtkActionAbout( if ((comptime adwaita.versionAtLeast(1, 5, 0)) and adwaita.versionAtLeast(1, 5, 0) and - self.isAdwWindow()) + adwaita.enabled(&self.app.config)) { c.adw_show_about_dialog( @ptrCast(self.window), From 4d983a208384cfa420f2628844c59f251cee7d8f Mon Sep 17 00:00:00 2001 From: moni-dz Date: Mon, 30 Dec 2024 12:32:37 +0800 Subject: [PATCH 016/135] surface: don't issue mode 2031 DSR reports when colors are changed by a VT sequence --- src/Surface.zig | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 053dec3fd..4472ce1c4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -853,11 +853,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }, .color_change => |change| { - // On any color change, we have to report for mode 2031 - // if it is enabled. - self.reportColorScheme(false); - - // Notify our apprt + // Notify our apprt, but don't send a mode 2031 DSR report + // because VT sequences were used to change the color. try self.rt_app.performAction( .{ .surface = self }, .color_change, From 057dd3e20924432a33a9ff628114a6c787b74f67 Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Sun, 29 Dec 2024 23:51:29 -0600 Subject: [PATCH 017/135] apprt/gtk: move some static CSS to the style.css file Signed-off-by: Tristan Partin --- src/apprt/gtk/App.zig | 3 --- src/apprt/gtk/style.css | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c6e2b4d08..033f4788c 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -972,9 +972,6 @@ fn loadRuntimeCss( const headerbar_foreground = config.@"window-titlebar-foreground" orelse config.foreground; try writer.print( - \\window.without-window-decoration-and-with-titlebar {{ - \\ border-radius: 0 0; - \\}} \\widget.unfocused-split {{ \\ opacity: {d:.2}; \\ background-color: rgb({d},{d},{d}); diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index 65dc0c075..edafc84c7 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -33,6 +33,10 @@ label.size-overlay.hidden { opacity: 0; } +window.without-window-decoration-and-with-titlebar { + border-radius: 0 0; +} + .transparent { background-color: transparent; } From e9bc033b882b2681d9dab08868e481ace799f20d Mon Sep 17 00:00:00 2001 From: Leigh Oliver Date: Sat, 28 Dec 2024 11:36:02 +0000 Subject: [PATCH 018/135] fix(gtk): fix issue detecting preferred color scheme --- src/apprt/gtk/App.zig | 53 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c6e2b4d08..b8fe9d0b7 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1403,7 +1403,15 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme { null, &err, ) orelse { - if (err) |e| log.err("unable to get current color scheme: {s}", .{e.message}); + if (err) |e| { + // If ReadOne is not yet implemented, fall back to deprecated "Read" method + // Error code: GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: No such method “ReadOne” + if (e.code == 19) { + return self.getColorSchemeDeprecated(); + } + // Otherwise, log the error and return .light + log.err("unable to get current color scheme: {s}", .{e.message}); + } return .light; }; defer c.g_variant_unref(value); @@ -1420,6 +1428,49 @@ pub fn getColorScheme(self: *App) apprt.ColorScheme { return .light; } +/// Call the deprecated D-Bus "Read" method to determine the current color scheme. If +/// there is any error at any point we'll log the error and return "light" +fn getColorSchemeDeprecated(self: *App) apprt.ColorScheme { + const dbus_connection = c.g_application_get_dbus_connection(@ptrCast(self.app)); + var err: ?*c.GError = null; + defer if (err) |e| c.g_error_free(e); + + const value = c.g_dbus_connection_call_sync( + dbus_connection, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", + "Read", + c.g_variant_new("(ss)", "org.freedesktop.appearance", "color-scheme"), + c.G_VARIANT_TYPE("(v)"), + c.G_DBUS_CALL_FLAGS_NONE, + -1, + null, + &err, + ) orelse { + if (err) |e| log.err("Read method failed: {s}", .{e.message}); + return .light; + }; + defer c.g_variant_unref(value); + + if (c.g_variant_is_of_type(value, c.G_VARIANT_TYPE("(v)")) == 1) { + var inner: ?*c.GVariant = null; + c.g_variant_get(value, "(v)", &inner); + defer if (inner) |i| c.g_variant_unref(i); + + if (inner) |i| { + const child = c.g_variant_get_child_value(i, 0) orelse { + return .light; + }; + defer c.g_variant_unref(child); + + const val = c.g_variant_get_uint32(child); + return if (val == 1) .dark else .light; + } + } + return .light; +} + /// This will be called by D-Bus when the style changes between light & dark. fn gtkNotifyColorScheme( _: ?*c.GDBusConnection, From aed61b62aecfe3d47ef4e7d0f850a5f9a7c5f384 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Mon, 30 Dec 2024 10:29:28 +0100 Subject: [PATCH 019/135] zsh: handle short boolean flags --- src/build/zsh_completions.zig | 148 ++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 71 deletions(-) diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig index a451c7175..27d3bedf3 100644 --- a/src/build/zsh_completions.zig +++ b/src/build/zsh_completions.zig @@ -47,50 +47,52 @@ fn writeZshCompletions(writer: anytype) !void { if (field.name[0] == '_') continue; try writer.writeAll(" \"--"); try writer.writeAll(field.name); - try writer.writeAll("=-:::"); - if (std.mem.startsWith(u8, field.name, "font-family")) - try writer.writeAll("_fonts") - else if (std.mem.eql(u8, "theme", field.name)) - try writer.writeAll("_themes") - else if (std.mem.eql(u8, "working-directory", field.name)) - try writer.writeAll("{_files -/}") - else if (field.type == Config.RepeatablePath) - try writer.writeAll("_files") // todo check if this is needed - else { - try writer.writeAll("("); - switch (@typeInfo(field.type)) { - .Bool => try writer.writeAll("true false"), - .Enum => |info| { - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - }, - .Struct => |info| { - if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") { + if (@typeInfo(field.type) != .Bool) { + try writer.writeAll("=-:::"); + + if (std.mem.startsWith(u8, field.name, "font-family")) + try writer.writeAll("_fonts") + else if (std.mem.eql(u8, "theme", field.name)) + try writer.writeAll("_themes") + else if (std.mem.eql(u8, "working-directory", field.name)) + try writer.writeAll("{_files -/}") + else if (field.type == Config.RepeatablePath) + try writer.writeAll("_files") // todo check if this is needed + else { + try writer.writeAll("("); + switch (@typeInfo(field.type)) { + .Enum => |info| { for (info.fields, 0..) |f, i| { if (i > 0) try writer.writeAll(" "); try writer.writeAll(f.name); - try writer.writeAll(" no-"); - try writer.writeAll(f.name); } - } else { - //resize-overlay-duration - //keybind - //window-padding-x ...-y - //link - //palette - //background - //foreground - //font-variation* - //font-feature - try writer.writeAll(" "); - } - }, - else => try writer.writeAll(" "), + }, + .Struct => |info| { + if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") { + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + try writer.writeAll(" no-"); + try writer.writeAll(f.name); + } + } else { + //resize-overlay-duration + //keybind + //window-padding-x ...-y + //link + //palette + //background + //foreground + //font-variation* + //font-feature + try writer.writeAll(" "); + } + }, + else => try writer.writeAll(" "), + } + try writer.writeAll(")"); } - try writer.writeAll(")"); } try writer.writeAll("\" \\\n"); @@ -170,40 +172,44 @@ fn writeZshCompletions(writer: anytype) !void { try writer.writeAll(padding ++ " '--"); try writer.writeAll(opt.name); - try writer.writeAll("=-:::"); - switch (@typeInfo(opt.type)) { - .Bool => try writer.writeAll("(true false)"), - .Enum => |info| { - try writer.writeAll("("); - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - try writer.writeAll(")"); - }, - .Optional => |optional| { - switch (@typeInfo(optional.child)) { - .Enum => |info| { - try writer.writeAll("("); - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - try writer.writeAll(")"); - }, - else => { - if (std.mem.eql(u8, "config-file", opt.name)) { - try writer.writeAll("_files"); - } else try writer.writeAll("( )"); - }, - } - }, - else => { - if (std.mem.eql(u8, "config-file", opt.name)) { - try writer.writeAll("_files"); - } else try writer.writeAll("( )"); - }, + + if (@typeInfo(opt.type) != .Bool) { + try writer.writeAll("=-:::"); + + switch (@typeInfo(opt.type)) { + .Enum => |info| { + try writer.writeAll("("); + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + try writer.writeAll(")"); + }, + .Optional => |optional| { + switch (@typeInfo(optional.child)) { + .Enum => |info| { + try writer.writeAll("("); + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + try writer.writeAll(")"); + }, + else => { + if (std.mem.eql(u8, "config-file", opt.name)) { + try writer.writeAll("_files"); + } else try writer.writeAll("( )"); + }, + } + }, + else => { + if (std.mem.eql(u8, "config-file", opt.name)) { + try writer.writeAll("_files"); + } else try writer.writeAll("( )"); + }, + } } + try writer.writeAll("' \\\n"); } try writer.writeAll(padding ++ ";;\n"); From d01b2397f14aaafa07f1821b7a1c54e465ef41f3 Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Mon, 30 Dec 2024 10:39:43 +0100 Subject: [PATCH 020/135] fish: handle short boolean flags --- src/build/fish_completions.zig | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index a06199256..454036724 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -56,7 +56,6 @@ fn writeFishCompletions(writer: anytype) !void { else { try writer.writeAll(if (field.type != Config.RepeatablePath) " -f" else " -F"); switch (@typeInfo(field.type)) { - .Bool => try writer.writeAll(" -a \"true false\""), .Enum => |info| { try writer.writeAll(" -a \""); for (info.fields, 0..) |f, i| { @@ -114,7 +113,6 @@ fn writeFishCompletions(writer: anytype) !void { } else try writer.writeAll(" -f"); switch (@typeInfo(opt.type)) { - .Bool => try writer.writeAll(" -a \"true false\""), .Enum => |info| { try writer.writeAll(" -a \""); for (info.fields, 0..) |f, i| { From 33b1131a145ae7591877ecaf2550a65d35627c72 Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Mon, 30 Dec 2024 00:26:52 +0100 Subject: [PATCH 021/135] 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 4f2110bce012131dcb7e36310029e4d5b6ef37f5 Mon Sep 17 00:00:00 2001 From: Iain H Date: Sun, 29 Dec 2024 21:49:43 -0600 Subject: [PATCH 022/135] Be more idiomatic in tests when comparing to booleans --- src/apprt/gtk/version.zig | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/apprt/gtk/version.zig b/src/apprt/gtk/version.zig index c470a872c..af7ad12ea 100644 --- a/src/apprt/gtk/version.zig +++ b/src/apprt/gtk/version.zig @@ -44,15 +44,15 @@ test "atLeast" { const std = @import("std"); const testing = std.testing; - try testing.expectEqual(true, atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expectEqual(false, atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expectEqual(false, atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expectEqual(false, atLeast(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(!atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(!atLeast(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expectEqual(true, atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expectEqual(true, atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expectEqual(true, atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(atLeast(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expectEqual(true, atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + try testing.expect(atLeast(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); } From 4e7982fc2b23eb0f4d1af72975e5f969ee13ed79 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Mon, 30 Dec 2024 09:32:15 -0500 Subject: [PATCH 023/135] bash: improved 'sudo' command wrapper The previous approach to wrapping `sudo` had a few shortcomings: 1. We were (re)defining our 'sudo' function wrapper in the "precmd" path. It only needs to be defined once in the shell session. 2. If there was an existing 'sudo' alias, the function definition would conflict and result in a syntax error. Fix (1) by hoisting the 'sudo' function into global scope. I also considered only defining our wrapper if an executable `sudo` binary could be found (e.g. `-x $(builtin command -v sudo)`, but let's keep the existing behavior for now. This allows for a `sudo` command to be installed later in the shell session and still be wrapped. Address (2) by defining the wrapper function using `function sudo` (instead of `sudo()`) syntax. An explicit function definition won't clash with an existing 'sudo' alias, although the alias will continue to take precedence (i.e. our wrapper won't be called). If the alias is defined _after_ our 'sudo' function is defined, our function will call the aliased command. This ordering is relevant because it can result in different behaviors depending on when a user defines their aliases relative to sourcing the shell integration script. Our recommendation remains that users either use automatic shell injection or manually source the shell integration script _before_ other things in their `.bashrc`, so that aligns with the expected behavior of the 'sudo' wrapper with regard to aliases. Given that, I don't think we need any more explicit user-facing documentation on this beyond the script-level comments. --- src/shell-integration/bash/ghostty.bash | 53 +++++++++++++------------ 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 48b03fed0..a6782e686 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -72,6 +72,34 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then builtin unset ghostty_bash_inject rcfile fi +# Sudo +if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" != "1" && -n "$TERMINFO" ]]; then + # Wrap `sudo` command to ensure Ghostty terminfo is preserved. + # + # This approach supports wrapping a `sudo` alias, but the alias definition + # must come _after_ this function is defined. Otherwise, the alias expansion + # will take precedence over this function, and it won't be wrapped. + function sudo { + builtin local sudo_has_sudoedit_flags="no" + for arg in "$@"; do + # Check if argument is '-e' or '--edit' (sudoedit flags) + if [[ "$arg" == "-e" || $arg == "--edit" ]]; then + sudo_has_sudoedit_flags="yes" + builtin break + fi + # Check if argument is neither an option nor a key-value pair + if [[ "$arg" != -* && "$arg" != *=* ]]; then + builtin break + fi + done + if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then + builtin command sudo "$@"; + else + builtin command sudo TERMINFO="$TERMINFO" "$@"; + fi + } +fi + # Import bash-preexec, safe to do multiple times builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh" @@ -113,31 +141,6 @@ function __ghostty_precmd() { PS0=$PS0'\[\e[0 q\]' fi - # Sudo - if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_SUDO" != "1" ]] && [[ -n "$TERMINFO" ]]; then - # Wrap `sudo` command to ensure Ghostty terminfo is preserved - # shellcheck disable=SC2317 - sudo() { - builtin local sudo_has_sudoedit_flags="no" - for arg in "$@"; do - # Check if argument is '-e' or '--edit' (sudoedit flags) - if [[ "$arg" == "-e" || $arg == "--edit" ]]; then - sudo_has_sudoedit_flags="yes" - builtin break - fi - # Check if argument is neither an option nor a key-value pair - if [[ "$arg" != -* && "$arg" != *=* ]]; then - builtin break - fi - done - if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then - builtin command sudo "$@"; - else - builtin command sudo TERMINFO="$TERMINFO" "$@"; - fi - } - fi - if [[ "$GHOSTTY_SHELL_INTEGRATION_NO_TITLE" != 1 ]]; then # Command and working directory # shellcheck disable=SC2016 From 011c17da41a263438382b0c7582fe2a6233dd1d7 Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Mon, 30 Dec 2024 12:29:03 +0100 Subject: [PATCH 024/135] fix: quick terminal CPU spikes Fixes #3998 --- .../QuickTerminalController.swift | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index e4606f729..47ee2dfd9 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -69,7 +69,7 @@ class QuickTerminalController: BaseTerminalController { window.isRestorable = false // Setup our configured appearance that we support. - syncAppearance(ghostty.config) + syncAppearance() // Setup our initial size based on our configured position position.setLoaded(window) @@ -214,6 +214,10 @@ class QuickTerminalController: BaseTerminalController { // If we canceled our animation in we do nothing guard self.visible else { return } + // Now that the window is visible, sync our appearance. This function + // requires the window is visible. + self.syncAppearance() + // Once our animation is done, we must grab focus since we can't grab // focus of a non-visible window. self.makeWindowKey(window) @@ -304,24 +308,18 @@ class QuickTerminalController: BaseTerminalController { }) } - private func syncAppearance(_ config: Ghostty.Config) { + private func syncAppearance() { guard let window else { return } - // If our window is not visible, then delay this. This is possible specifically - // during state restoration but probably in other scenarios as well. To delay, - // we just loop directly on the dispatch queue. We have to delay because some - // APIs such as window blur have no effect unless the window is visible. - guard window.isVisible else { - // Weak window so that if the window changes or is destroyed we aren't holding a ref - DispatchQueue.main.async { [weak self] in self?.syncAppearance(config) } - return - } + // If our window is not visible, then no need to sync the appearance yet. + // Some APIs such as window blur have no effect unless the window is visible. + guard window.isVisible else { return } // Terminals typically operate in sRGB color space and macOS defaults // to "native" which is typically P3. There is a lot more resources // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 // Ghostty defaults to sRGB but this can be overridden. - switch (config.windowColorspace) { + switch (self.derivedConfig.windowColorspace) { case "display-p3": window.colorSpace = .displayP3 case "srgb": @@ -331,7 +329,7 @@ class QuickTerminalController: BaseTerminalController { } // If we have window transparency then set it transparent. Otherwise set it opaque. - if (config.backgroundOpacity < 1) { + if (self.derivedConfig.backgroundOpacity < 1) { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -391,24 +389,30 @@ class QuickTerminalController: BaseTerminalController { // Update our derived config self.derivedConfig = DerivedConfig(config) - syncAppearance(config) + syncAppearance() } private struct DerivedConfig { let quickTerminalScreen: QuickTerminalScreen let quickTerminalAnimationDuration: Double let quickTerminalAutoHide: Bool + let windowColorspace: String + let backgroundOpacity: Double init() { self.quickTerminalScreen = .main self.quickTerminalAnimationDuration = 0.2 self.quickTerminalAutoHide = true + self.windowColorspace = "" + self.backgroundOpacity = 1.0 } init(_ config: Ghostty.Config) { self.quickTerminalScreen = config.quickTerminalScreen self.quickTerminalAnimationDuration = config.quickTerminalAnimationDuration self.quickTerminalAutoHide = config.quickTerminalAutoHide + self.windowColorspace = config.windowColorspace + self.backgroundOpacity = config.backgroundOpacity } } } From 66681f94e04f61dc3d7d0d8456866f919d60ceea Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 26 Dec 2024 13:37:30 +0300 Subject: [PATCH 025/135] 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 f2ac9b85e367b01263cf0059c56819bad9cea606 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 30 Dec 2024 09:49:55 -0600 Subject: [PATCH 026/135] gtk: correct comptime adwaita.versionAtLeast() comparison --- src/apprt/gtk/adwaita.zig | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/adwaita.zig b/src/apprt/gtk/adwaita.zig index 2c28bc39b..075055586 100644 --- a/src/apprt/gtk/adwaita.zig +++ b/src/apprt/gtk/adwaita.zig @@ -25,7 +25,10 @@ pub inline fn enabled(config: *const Config) bool { /// in the headers. If it is run in a runtime context, it will /// check the actual version of the library we are linked against. /// So generally you probably want to do both checks! -pub fn versionAtLeast( +/// +/// This is inlined so that the comptime checks will disable the +/// runtime checks if the comptime checks fail. +pub inline fn versionAtLeast( comptime major: u16, comptime minor: u16, comptime micro: u16, @@ -37,8 +40,9 @@ pub fn versionAtLeast( // compiling against unknown symbols and makes runtime checks // very slightly faster. if (comptime c.ADW_MAJOR_VERSION < major or - c.ADW_MINOR_VERSION < minor or - c.ADW_MICRO_VERSION < micro) return false; + (c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION < minor) or + (c.ADW_MAJOR_VERSION == major and c.ADW_MINOR_VERSION == minor and c.ADW_MICRO_VERSION < micro)) + return false; // If we're in comptime then we can't check the runtime version. if (@inComptime()) return true; @@ -56,3 +60,16 @@ pub fn versionAtLeast( return false; } + +test "versionAtLeast" { + const testing = std.testing; + + try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); + try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); + try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); + try testing.expect(!versionAtLeast(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); + try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); + try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); + try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); + try testing.expect(versionAtLeast(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1)); +} From adcaff7137ef1fedf5f094126032ce7de34d73e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 29 Dec 2024 19:49:31 -0800 Subject: [PATCH 027/135] config: edit opens AppSupport over XDG on macOS, prefers non-empty paths Fixes #3953 Fixes #3284 This fixes two issues. In fixing one issue, the other became apparent so I fixed both in this one commit. The first issue is that on macOS, the `open` command should take the `-t` flag to open text files in a text editor. To do this, the `os.open` function now takes a type hint that is used to better do the right thing. Second, the order of the paths that we attempt to open when editing a config on macOS is wrong. Our priority when loading configs is well documented: https://ghostty.org/docs/config#macos-specific-path-(macos-only). But open_config does the opposite. This makes it too easy for people to have configs that are being overridden without them realizing it. This commit changes the order of the paths to match the documented order. If neither path exists, we prefer AppSupport. --- src/Surface.zig | 6 +-- src/config/edit.zig | 106 +++++++++++++++++++++++++++++++++++--------- src/os/macos.zig | 2 +- src/os/main.zig | 1 + src/os/open.zig | 64 +++++++++++++++++++------- 5 files changed, 140 insertions(+), 39 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 053dec3fd..13e986919 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3195,7 +3195,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { .trim = false, }); defer self.alloc.free(str); - try internal_os.open(self.alloc, str); + try internal_os.open(self.alloc, .unknown, str); }, ._open_osc8 => { @@ -3203,7 +3203,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; - try internal_os.open(self.alloc, uri); + try internal_os.open(self.alloc, .unknown, uri); }, } @@ -4303,7 +4303,7 @@ fn writeScreenFile( const path = try tmp_dir.dir.realpath(filename, &path_buf); switch (write_action) { - .open => try internal_os.open(self.alloc, path), + .open => try internal_os.open(self.alloc, .text, path), .paste => self.io.queueMessage(try termio.Message.writeReq( self.alloc, path, diff --git a/src/config/edit.zig b/src/config/edit.zig index 692447594..68d9da88c 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -1,31 +1,29 @@ const std = @import("std"); const builtin = @import("builtin"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const internal_os = @import("../os/main.zig"); /// Open the configuration in the OS default editor according to the default /// paths the main config file could be in. +/// +/// On Linux, this will open the file at the XDG config path. This is the +/// only valid path for Linux so we don't need to check for other paths. +/// +/// On macOS, both XDG and AppSupport paths are valid. Because Ghostty +/// prioritizes AppSupport over XDG, we will open AppSupport if it exists, +/// followed by XDG if it exists, and finally AppSupport if neither exist. +/// For the existence check, we also prefer non-empty files over empty +/// files. pub fn open(alloc_gpa: Allocator) !void { - // default location - const config_path = config_path: { - const xdg_config_path = try internal_os.xdg.config(alloc_gpa, .{ .subdir = "ghostty/config" }); + // Use an arena to make memory management easier in here. + var arena = ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); - if (comptime builtin.os.tag == .macos) macos: { - // On macOS, use the application support path if the XDG path doesn't exists. - if (std.fs.accessAbsolute(xdg_config_path, .{})) { - break :macos; - } else |err| switch (err) { - error.BadPathName, error.FileNotFound => {}, - else => break :macos, - } - - alloc_gpa.free(xdg_config_path); - break :config_path try internal_os.macos.appSupportDir(alloc_gpa, "config"); - } - - break :config_path xdg_config_path; - }; - defer alloc_gpa.free(config_path); + // Get the path we should open + const config_path = try configPath(alloc); // Create config directory recursively. if (std.fs.path.dirname(config_path)) |config_dir| { @@ -43,5 +41,73 @@ pub fn open(alloc_gpa: Allocator) !void { } }; - try internal_os.open(alloc_gpa, config_path); + try internal_os.open(alloc, .text, config_path); +} + +/// Returns the config path to use for open for the current OS. +/// +/// The allocator must be an arena allocator. No memory is freed by this +/// function and the resulting path is not all the memory that is allocated. +/// +/// NOTE: WHY IS THIS INLINE? This is inline because when this is not +/// inline then Zig 0.13 crashes [most of the time] when trying to compile +/// this file. This is a workaround for that issue. This function is only +/// called from one place that is not performance critical so it is fine +/// to be inline. +inline fn configPath(alloc_arena: Allocator) ![]const u8 { + const paths: []const []const u8 = try configPathCandidates(alloc_arena); + assert(paths.len > 0); + + // Find the first path that exists and is non-empty. If no paths are + // non-empty but at least one exists, we will return the first path that + // exists. + var exists: ?[]const u8 = null; + for (paths) |path| { + const f = std.fs.openFileAbsolute(path, .{}) catch |err| { + switch (err) { + // File doesn't exist, continue. + error.BadPathName, error.FileNotFound => continue, + + // Some other error, assume it exists and return it. + else => return err, + } + }; + defer f.close(); + + // We expect stat to succeed because we just opened the file. + const stat = try f.stat(); + + // If the file is non-empty, return it. + if (stat.size > 0) return path; + + // If the file is empty, remember it exists. + if (exists == null) exists = path; + } + + // No paths are non-empty, return the first path that exists. + if (exists) |v| return v; + + // No paths are non-empty or exist, return the first path. + return paths[0]; +} + +/// Returns a const list of possible paths the main config file could be +/// in for the current OS. +fn configPathCandidates(alloc_arena: Allocator) ![]const []const u8 { + var paths = try std.ArrayList([]const u8).initCapacity(alloc_arena, 2); + errdefer paths.deinit(); + + if (comptime builtin.os.tag == .macos) { + paths.appendAssumeCapacity(try internal_os.macos.appSupportDir( + alloc_arena, + "config", + )); + } + + paths.appendAssumeCapacity(try internal_os.xdg.config( + alloc_arena, + .{ .subdir = "ghostty/config" }, + )); + + return paths.items; } diff --git a/src/os/macos.zig b/src/os/macos.zig index 53dfd1719..b3d0a917c 100644 --- a/src/os/macos.zig +++ b/src/os/macos.zig @@ -24,7 +24,7 @@ pub const AppSupportDirError = Allocator.Error || error{AppleAPIFailed}; pub fn appSupportDir( alloc: Allocator, sub_path: []const u8, -) AppSupportDirError![]u8 { +) AppSupportDirError![]const u8 { comptime assert(builtin.target.isDarwin()); const NSFileManager = objc.getClass("NSFileManager").?; diff --git a/src/os/main.zig b/src/os/main.zig index 3b7007fcb..98e57b4fc 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -41,5 +41,6 @@ pub const home = homedir.home; pub const ensureLocale = locale.ensureLocale; pub const clickInterval = mouse.clickInterval; pub const open = openpkg.open; +pub const OpenType = openpkg.Type; pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; diff --git a/src/os/open.zig b/src/os/open.zig index 8df059487..ff7d6049a 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -2,25 +2,26 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +/// The type of the data at the URL to open. This is used as a hint +/// to potentially open the URL in a different way. +pub const Type = enum { + text, + unknown, +}; + /// Open a URL in the default handling application. /// /// Any output on stderr is logged as a warning in the application logs. /// Output on stdout is ignored. -pub fn open(alloc: Allocator, url: []const u8) !void { - // Some opener commands terminate after opening (macOS open) and some do not - // (xdg-open). For those which do not terminate, we do not want to wait for - // the process to exit to collect stderr. - const argv, const wait = switch (builtin.os.tag) { - .linux => .{ &.{ "xdg-open", url }, false }, - .macos => .{ &.{ "open", url }, true }, - .windows => .{ &.{ "rundll32", "url.dll,FileProtocolHandler", url }, false }, - .ios => return error.Unimplemented, - else => @compileError("unsupported OS"), - }; +pub fn open( + alloc: Allocator, + typ: Type, + url: []const u8, +) !void { + const cmd = try openCommand(alloc, typ, url); - var exe = std.process.Child.init(argv, alloc); - - if (comptime wait) { + var exe = cmd.child; + if (cmd.wait) { // Pipe stdout/stderr so we can collect output from the command exe.stdout_behavior = .Pipe; exe.stderr_behavior = .Pipe; @@ -28,7 +29,7 @@ pub fn open(alloc: Allocator, url: []const u8) !void { try exe.spawn(); - if (comptime wait) { + if (cmd.wait) { // 50 KiB is the default value used by std.process.Child.run const output_max_size = 50 * 1024; @@ -47,3 +48,36 @@ pub fn open(alloc: Allocator, url: []const u8) !void { if (stderr.items.len > 0) std.log.err("open stderr={s}", .{stderr.items}); } } + +const OpenCommand = struct { + child: std.process.Child, + wait: bool = false, +}; + +fn openCommand(alloc: Allocator, typ: Type, url: []const u8) !OpenCommand { + return switch (builtin.os.tag) { + .linux => .{ .child = std.process.Child.init( + &.{ "xdg-open", url }, + alloc, + ) }, + + .windows => .{ .child = std.process.Child.init( + &.{ "rundll32", "url.dll,FileProtocolHandler", url }, + alloc, + ) }, + + .macos => .{ + .child = std.process.Child.init( + switch (typ) { + .text => &.{ "open", "-t", url }, + .unknown => &.{ "open", url }, + }, + alloc, + ), + .wait = true, + }, + + .ios => return error.Unimplemented, + else => @compileError("unsupported OS"), + }; +} From c62f64866c8183ca15a16e879c71a828c73a46f1 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Mon, 30 Dec 2024 22:55:01 +0800 Subject: [PATCH 028/135] Ensure correct coordinate ordering in selection file write When writing selected text to file, use `topLeft` and `bottomRight` instead of `start` and `end` to ensure correct coordinate ordering. This fixes an issue where selection files could be empty when selecting text in reverse order. - Use `terminal.Selection.topLeft()` for start coordinate - Use `terminal.Selection.bottomRight()` for end coordinate --- src/Surface.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 9a6d2f6db..5b39c6046 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4293,11 +4293,16 @@ fn writeScreenFile( tmp_dir.deinit(); return; }; + + // Use topLeft and bottomRight to ensure correct coordinate ordering + const tl = sel.topLeft(&self.io.terminal.screen); + const br = sel.bottomRight(&self.io.terminal.screen); + try self.io.terminal.screen.dumpString( buf_writer.writer(), .{ - .tl = sel.start(), - .br = sel.end(), + .tl = tl, + .br = br, .unwrap = true, }, ); From a1f7a957636383fb1abf71088802028983a061bf Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Tue, 31 Dec 2024 01:14:46 +0800 Subject: [PATCH 029/135] Add pin order assertion in Pin.pageIterator --- src/terminal/PageList.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index ca928fda6..5fb49ea66 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3413,6 +3413,16 @@ pub const Pin = struct { direction: Direction, limit: ?Pin, ) PageIterator { + if (build_config.slow_runtime_safety) { + if (limit) |l| { + // Check the order according to the iteration direction. + switch (direction) { + .right_down => assert(self.eql(l) or self.before(l)), + .left_up => assert(self.eql(l) or l.before(self)), + } + } + } + return .{ .row = self, .limit = if (limit) |p| .{ .row = p } else .{ .none = {} }, From c87e3e98a3414b105cc89762927b15e070311ad6 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Mon, 30 Dec 2024 18:37:59 +0100 Subject: [PATCH 030/135] 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 ade07c4c3ce76f0a1b46071d744478a1478a1bab Mon Sep 17 00:00:00 2001 From: Damien Mehala Date: Sun, 29 Dec 2024 18:12:08 +0100 Subject: [PATCH 031/135] fix: quick terminal `focus-follows-mouse` behaviour Quick Terminal now focuses on the surface under the mouse pointer when `focus-follows-mouse` is enabled. Fixes #3337 --- .../Features/Terminal/BaseTerminalController.swift | 9 ++++++++- .../Features/Terminal/TerminalController.swift | 5 ----- macos/Sources/Features/Terminal/TerminalWindow.swift | 2 -- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 11 ++++++----- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 68c243004..5342663d6 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -45,6 +45,11 @@ class BaseTerminalController: NSWindowController, didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } + /// Whether the terminal surface should focus when the mouse is over it. + var focusFollowsMouse: Bool { + self.derivedConfig.focusFollowsMouse + } + /// Non-nil when an alert is active so we don't overlap multiple. private var alert: NSAlert? = nil @@ -262,7 +267,6 @@ class BaseTerminalController: NSWindowController, // Set the main window title window.title = to - } func pwdDidChange(to: URL?) { @@ -604,15 +608,18 @@ class BaseTerminalController: NSWindowController, private struct DerivedConfig { let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let windowStepResize: Bool + let focusFollowsMouse: Bool init() { self.macosTitlebarProxyIcon = .visible self.windowStepResize = false + self.focusFollowsMouse = false } init(_ config: Ghostty.Config) { self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon self.windowStepResize = config.windowStepResize + self.focusFollowsMouse = config.focusFollowsMouse } } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7fd1802dc..13a1a3b53 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -117,9 +117,6 @@ class TerminalController: BaseTerminalController { // Update our derived config self.derivedConfig = DerivedConfig(config) - guard let window = window as? TerminalWindow else { return } - window.focusFollowsMouse = config.focusFollowsMouse - // If we have no surfaces in our window (is that possible?) then we update // our window appearance based on the root config. If we have surfaces, we // don't call this because the TODO @@ -422,8 +419,6 @@ class TerminalController: BaseTerminalController { } } - window.focusFollowsMouse = config.focusFollowsMouse - // Apply any additional appearance-related properties to the new window. We // apply this based on the root config but change it later based on surface // config (see focused surface change callback). diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 503e76791..35f629bfd 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -414,8 +414,6 @@ class TerminalWindow: NSWindow { } } - var focusFollowsMouse: Bool = false - // Find the NSTextField responsible for displaying the titlebar's title. private var titlebarTextField: NSTextField? { guard let titlebarView = titlebarContainer?.subviews diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 60de024d3..2cac4a0dd 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -617,11 +617,12 @@ extension Ghostty { let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) - // If focus follows mouse is enabled then move focus to this surface. - if let window = self.window as? TerminalWindow, - window.isKeyWindow && - window.focusFollowsMouse && - !self.focused + // Handle focus-follows-mouse + if let window, + let controller = window.windowController as? BaseTerminalController, + (window.isKeyWindow && + !self.focused && + controller.focusFollowsMouse) { Ghostty.moveFocus(to: self) } From 31f101c97051be3b7482fa9828d2b329ae8288a7 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 30 Dec 2024 14:39:07 -0500 Subject: [PATCH 032/135] Revert "coretext: exclude bitmap fonts from discovery" This reverts commit 322f166ca50eb495aba3e49d24fda280e2b0a759. --- src/font/discovery.zig | 49 ++++-------------------------------------- 1 file changed, 4 insertions(+), 45 deletions(-) diff --git a/src/font/discovery.zig b/src/font/discovery.zig index a42055d5a..e73ea626f 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -362,16 +362,9 @@ pub const CoreText = struct { const list = set.createMatchingFontDescriptors(); defer list.release(); - // Bring the list of descriptors in to zig land - var zig_list = try copyMatchingDescriptors(alloc, list); - errdefer alloc.free(zig_list); - - // Filter them. We don't use `CTFontCollectionSetExclusionDescriptors` - // to do this because that requires a mutable collection. This way is - // much more straight forward. - zig_list = try alloc.realloc(zig_list, filterDescriptors(zig_list)); - // Sort our descriptors + const zig_list = try copyMatchingDescriptors(alloc, list); + errdefer alloc.free(zig_list); sortMatchingDescriptors(&desc, zig_list); return DiscoverIterator{ @@ -558,47 +551,13 @@ pub const CoreText = struct { for (0..result.len) |i| { result[i] = list.getValueAtIndex(macos.text.FontDescriptor, i); - // We need to retain because once the list - // is freed it will release all its members. + // We need to retain becauseonce the list is freed it will + // release all its members. result[i].retain(); } return result; } - /// Filter any descriptors out of the list that aren't acceptable for - /// some reason or another (e.g. the font isn't in a format we can handle). - /// - /// Invalid descriptors are filled in from the end of - /// the list and the new length for the list is returned. - fn filterDescriptors(list: []*macos.text.FontDescriptor) usize { - var end = list.len; - var i: usize = 0; - while (i < end) { - if (validDescriptor(list[i])) { - i += 1; - } else { - list[i].release(); - end -= 1; - list[i] = list[end]; - } - } - return end; - } - - /// Used by `filterDescriptors` to decide whether a descriptor is valid. - fn validDescriptor(desc: *macos.text.FontDescriptor) bool { - if (desc.copyAttribute(macos.text.FontAttribute.format)) |format| { - defer format.release(); - var value: c_int = undefined; - assert(format.getValue(.int, &value)); - - // Bitmap fonts are not currently supported. - if (value == macos.text.c.kCTFontFormatBitmap) return false; - } - - return true; - } - fn sortMatchingDescriptors( desc: *const Descriptor, list: []*macos.text.FontDescriptor, From 7a4215abd7fff703a122e2f2f5afd270ed7b988a Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 30 Dec 2024 14:44:30 -0500 Subject: [PATCH 033/135] font/coretext: properly resolve metrics for bitmap-only fonts macOS bitmap-only fonts are a poorly documented format, which are often distributed as `.dfont` or `.dfon` files. They use a 'bhed' table in place of the usual 'head', but the table format is byte-identical, so enabling the use of bitmap-only fonts only requires us to properly fetch this table while calculating metrics. ref: https://fontforge.org/docs/techref/bitmaponlysfnt.html --- src/font/face/coretext.zig | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 92ab4d396..dd4f6432e 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -515,8 +515,17 @@ pub const Face = struct { fn calcMetrics(ct_font: *macos.text.Font) CalcMetricsError!font.face.Metrics { // Read the 'head' table out of the font data. const head: opentype.Head = head: { - const tag = macos.text.FontTableTag.init("head"); - const data = ct_font.copyTable(tag) orelse return error.CopyTableError; + // macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but + // the table format is byte-identical to the 'head' table, so if we + // can't find 'head' we try 'bhed' instead before failing. + // + // ref: https://fontforge.org/docs/techref/bitmaponlysfnt.html + const head_tag = macos.text.FontTableTag.init("head"); + const bhed_tag = macos.text.FontTableTag.init("bhed"); + const data = + ct_font.copyTable(head_tag) orelse + ct_font.copyTable(bhed_tag) orelse + return error.CopyTableError; defer data.release(); const ptr = data.getPointer(); const len = data.getLength(); From a6eec4cbe27b5d45f034f3bfb68d81308042640a Mon Sep 17 00:00:00 2001 From: acsetter Date: Mon, 30 Dec 2024 12:33:17 -0500 Subject: [PATCH 034/135] feat: support for short hex colors in config --- 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 a2f71c0c0..4a06e22ac 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3797,17 +3797,22 @@ pub const Color = struct { pub fn fromHex(input: []const u8) !Color { // Trim the beginning '#' if it exists const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input; + if (trimmed.len != 6 and trimmed.len != 3) return error.InvalidValue; - // We expect exactly 6 for RRGGBB - if (trimmed.len != 6) return error.InvalidValue; + // Expand short hex values to full hex values + const rgb: []const u8 = if (trimmed.len == 3) &.{ + trimmed[0], trimmed[0], + trimmed[1], trimmed[1], + trimmed[2], trimmed[2], + } else trimmed; // Parse the colors two at a time. var result: Color = undefined; comptime var i: usize = 0; inline while (i < 6) : (i += 2) { const v: u8 = - ((try std.fmt.charToDigit(trimmed[i], 16)) * 16) + - try std.fmt.charToDigit(trimmed[i + 1], 16); + ((try std.fmt.charToDigit(rgb[i], 16)) * 16) + + try std.fmt.charToDigit(rgb[i + 1], 16); @field(result, switch (i) { 0 => "r", @@ -3827,6 +3832,8 @@ pub const Color = struct { try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("#0A0B0C")); try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("0A0B0C")); try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFFFFF")); + try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFF")); + try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.fromHex("#345")); } test "parseCLI from name" { From 41df2d980590de51c874783b2e9795dbb839c025 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Dec 2024 12:01:31 -0800 Subject: [PATCH 035/135] font/freetype: hardcode DPI in test to avoid variation between OS --- src/font/face/freetype.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index d63cf99f1..630eaee25 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -1029,7 +1029,11 @@ test "bitmap glyph" { defer atlas.deinit(alloc); // Any glyph at 12pt @ 96 DPI is a bitmap - var ft_font = try Face.init(lib, testFont, .{ .size = .{ .points = 12 } }); + var ft_font = try Face.init(lib, testFont, .{ .size = .{ + .points = 12, + .xdpi = 96, + .ydpi = 96, + } }); defer ft_font.deinit(); // glyph 77 = 'i' From e9edd21bed27144c2bae6f9cc098b702bf8cb3c0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Dec 2024 12:20:57 -0800 Subject: [PATCH 036/135] os: don't return stack memory A regression from adcaff7137ef --- src/os/open.zig | 54 +++++++++++++++++++++++-------------------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/src/os/open.zig b/src/os/open.zig index ff7d6049a..f6dc7ca2a 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -18,7 +18,31 @@ pub fn open( typ: Type, url: []const u8, ) !void { - const cmd = try openCommand(alloc, typ, url); + const cmd: OpenCommand = switch (builtin.os.tag) { + .linux => .{ .child = std.process.Child.init( + &.{ "xdg-open", url }, + alloc, + ) }, + + .windows => .{ .child = std.process.Child.init( + &.{ "rundll32", "url.dll,FileProtocolHandler", url }, + alloc, + ) }, + + .macos => .{ + .child = std.process.Child.init( + switch (typ) { + .text => &.{ "open", "-t", url }, + .unknown => &.{ "open", url }, + }, + alloc, + ), + .wait = true, + }, + + .ios => return error.Unimplemented, + else => @compileError("unsupported OS"), + }; var exe = cmd.child; if (cmd.wait) { @@ -53,31 +77,3 @@ const OpenCommand = struct { child: std.process.Child, wait: bool = false, }; - -fn openCommand(alloc: Allocator, typ: Type, url: []const u8) !OpenCommand { - return switch (builtin.os.tag) { - .linux => .{ .child = std.process.Child.init( - &.{ "xdg-open", url }, - alloc, - ) }, - - .windows => .{ .child = std.process.Child.init( - &.{ "rundll32", "url.dll,FileProtocolHandler", url }, - alloc, - ) }, - - .macos => .{ - .child = std.process.Child.init( - switch (typ) { - .text => &.{ "open", "-t", url }, - .unknown => &.{ "open", url }, - }, - alloc, - ), - .wait = true, - }, - - .ios => return error.Unimplemented, - else => @compileError("unsupported OS"), - }; -} From bdeb93fe8701d38c50aedbf05148e16e31629349 Mon Sep 17 00:00:00 2001 From: kaizo Date: Mon, 30 Dec 2024 15:23:16 -0500 Subject: [PATCH 037/135] Fix clipboard confirmation window typo --- src/apprt/gtk/ClipboardConfirmationWindow.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index 30b38f1d4..11c68da7e 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -238,7 +238,7 @@ fn promptText(req: apprt.ClipboardRequest) [:0]const u8 { \\Pasting this text into the terminal may be dangerous as it looks like some commands may be executed. , .osc_52_read => - \\An appliclication is attempting to read from the clipboard. + \\An application is attempting to read from the clipboard. \\The current clipboard contents are shown below. , .osc_52_write => From ffe1b7a8722c8e12dfe45178a2ad933048aed655 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 30 Dec 2024 14:44:56 -0600 Subject: [PATCH 038/135] gtk: don't use gtk_window_set_titlebar if adwaita is enabled but it's older than 1.4.0 --- src/apprt/gtk/Window.zig | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index ea863051c..f3d53e4eb 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -216,6 +216,14 @@ pub fn init(self: *Window, app: *App) !void { } } + // If Adwaita is enabled and is older than 1.4.0 we don't have the tab overview and so we + // need to stick the headerbar into the content box. + if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { + if (self.header) |h| { + c.gtk_box_append(@ptrCast(box), h.asWidget()); + } + } + // In debug we show a warning and apply the 'devel' class to the window. // This is a really common issue where people build from source in debug and performance is really bad. if (comptime std.debug.runtime_safety) { @@ -363,8 +371,17 @@ pub fn init(self: *Window, app: *App) !void { } // The box is our main child - c.gtk_window_set_child(gtk_window, box); - if (self.header) |h| c.gtk_window_set_titlebar(gtk_window, h.asWidget()); + if (!adwaita.versionAtLeast(1, 4, 0) and adwaita.enabled(&self.app.config)) { + c.adw_application_window_set_content( + @ptrCast(gtk_window), + box, + ); + } else { + c.gtk_window_set_child(gtk_window, box); + if (self.header) |h| { + c.gtk_window_set_titlebar(gtk_window, h.asWidget()); + } + } } // Show the window From dd41a9447d7a2b5a61e3c858bebaf18d4a6a7269 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Dec 2024 13:43:48 -0800 Subject: [PATCH 039/135] macOS: weak self for event monitor to avoid retain cycle for controllers Fixes #3219 We were holding a reference cycle to the base terminal controller. This was preventing the window from ever being fully deallocated. --- .../Sources/Features/Terminal/BaseTerminalController.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 54d505db9..01b211730 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -111,8 +111,8 @@ class BaseTerminalController: NSWindowController, // Listen for local events that we need to know of outside of // single surface handlers. self.eventMonitor = NSEvent.addLocalMonitorForEvents( - matching: [.flagsChanged], - handler: localEventHandler) + matching: [.flagsChanged] + ) { [weak self] event in self?.localEventHandler(event) } } deinit { @@ -160,7 +160,7 @@ class BaseTerminalController: NSWindowController, } // MARK: Notifications - + @objc private func didChangeScreenParametersNotification(_ notification: Notification) { // If we have a window that is visible and it is outside the bounds of the // screen then we clamp it back to within the screen. From 220d40e99ae189a9ef064bd252dc691dd0bf5f62 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 30 Dec 2024 16:09:07 -0600 Subject: [PATCH 040/135] gtk: make sure that window-decoration is honored on all paths --- src/apprt/gtk/Window.zig | 8 +++----- src/apprt/gtk/headerbar.zig | 4 ++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index f3d53e4eb..98bea6da2 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -156,6 +156,9 @@ pub fn init(self: *Window, app: *App) !void { if (app.config.@"gtk-titlebar") { const header = HeaderBar.init(self); + // If we are not decorated then we hide the titlebar. + header.setVisible(app.config.@"window-decoration"); + { const btn = c.gtk_menu_button_new(); c.gtk_widget_set_tooltip_text(btn, "Main Menu"); @@ -298,11 +301,6 @@ pub fn init(self: *Window, app: *App) !void { if (self.header) |header| { const header_widget = header.asWidget(); c.adw_toolbar_view_add_top_bar(toolbar_view, header_widget); - - // If we are not decorated then we hide the titlebar. - if (!app.config.@"window-decoration") { - c.gtk_widget_set_visible(header_widget, 0); - } } if (self.app.config.@"gtk-tabs-location" != .hidden) { diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig index b1567ce27..87dfa8d1a 100644 --- a/src/apprt/gtk/headerbar.zig +++ b/src/apprt/gtk/headerbar.zig @@ -30,6 +30,10 @@ pub const HeaderBar = union(enum) { return .{ .gtk = @ptrCast(headerbar) }; } + pub fn setVisible(self: HeaderBar, visible: bool) void { + c.gtk_widget_set_visible(self.asWidget(), if (visible) 1 else 0); + } + pub fn asWidget(self: HeaderBar) *c.GtkWidget { return switch (self) { .adw => |headerbar| @ptrCast(@alignCast(headerbar)), From 98d77788f4b233d422606482294cef8142c235b5 Mon Sep 17 00:00:00 2001 From: Leah Amelia Chen Date: Sat, 28 Dec 2024 00:14:21 +0800 Subject: [PATCH 041/135] feat(config): generate default template when config file is not found Closes #3203 --- src/config/Config.zig | 64 ++++++++++++++++++++++++++++++-------- src/config/config-template | 43 +++++++++++++++++++++++++ src/config/edit.zig | 8 +---- 3 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 src/config/config-template diff --git a/src/config/Config.zig b/src/config/Config.zig index 4a06e22ac..eb3d28d95 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2668,18 +2668,40 @@ pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { try self.expandPaths(std.fs.path.dirname(path).?); } +pub const OptionalFileAction = enum { loaded, not_found, @"error" }; + /// Load optional configuration file from `path`. All errors are ignored. -pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void { - self.loadFile(alloc, path) catch |err| switch (err) { - error.FileNotFound => std.log.info( - "optional config file not found, not loading path={s}", - .{path}, - ), - else => std.log.warn( - "error reading optional config file, not loading err={} path={s}", - .{ err, path }, - ), - }; +/// +/// Returns the action that was taken. +pub fn loadOptionalFile( + self: *Config, + alloc: Allocator, + path: []const u8, +) OptionalFileAction { + if (self.loadFile(alloc, path)) { + return .loaded; + } else |err| switch (err) { + error.FileNotFound => return .not_found, + else => { + std.log.warn( + "error reading optional config file, not loading err={} path={s}", + .{ err, path }, + ); + + return .@"error"; + }, + } +} + +fn writeConfigTemplate(path: []const u8) !void { + log.info("creating template config file: path={s}", .{path}); + const file = try std.fs.createFileAbsolute(path, .{}); + defer file.close(); + try std.fmt.format( + file.writer(), + @embedFile("./config-template"), + .{ .path = path }, + ); } /// Load configurations from the default configuration files. The default @@ -2688,14 +2710,30 @@ pub fn loadOptionalFile(self: *Config, alloc: Allocator, path: []const u8) void /// On macOS, `$HOME/Library/Application Support/$CFBundleIdentifier/config` /// is also loaded. pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { + // Load XDG first const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); defer alloc.free(xdg_path); - self.loadOptionalFile(alloc, xdg_path); + const xdg_action = self.loadOptionalFile(alloc, xdg_path); + // On macOS load the app support directory as well if (comptime builtin.os.tag == .macos) { const app_support_path = try internal_os.macos.appSupportDir(alloc, "config"); defer alloc.free(app_support_path); - self.loadOptionalFile(alloc, app_support_path); + const app_support_action = self.loadOptionalFile(alloc, app_support_path); + + // If both files are not found, then we create a template file. + // For macOS, we only create the template file in the app support + if (app_support_action == .not_found and xdg_action == .not_found) { + writeConfigTemplate(app_support_path) catch |err| { + log.warn("error creating template config file err={}", .{err}); + }; + } + } else { + if (xdg_action == .not_found) { + writeConfigTemplate(xdg_path) catch |err| { + log.warn("error creating template config file err={}", .{err}); + }; + } } } diff --git a/src/config/config-template b/src/config/config-template new file mode 100644 index 000000000..4645e60aa --- /dev/null +++ b/src/config/config-template @@ -0,0 +1,43 @@ +# This is the configuration file for Ghostty. +# +# This template file has been automatically created at the following +# path since Ghostty couldn't find any existing config files on your system: +# +# {[path]s} +# +# The template does not set any default options, since Ghostty ships +# with sensible defaults for all options. Users should only need to set +# options that they want to change from the default. +# +# Run `ghostty +show-config --default --docs` to view a list of +# all available config options and their default values. +# +# Additionally, each config option is also explained in detail +# on Ghostty's website, at https://ghostty.org/docs/config. + +# Config syntax crash course +# ========================== +# # The config file consists of simple key-value pairs, +# # separated by equals signs. +# font-family = Iosevka +# window-padding-x = 2 +# +# # Spacing around the equals sign does not matter. +# # All of these are identical: +# key=value +# key= value +# key =value +# key = value +# +# # Any line beginning with a # is a comment. It's not possible to put +# # a comment after a config option, since it would be interpreted as a +# # part of the value. For example, this will have a value of "#123abc": +# background = #123abc +# +# # Empty values are used to reset config keys to default. +# key = +# +# # Some config options have unique syntaxes for their value, +# # which is explained in the docs for that config option. +# # Just for example: +# resize-overlay-duration = 4s 200ms diff --git a/src/config/edit.zig b/src/config/edit.zig index 68d9da88c..871a1a755 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -48,13 +48,7 @@ pub fn open(alloc_gpa: Allocator) !void { /// /// The allocator must be an arena allocator. No memory is freed by this /// function and the resulting path is not all the memory that is allocated. -/// -/// NOTE: WHY IS THIS INLINE? This is inline because when this is not -/// inline then Zig 0.13 crashes [most of the time] when trying to compile -/// this file. This is a workaround for that issue. This function is only -/// called from one place that is not performance critical so it is fine -/// to be inline. -inline fn configPath(alloc_arena: Allocator) ![]const u8 { +fn configPath(alloc_arena: Allocator) ![]const u8 { const paths: []const []const u8 = try configPathCandidates(alloc_arena); assert(paths.len > 0); From f97f7e8a707024f5d88a1ffbfcea1554947b8bc4 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 30 Dec 2024 19:40:13 -0600 Subject: [PATCH 042/135] gtk: also add css window-decorated class when toggling window decorations --- src/apprt/gtk/Window.zig | 12 +++++++++--- src/apprt/gtk/headerbar.zig | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 98bea6da2..c9e274ea0 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -514,13 +514,19 @@ pub fn toggleWindowDecorations(self: *Window) void { const new_decorated = !old_decorated; c.gtk_window_set_decorated(self.window, @intFromBool(new_decorated)); + // Fix any artifacting that may occur in window corners. + if (new_decorated) { + c.gtk_widget_add_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar"); + } else { + c.gtk_widget_remove_css_class(@ptrCast(self.window), "without-window-decoration-and-with-titlebar"); + } + // If we have a titlebar, then we also show/hide it depending on the // decorated state. GTK tends to consider the titlebar part of the frame // and hides it with decorations, but libadwaita doesn't. This makes it // explicit. - if (self.header) |v| { - const widget = v.asWidget(); - c.gtk_widget_set_visible(widget, @intFromBool(new_decorated)); + if (self.header) |headerbar| { + headerbar.setVisible(new_decorated); } } diff --git a/src/apprt/gtk/headerbar.zig b/src/apprt/gtk/headerbar.zig index 87dfa8d1a..5bb92aca2 100644 --- a/src/apprt/gtk/headerbar.zig +++ b/src/apprt/gtk/headerbar.zig @@ -31,7 +31,7 @@ pub const HeaderBar = union(enum) { } pub fn setVisible(self: HeaderBar, visible: bool) void { - c.gtk_widget_set_visible(self.asWidget(), if (visible) 1 else 0); + c.gtk_widget_set_visible(self.asWidget(), @intFromBool(visible)); } pub fn asWidget(self: HeaderBar) *c.GtkWidget { From 3971c460d132aa955d72f21f3868c16fdfe758f3 Mon Sep 17 00:00:00 2001 From: Hugo Gouveia Date: Mon, 30 Dec 2024 23:05:21 -0300 Subject: [PATCH 043/135] 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 044/135] 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 aa81c16ba153764ec9b003950aec2d7ea18c5f1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Dec 2024 20:56:13 -0800 Subject: [PATCH 045/135] input: parse triggers with codepoints that map to keys as translated Fixes #4146 This makes it so that keys such as `cmd+1` and `cmd+one` are identical. --- src/config/Config.zig | 6 ++++-- src/input/Binding.zig | 21 +++++++++++++++++++++ src/input/key.zig | 4 +++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index eb3d28d95..7d9c7d8cc 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4746,9 +4746,11 @@ pub const Keybinds = struct { try list.parseCLI(alloc, "ctrl+z>2=goto_tab:2"); try list.formatEntry(formatterpkg.entryFormatter("keybind", buf.writer())); + // Note they turn into translated keys because they match + // their ASCII mapping. const want = - \\keybind = ctrl+z>1=goto_tab:1 - \\keybind = ctrl+z>2=goto_tab:2 + \\keybind = ctrl+z>two=goto_tab:2 + \\keybind = ctrl+z>one=goto_tab:1 \\ ; try std.testing.expectEqualStrings(want, buf.items); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 85721339d..b2c03b674 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1019,6 +1019,14 @@ pub const Trigger = struct { const cp = it.nextCodepoint() orelse break :unicode; if (it.nextCodepoint() != null) break :unicode; + // If this is ASCII and we have a translated key, set that. + if (std.math.cast(u8, cp)) |ascii| { + if (key.Key.fromASCII(ascii)) |k| { + result.key = .{ .translated = k }; + continue :loop; + } + } + result.key = .{ .unicode = cp }; continue :loop; } @@ -1554,6 +1562,19 @@ test "parse: triggers" { try parseSingle("a=ignore"), ); + // unicode keys that map to translated + try testing.expectEqual(Binding{ + .trigger = .{ .key = .{ .translated = .one } }, + .action = .{ .ignore = {} }, + }, try parseSingle("1=ignore")); + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .super = true }, + .key = .{ .translated = .period }, + }, + .action = .{ .ignore = {} }, + }, try parseSingle("cmd+.=ignore")); + // single modifier try testing.expectEqual(Binding{ .trigger = .{ diff --git a/src/input/key.zig b/src/input/key.zig index eb2526593..a875611d0 100644 --- a/src/input/key.zig +++ b/src/input/key.zig @@ -729,7 +729,9 @@ pub const Key = enum(c_int) { .{ '\t', .tab }, // Keypad entries. We just assume keypad with the kp_ prefix - // so that has some special meaning. These must also always be last. + // so that has some special meaning. These must also always be last, + // so that our `fromASCII` function doesn't accidentally map them + // over normal numerics and other keys. .{ '0', .kp_0 }, .{ '1', .kp_1 }, .{ '2', .kp_2 }, From 789e2024a5ac3e1dc6431d1f84a993d2fff7e813 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Dec 2024 21:30:30 -0800 Subject: [PATCH 046/135] config: fix segfault if font-family is reset via the CLI Fixes #4149 --- src/config/Config.zig | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 7d9c7d8cc..91c07cc78 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2843,17 +2843,21 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { // replace the entire list with the new list. inline for (fields, 0..) |field, i| { const v = &@field(self, field); - const len = v.list.items.len - counter[i]; - if (len > 0) { - // Note: we don't have to worry about freeing the memory - // that we overwrite or cut off here because its all in - // an arena. - v.list.replaceRangeAssumeCapacity( - 0, - len, - v.list.items[counter[i]..], - ); - v.list.items.len = len; + + // The list can be empty if it was reset, i.e. --font-family="" + if (v.list.items.len > 0) { + const len = v.list.items.len - counter[i]; + if (len > 0) { + // Note: we don't have to worry about freeing the memory + // that we overwrite or cut off here because its all in + // an arena. + v.list.replaceRangeAssumeCapacity( + 0, + len, + v.list.items[counter[i]..], + ); + v.list.items.len = len; + } } } } From 12a333dfb4c6e97ab47a224a90d5d7562b906fb2 Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Tue, 31 Dec 2024 05:40:18 +0000 Subject: [PATCH 047/135] 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 4152b6f2f..0eb9d0eaa 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/08df2e8a72dde535f8d17d8115fe792dbcdc6f35.tar.gz", - .hash = "1220d1090ac2edf1e47059b592403deacd56e7289c7ad8744ed20dd2f297596744b8", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e030599a6a6e19fcd1ea047c7714021170129d56.tar.gz", + .hash = "1220cc25b537556a42b0948437c791214c229efb78b551c80b1e9b18d70bf0498620", }, .vaxis = .{ .url = "git+https://github.com/rockorager/libvaxis/?ref=main#6d729a2dc3b934818dffe06d2ba3ce02841ed74b", diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 6a4c8e443..d4d451e03 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-lfXkAoEd0lB25YV2cB4VVe+CI01DDY5PVOtvq//Qw/U=" +"sha256-ot5onG1yq7EWQkNUgTNBuqvsnLuaoFs2UDS96IqgJmU=" From 85ed9b626ed5b04449702348c784838cc8e915da Mon Sep 17 00:00:00 2001 From: Maciej Bartczak <39600846+maciekbartczak@users.noreply.github.com> Date: Tue, 31 Dec 2024 09:36:23 +0100 Subject: [PATCH 048/135] explicitly handle bool values --- src/build/fish_completions.zig | 2 + src/build/zsh_completions.zig | 160 +++++++++++++++++---------------- 2 files changed, 87 insertions(+), 75 deletions(-) diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index 454036724..b75c4dd16 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -56,6 +56,7 @@ fn writeFishCompletions(writer: anytype) !void { else { try writer.writeAll(if (field.type != Config.RepeatablePath) " -f" else " -F"); switch (@typeInfo(field.type)) { + .Bool => {}, .Enum => |info| { try writer.writeAll(" -a \""); for (info.fields, 0..) |f, i| { @@ -113,6 +114,7 @@ fn writeFishCompletions(writer: anytype) !void { } else try writer.writeAll(" -f"); switch (@typeInfo(opt.type)) { + .Bool => {}, .Enum => |info| { try writer.writeAll(" -a \""); for (info.fields, 0..) |f, i| { diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig index 27d3bedf3..5c42ea5ab 100644 --- a/src/build/zsh_completions.zig +++ b/src/build/zsh_completions.zig @@ -7,6 +7,8 @@ const Action = @import("../cli/action.zig").Action; /// and options. pub const zsh_completions = comptimeGenerateZshCompletions(); +const equals_required = "=-:::"; + fn comptimeGenerateZshCompletions() []const u8 { comptime { @setEvalBranchQuota(50000); @@ -48,50 +50,58 @@ fn writeZshCompletions(writer: anytype) !void { try writer.writeAll(" \"--"); try writer.writeAll(field.name); - if (@typeInfo(field.type) != .Bool) { - try writer.writeAll("=-:::"); - - if (std.mem.startsWith(u8, field.name, "font-family")) - try writer.writeAll("_fonts") - else if (std.mem.eql(u8, "theme", field.name)) - try writer.writeAll("_themes") - else if (std.mem.eql(u8, "working-directory", field.name)) - try writer.writeAll("{_files -/}") - else if (field.type == Config.RepeatablePath) - try writer.writeAll("_files") // todo check if this is needed - else { - try writer.writeAll("("); - switch (@typeInfo(field.type)) { - .Enum => |info| { + if (std.mem.startsWith(u8, field.name, "font-family")) { + try writer.writeAll(equals_required); + try writer.writeAll("_fonts"); + } else if (std.mem.eql(u8, "theme", field.name)) { + try writer.writeAll(equals_required); + try writer.writeAll("_themes"); + } else if (std.mem.eql(u8, "working-directory", field.name)) { + try writer.writeAll(equals_required); + try writer.writeAll("{_files -/}"); + } else if (field.type == Config.RepeatablePath) { + try writer.writeAll(equals_required); + try writer.writeAll("_files"); // todo check if this is needed + } else { + switch (@typeInfo(field.type)) { + .Bool => {}, + .Enum => |info| { + try writer.writeAll(equals_required); + try writer.writeAll("("); + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + try writer.writeAll(")"); + }, + .Struct => |info| { + try writer.writeAll(equals_required); + if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") { + try writer.writeAll("("); for (info.fields, 0..) |f, i| { if (i > 0) try writer.writeAll(" "); try writer.writeAll(f.name); + try writer.writeAll(" no-"); + try writer.writeAll(f.name); } - }, - .Struct => |info| { - if (!@hasDecl(field.type, "parseCLI") and info.layout == .@"packed") { - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - try writer.writeAll(" no-"); - try writer.writeAll(f.name); - } - } else { - //resize-overlay-duration - //keybind - //window-padding-x ...-y - //link - //palette - //background - //foreground - //font-variation* - //font-feature - try writer.writeAll(" "); - } - }, - else => try writer.writeAll(" "), - } - try writer.writeAll(")"); + try writer.writeAll(")"); + } else { + //resize-overlay-duration + //keybind + //window-padding-x ...-y + //link + //palette + //background + //foreground + //font-variation* + //font-feature + try writer.writeAll("( )"); + } + }, + else => { + try writer.writeAll(equals_required); + try writer.writeAll("( )"); + }, } } @@ -173,41 +183,41 @@ fn writeZshCompletions(writer: anytype) !void { try writer.writeAll(padding ++ " '--"); try writer.writeAll(opt.name); - if (@typeInfo(opt.type) != .Bool) { - try writer.writeAll("=-:::"); - - switch (@typeInfo(opt.type)) { - .Enum => |info| { - try writer.writeAll("("); - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - try writer.writeAll(")"); - }, - .Optional => |optional| { - switch (@typeInfo(optional.child)) { - .Enum => |info| { - try writer.writeAll("("); - for (info.fields, 0..) |f, i| { - if (i > 0) try writer.writeAll(" "); - try writer.writeAll(f.name); - } - try writer.writeAll(")"); - }, - else => { - if (std.mem.eql(u8, "config-file", opt.name)) { - try writer.writeAll("_files"); - } else try writer.writeAll("( )"); - }, - } - }, - else => { - if (std.mem.eql(u8, "config-file", opt.name)) { - try writer.writeAll("_files"); - } else try writer.writeAll("( )"); - }, - } + switch (@typeInfo(opt.type)) { + .Bool => {}, + .Enum => |info| { + try writer.writeAll(equals_required); + try writer.writeAll("("); + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + try writer.writeAll(")"); + }, + .Optional => |optional| { + try writer.writeAll(equals_required); + switch (@typeInfo(optional.child)) { + .Enum => |info| { + try writer.writeAll("("); + for (info.fields, 0..) |f, i| { + if (i > 0) try writer.writeAll(" "); + try writer.writeAll(f.name); + } + try writer.writeAll(")"); + }, + else => { + if (std.mem.eql(u8, "config-file", opt.name)) { + try writer.writeAll("_files"); + } else try writer.writeAll("( )"); + }, + } + }, + else => { + try writer.writeAll(equals_required); + if (std.mem.eql(u8, "config-file", opt.name)) { + try writer.writeAll("_files"); + } else try writer.writeAll("( )"); + }, } try writer.writeAll("' \\\n"); From 061a730dd37188c6b6aca7eac66f8d0a9bc108b3 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Tue, 31 Dec 2024 12:06:45 +0100 Subject: [PATCH 049/135] 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 050/135] 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 051/135] 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 052/135] 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 d59a57e133e95aac3536199f3c95380436b026ce Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 31 Dec 2024 07:16:07 -0800 Subject: [PATCH 053/135] `write_*_file` actions default to mode 0600 This commit changes the default filemode for the write actions so that it is only readable and writable by the user running Ghostty. --- src/Surface.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 8c7c2619e..ab761e52a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4242,7 +4242,7 @@ fn writeScreenFile( const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)}); // Open our scrollback file - var file = try tmp_dir.dir.createFile(filename, .{}); + var file = try tmp_dir.dir.createFile(filename, .{ .mode = 0o600 }); defer file.close(); // Screen.dumpString writes byte-by-byte, so buffer it From cf34ffa28e5c387e64d1b0027ba271b7de6a2fd0 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 31 Dec 2024 12:56:18 -0600 Subject: [PATCH 054/135] core: fix windows compile regression from #4021 --- src/Surface.zig | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index ab761e52a..c359efd8a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4242,7 +4242,13 @@ fn writeScreenFile( const filename = try std.fmt.bufPrint(&filename_buf, "{s}.txt", .{@tagName(loc)}); // Open our scrollback file - var file = try tmp_dir.dir.createFile(filename, .{ .mode = 0o600 }); + var file = try tmp_dir.dir.createFile( + filename, + switch (builtin.os.tag) { + .windows => .{}, + else => .{ .mode = 0o600 }, + }, + ); defer file.close(); // Screen.dumpString writes byte-by-byte, so buffer it 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 055/135] 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 5ba8fee38a8f18f6488d58673d2128d801c4f0f4 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 31 Dec 2024 14:35:07 -0500 Subject: [PATCH 056/135] test/terminal: add failing sgr direct color parsing test Behavior checked against xterm --- src/terminal/sgr.zig | 56 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index 7d602714c..7dca98c96 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -566,3 +566,59 @@ test "sgr: direct color bg missing color" { var p: Parser = .{ .params = &[_]u16{ 48, 5 }, .colon = false }; while (p.next()) |_| {} } + +test "sgr: direct fg/bg/underline ignore optional color space" { + // These behaviors have been verified against xterm. + + // Colon version should skip the optional color space identifier + { + // 3 8 : 2 : Pi : Pr : Pg : Pb + const v = testParseColon(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 }); + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 1), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 2), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 3), v.direct_color_fg.b); + } + { + // 4 8 : 2 : Pi : Pr : Pg : Pb + const v = testParseColon(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 }); + try testing.expect(v == .direct_color_bg); + try testing.expectEqual(@as(u8, 1), v.direct_color_bg.r); + try testing.expectEqual(@as(u8, 2), v.direct_color_bg.g); + try testing.expectEqual(@as(u8, 3), v.direct_color_bg.b); + } + { + // 5 8 : 2 : Pi : Pr : Pg : Pb + const v = testParseColon(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 }); + try testing.expect(v == .underline_color); + try testing.expectEqual(@as(u8, 1), v.underline_color.r); + try testing.expectEqual(@as(u8, 2), v.underline_color.g); + try testing.expectEqual(@as(u8, 3), v.underline_color.b); + } + + // Semicolon version should not parse optional color space identifier + { + // 3 8 ; 2 ; Pr ; Pg ; Pb + const v = testParse(&[_]u16{ 38, 2, 0, 1, 2, 3, 4 }); + try testing.expect(v == .direct_color_fg); + try testing.expectEqual(@as(u8, 0), v.direct_color_fg.r); + try testing.expectEqual(@as(u8, 1), v.direct_color_fg.g); + try testing.expectEqual(@as(u8, 2), v.direct_color_fg.b); + } + { + // 4 8 ; 2 ; Pr ; Pg ; Pb + const v = testParse(&[_]u16{ 48, 2, 0, 1, 2, 3, 4 }); + try testing.expect(v == .direct_color_bg); + try testing.expectEqual(@as(u8, 0), v.direct_color_bg.r); + try testing.expectEqual(@as(u8, 1), v.direct_color_bg.g); + try testing.expectEqual(@as(u8, 2), v.direct_color_bg.b); + } + { + // 5 8 ; 2 ; Pr ; Pg ; Pb + const v = testParse(&[_]u16{ 58, 2, 0, 1, 2, 3, 4 }); + try testing.expect(v == .underline_color); + try testing.expectEqual(@as(u8, 0), v.underline_color.r); + try testing.expectEqual(@as(u8, 1), v.underline_color.g); + try testing.expectEqual(@as(u8, 2), v.underline_color.b); + } +} From 4543cdeac867bff23a2239ba5667639fbbfb8a88 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Tue, 31 Dec 2024 14:37:23 -0500 Subject: [PATCH 057/135] fix(terminal): correct SGR direct color parsing --- src/terminal/sgr.zig | 157 ++++++++++++++++++++++++++----------------- 1 file changed, 96 insertions(+), 61 deletions(-) diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index 7dca98c96..cdf39657b 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -189,26 +189,39 @@ pub const Parser = struct { .@"8_fg" = @enumFromInt(slice[0] - 30), }, - 38 => if (slice.len >= 5 and slice[1] == 2) { - self.idx += 4; + 38 => if (slice.len >= 2) switch (slice[1]) { + // `2` indicates direct-color (r, g, b). + // We need at least 3 more params for this to make sense. + 2 => if (slice.len >= 5) { + self.idx += 4; + // When a colon separator is used, there may or may not be + // a color space identifier as the third param, which we + // need to ignore (it has no standardized behavior). + const rgb = if (slice.len == 5 or !self.colon) + slice[2..5] + else rgb: { + self.idx += 1; + break :rgb slice[3..6]; + }; - // In the 6-len form, ignore the 3rd param. - const rgb = slice[2..5]; - - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .direct_color_fg = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - } else if (slice.len >= 3 and slice[1] == 5) { - self.idx += 2; - return Attribute{ - .@"256_fg" = @truncate(slice[2]), - }; + // We use @truncate because the value should be 0 to 255. If + // it isn't, the behavior is undefined so we just... truncate it. + return Attribute{ + .direct_color_fg = .{ + .r = @truncate(rgb[0]), + .g = @truncate(rgb[1]), + .b = @truncate(rgb[2]), + }, + }; + }, + // `5` indicates indexed color. + 5 => if (slice.len >= 3) { + self.idx += 2; + return Attribute{ + .@"256_fg" = @truncate(slice[2]), + }; + }, + else => {}, }, 39 => return Attribute{ .reset_fg = {} }, @@ -217,26 +230,39 @@ pub const Parser = struct { .@"8_bg" = @enumFromInt(slice[0] - 40), }, - 48 => if (slice.len >= 5 and slice[1] == 2) { - self.idx += 4; + 48 => if (slice.len >= 2) switch (slice[1]) { + // `2` indicates direct-color (r, g, b). + // We need at least 3 more params for this to make sense. + 2 => if (slice.len >= 5) { + self.idx += 4; + // When a colon separator is used, there may or may not be + // a color space identifier as the third param, which we + // need to ignore (it has no standardized behavior). + const rgb = if (slice.len == 5 or !self.colon) + slice[2..5] + else rgb: { + self.idx += 1; + break :rgb slice[3..6]; + }; - // We only support the 5-len form. - const rgb = slice[2..5]; - - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .direct_color_bg = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - } else if (slice.len >= 3 and slice[1] == 5) { - self.idx += 2; - return Attribute{ - .@"256_bg" = @truncate(slice[2]), - }; + // We use @truncate because the value should be 0 to 255. If + // it isn't, the behavior is undefined so we just... truncate it. + return Attribute{ + .direct_color_bg = .{ + .r = @truncate(rgb[0]), + .g = @truncate(rgb[1]), + .b = @truncate(rgb[2]), + }, + }; + }, + // `5` indicates indexed color. + 5 => if (slice.len >= 3) { + self.idx += 2; + return Attribute{ + .@"256_bg" = @truncate(slice[2]), + }; + }, + else => {}, }, 49 => return Attribute{ .reset_bg = {} }, @@ -244,30 +270,39 @@ pub const Parser = struct { 53 => return Attribute{ .overline = {} }, 55 => return Attribute{ .reset_overline = {} }, - 58 => if (slice.len >= 5 and slice[1] == 2) { - self.idx += 4; + 58 => if (slice.len >= 2) switch (slice[1]) { + // `2` indicates direct-color (r, g, b). + // We need at least 3 more params for this to make sense. + 2 => if (slice.len >= 5) { + self.idx += 4; + // When a colon separator is used, there may or may not be + // a color space identifier as the third param, which we + // need to ignore (it has no standardized behavior). + const rgb = if (slice.len == 5 or !self.colon) + slice[2..5] + else rgb: { + self.idx += 1; + break :rgb slice[3..6]; + }; - // In the 6-len form, ignore the 3rd param. Otherwise, use it. - const rgb = if (slice.len == 5) slice[2..5] else rgb: { - // Consume one more element - self.idx += 1; - break :rgb slice[3..6]; - }; - - // We use @truncate because the value should be 0 to 255. If - // it isn't, the behavior is undefined so we just... truncate it. - return Attribute{ - .underline_color = .{ - .r = @truncate(rgb[0]), - .g = @truncate(rgb[1]), - .b = @truncate(rgb[2]), - }, - }; - } else if (slice.len >= 3 and slice[1] == 5) { - self.idx += 2; - return Attribute{ - .@"256_underline_color" = @truncate(slice[2]), - }; + // We use @truncate because the value should be 0 to 255. If + // it isn't, the behavior is undefined so we just... truncate it. + return Attribute{ + .underline_color = .{ + .r = @truncate(rgb[0]), + .g = @truncate(rgb[1]), + .b = @truncate(rgb[2]), + }, + }; + }, + // `5` indicates indexed color. + 5 => if (slice.len >= 3) { + self.idx += 2; + return Attribute{ + .@"256_underline_color" = @truncate(slice[2]), + }; + }, + else => {}, }, 59 => return Attribute{ .reset_underline_color = {} }, From 5957e1101c9e51131ca0042beee07e5c67644c06 Mon Sep 17 00:00:00 2001 From: Jan200101 Date: Tue, 31 Dec 2024 17:54:09 +0100 Subject: [PATCH 058/135] 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 059/135] 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 a857d56fb61f8c8cc47b37c9f8245ad69f58721c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 31 Dec 2024 12:43:20 -0800 Subject: [PATCH 060/135] ci: proper blob file setup for source tarballs on release --- .github/workflows/release-tag.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 59be2a17c..431397972 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -360,8 +360,8 @@ jobs: run: | mkdir blob mkdir -p blob/${GHOSTTY_VERSION} - mv "ghostty-${GHOSTTY_VERSION}.tar.gz blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz" - mv ghostty-${GHOSTTY_VERSION}.tar.gz.minisig blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz.minisig + mv "ghostty-${GHOSTTY_VERSION}.tar.gz" blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz + mv "ghostty-${GHOSTTY_VERSION}.tar.gz.minisig" blob/${GHOSTTY_VERSION}/ghostty-${GHOSTTY_VERSION}.tar.gz.minisig mv ghostty-source.tar.gz blob/${GHOSTTY_VERSION}/ghostty-source.tar.gz mv ghostty-source.tar.gz.minisig blob/${GHOSTTY_VERSION}/ghostty-source.tar.gz.minisig mv ghostty-macos-universal.zip blob/${GHOSTTY_VERSION}/ghostty-macos-universal.zip From 3f7c3afaf947280bd2852626ff4599c02d9fb07e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 31 Dec 2024 12:47:38 -0800 Subject: [PATCH 061/135] ci: source tarball files must not be quoted --- .github/workflows/release-tag.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 431397972..cf94bf23e 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -105,8 +105,8 @@ jobs: with: name: source-tarball path: |- - "ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz" - "ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz.minisig" + ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz + ghostty-${{ env.GHOSTTY_VERSION }}.tar.gz.minisig ghostty-source.tar.gz ghostty-source.tar.gz.minisig From 1d71196de35450b8c0586b66bdde612622f6d8b0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 31 Dec 2024 14:11:48 -0800 Subject: [PATCH 062/135] up version to 1.0.2 for development --- build.zig | 2 +- build.zig.zon | 2 +- nix/package.nix | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig b/build.zig index 6b92a095e..3ba8b6b64 100644 --- a/build.zig +++ b/build.zig @@ -43,7 +43,7 @@ comptime { } /// The version of the next release. -const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 1 }; +const app_version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 2 }; pub fn build(b: *std.Build) !void { const optimize = b.standardOptimizeOption(.{}); diff --git a/build.zig.zon b/build.zig.zon index 0eb9d0eaa..5c202e9cd 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = "ghostty", - .version = "1.0.1", + .version = "1.0.2", .paths = .{""}, .dependencies = .{ // Zig libs diff --git a/nix/package.nix b/nix/package.nix index 3c36661bf..78d2e2fdd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -111,7 +111,7 @@ in stdenv.mkDerivation (finalAttrs: { pname = "ghostty"; - version = "1.0.1"; + version = "1.0.2"; inherit src; nativeBuildInputs = [ From 41719aa48c87dddfef40330513801b36fde609aa Mon Sep 17 00:00:00 2001 From: Daniel Fox Date: Tue, 31 Dec 2024 14:26:03 -0800 Subject: [PATCH 063/135] Set the paste preview to monospace --- src/apprt/gtk/ClipboardConfirmationWindow.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index 11c68da7e..f0b60a2c6 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -131,6 +131,7 @@ const PrimaryView = struct { c.gtk_text_view_set_bottom_margin(@ptrCast(text), 8); c.gtk_text_view_set_left_margin(@ptrCast(text), 8); c.gtk_text_view_set_right_margin(@ptrCast(text), 8); + c.gtk_text_view_set_monospace(@ptrCast(text), 1); return .{ .root = view.root, .text = @ptrCast(text) }; } From e9e82d94accd13a9fa6964a897a7c692c2ec2db2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 31 Dec 2024 14:34:16 -0800 Subject: [PATCH 064/135] PACKAGING: Note GLFW is not for distribution --- PACKAGING.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/PACKAGING.md b/PACKAGING.md index 4cea7cf6a..82c7c5673 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -117,3 +117,11 @@ relevant to package maintainers: often necessary for system packages to specify a specific minimum Linux version, glibc, etc. Run `zig targets` to a get a full list of available targets. + +> [!WARNING] +> +> **The GLFW runtime is not meant for distribution.** The GLFW runtime +> (`-Dapp-runtime=glfw`) is meant for development and testing only. It is +> missing many features, has known memory leak scenarios, known crashes, +> and more. Please do not package the GLFW-based Ghostty runtime for +> distribution. 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 065/135] 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 066/135] 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 067/135] 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 068/135] 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 069/135] 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 070/135] 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 071/135] 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 072/135] 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 073/135] 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 074/135] 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 075/135] 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 076/135] 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 077/135] 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 078/135] 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 079/135] 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 080/135] 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 081/135] 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 082/135] 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 083/135] 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 084/135] 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 085/135] 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 086/135] 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 087/135] 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 088/135] 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 089/135] 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 090/135] 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 091/135] 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 092/135] 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 093/135] 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 094/135] 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 095/135] 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 096/135] 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 097/135] 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 098/135] 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 099/135] 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 100/135] 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 101/135] 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 102/135] 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 103/135] 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 104/135] 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 105/135] 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 106/135] 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 107/135] 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 108/135] 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 109/135] 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 110/135] 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 111/135] 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 112/135] 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 113/135] 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 114/135] 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 115/135] 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 116/135] 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 117/135] 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 118/135] 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 119/135] 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 120/135] 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 121/135] 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 122/135] 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 123/135] 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 82695edaff26de7a51c69c01f3d6c69de8c92b58 Mon Sep 17 00:00:00 2001 From: Gabriel Moreno Date: Wed, 1 Jan 2025 14:31:23 -0400 Subject: [PATCH 124/135] 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 125/135] 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 126/135] 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 127/135] 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 128/135] 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 129/135] 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 130/135] 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 131/135] 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 132/135] 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 133/135] 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 134/135] 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 135/135] 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 {